Commit | Line | Data |
---|---|---|
ae711167 CE |
1 | #!/usr/bin/python3 |
2 | # minidsp-lcd-monitor.py --- A simple LCD monitor for the minidsp 2x4HD | |
3 | ||
4 | # Copyright (C) 2022 Clinton Ebadi <clinton@unknownlamer.org> | |
5 | ||
6 | # Author: Clinton Ebadi <clinton@unknownlamer.org> | |
7 | ||
8 | # This program is free software: you can redistribute it and/or modify | |
9 | # it under the terms of the GNU General Public License as published by | |
10 | # the Free Software Foundation, either version 3 of the License, or | |
11 | # (at your option) any later version. | |
12 | ||
13 | # This program is distributed in the hope that it will be useful, | |
14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
16 | # GNU General Public License for more details. | |
17 | ||
18 | # You should have received a copy of the GNU General Public License | |
19 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
20 | ||
21 | # Hardware needed: | |
22 | # | |
23 | # * Adafruit USB+Serial RGB Backlight Character LCD Backpack | |
24 | # <https://learn.adafruit.com/usb-plus-serial-backpack/overview> | |
25 | # <https://learn.adafruit.com/usb-plus-serial-backpack/command-reference> | |
26 | # (hard requirement is a 20x4 character lcd with 5x8 cells) | |
27 | # * Adafruit 20x4 RGB Character LCD (Negative looks cooler but isn't required) | |
28 | # <https://www.adafruit.com/product/498> | |
29 | # | |
30 | # Software dependencies: | |
31 | # | |
32 | # * minidsp-rs daemon with HTTP API enabled | |
33 | # <https://minidsp-rs.pages.dev/daemon/http> | |
34 | ||
35 | # TODO (probably for someone else, patches welcome): | |
36 | # - Configuration file | |
37 | # - Support different sizes of LCDs or at least controlling which data | |
38 | # is shown and on which line. | |
39 | # - More consistent naming scheme for LCDDriver methods | |
40 | # - Remove extra pip from bottom of volume bar? Actual characters | |
41 | # don't use it so might look better that way. | |
42 | ||
43 | import asyncio | |
44 | import json | |
45 | import pprint | |
46 | import serial | |
47 | import sys | |
48 | import time | |
49 | import websockets | |
50 | ||
51 | # Configuration | |
52 | LCD_ROWS = 4 | |
53 | LCD_COLS = 20 | |
54 | LCD_CONTRAST = 255 | |
55 | LCD_BRIGHTNESS = 200 | |
56 | ||
57 | SERIAL_PORT = '/dev/ttyACM0' | |
58 | ||
59 | MINIDSP_WS = 'ws://127.0.0.1:5380/devices/0' | |
60 | ||
61 | # LCD control | |
62 | class LCDDriver: | |
63 | def __init__(self, port = SERIAL_PORT, cols = LCD_COLS, rows = LCD_ROWS, contrast = LCD_CONTRAST, brightness = LCD_BRIGHTNESS): | |
64 | self.lcd_rows = rows | |
65 | self.lcd_cols = cols | |
66 | self.lcd_contrast = contrast | |
67 | self.lcd_brightness = brightness | |
68 | self.serio = serial.Serial(port, 9600, timeout=1) | |
69 | ||
70 | #self.cmd_set_display_size(20, 4) | |
71 | self.cmd_clear_screen () | |
72 | self.cmd_set_contrast (contrast) | |
73 | self.cmd_set_brightness(brightness) | |
74 | self.cmd_backlight_enable () | |
75 | ||
76 | # Low level command sending, use cmd_FOO functions instead | |
77 | def send_command(self, codes): | |
78 | self.serio.write(b'\xFE') | |
79 | self.serio.write(bytes(codes)) | |
80 | ||
81 | # high level interface | |
82 | def write(self, txt): | |
83 | self.serio.write(txt) | |
84 | ||
85 | # https://learn.adafruit.com/usb-plus-serial-backpack/command-reference | |
86 | def cmd_backlight_enable(self, state=True): | |
87 | if state: | |
88 | # Adafruit backpack doesn't support timeout | |
89 | self.send_command([0x42, 0]) | |
90 | else: | |
91 | self.send_command([0x46]) | |
92 | ||
93 | def cmd_autoscroll(self, enable=True): | |
94 | if enable: | |
95 | self.send_command(b'\x51') | |
96 | else: | |
97 | self.send_command(b'\x52') | |
98 | ||
99 | def cmd_set_brightness(self, brightness): | |
100 | self.send_command([0x98, brightness]) | |
101 | self.lcd_brightness = brightness | |
102 | ||
103 | def cmd_set_backlight_color(self, r, g, b): | |
104 | self.send_command([0xD0, r, g, b]) | |
105 | ||
106 | def cmd_set_display_size(self, cols, rows): | |
107 | self.send_command([0xD1, cols, rows]) | |
108 | ||
109 | def cmd_set_contrast(self, contrast): | |
110 | self.send_command([0x50, contrast]) | |
111 | self.lcd_contrast = contrast | |
112 | ||
113 | def cmd_clear_screen (self): | |
114 | self.send_command(b'\x58') | |
115 | ||
116 | def cmd_cursor_set_position(self, col, row): | |
117 | self.send_command([0x47, col, row]) | |
118 | ||
119 | def cmd_cursor_home(self): | |
120 | self.send_command(b'\x48') | |
121 | ||
122 | def cmd_cursor_forward(self): | |
123 | self.send_command(b'\x4A') | |
124 | ||
125 | def cmd_cursor_back(self): | |
126 | self.send_command(b'\x4B') | |
127 | ||
128 | def cmd_character_set(self, slot, chars): | |
129 | self.send_command([0x4E, slot] + chars) | |
130 | ||
131 | class DSPMonitor: | |
132 | # Volume bar characters | |
133 | # | |
134 | # Bars are set every other character column so that they appear even | |
135 | # despite gaps between characters. Using 17 characters and making each | |
136 | # visible bar a 2% increment gives us a max of 102%. | |
137 | custom_bar_1 = [0x0,0x10,0x10,0x10,0x10,0x10,0x10,0x0] | |
138 | custom_bar_2 = [0x0,0x14,0x14,0x14,0x14,0x14,0x14,0x0] | |
139 | custom_bar_3 = [0x0,0x15,0x15,0x15,0x15,0x15,0x15,0x0] | |
140 | custom_dB = [0x6,0x5,0x5,0x6,0xd,0x15,0x15,0x1e] # dB, kind of... | |
141 | ||
142 | minidsp_min_volume_db = -127 | |
143 | ||
144 | def __init__(self, wsurl = MINIDSP_WS): | |
145 | self.wsurl = wsurl | |
146 | self.dspattrs = { 'preset': None, 'source': None, 'volume': None, 'mute': None } | |
147 | self.lcd = LCDDriver () | |
148 | ||
149 | self.lcd.cmd_character_set(0, self.custom_bar_1) | |
150 | self.lcd.cmd_character_set(1, self.custom_bar_2) | |
151 | self.lcd.cmd_character_set(2, self.custom_bar_3) | |
152 | self.lcd.cmd_character_set(3, self.custom_dB) | |
153 | ||
154 | self.lcd.cmd_autoscroll(False) | |
155 | self.lcd.cmd_set_backlight_color(255, 0, 100) | |
156 | self.lcd.cmd_cursor_home() | |
157 | self.lcd.write(b'DSP Monitor Ready') | |
158 | ||
159 | async def run(self): | |
160 | self.lcd.cmd_clear_screen() | |
161 | self.lcd.cmd_cursor_home() | |
162 | self.lcd.write(b'DSP Monitor Connecting...') | |
163 | # pings disabled as minidsp-rs does not appear to support them | |
164 | async for websocket in websockets.connect(self.wsurl, ping_interval=None): | |
165 | try: | |
166 | force_redisplay = True | |
167 | self.lcd.cmd_clear_screen() | |
168 | self.lcd.cmd_cursor_home() | |
169 | async for message in websocket: | |
170 | await self.process_ws_message(message, force_redisplay) | |
171 | force_redisplay = False | |
172 | except websockets.ConnectionClosed: | |
173 | self.lcd.cmd_clear_screen() | |
174 | self.lcd.cmd_cursor_home() | |
175 | self.lcd.write(b'Connection Lost'.center(20)) | |
176 | time.sleep(0.5) | |
177 | continue | |
178 | ||
179 | async def process_ws_message(self,message, force_redisplay=False): | |
180 | print (message); | |
181 | parsed = json.loads(message) | |
182 | pprint.pp (parsed['master']) | |
7faf6913 | 183 | self.update_screen(self.dspattrs | parsed['master'], force=force_redisplay) |
ae711167 CE |
184 | |
185 | def update_screen(self, attrs=None, force=False): | |
186 | if attrs == None: | |
187 | attrs = self.dspattrs | |
7faf6913 | 188 | if attrs['source'] != self.dspattrs['source'] or force: |
ae711167 | 189 | self.draw_source(attrs['source']) |
7faf6913 | 190 | if attrs['preset'] != self.dspattrs['preset'] or force: |
ae711167 | 191 | self.draw_preset(attrs['preset']) |
7faf6913 | 192 | if attrs['volume'] != self.dspattrs['volume'] or attrs['mute'] != self.dspattrs['mute'] or force: |
ae711167 CE |
193 | pct = int(abs((self.minidsp_min_volume_db - attrs['volume']) / abs(self.minidsp_min_volume_db)) * 100) |
194 | print('pct {}, min {}, vol {}'.format(pct, self.minidsp_min_volume_db, attrs['volume'])) | |
195 | self.draw_volume_bar (pct, attrs['mute']) | |
7faf6913 CE |
196 | |
197 | self.dspattrs = attrs | |
ae711167 CE |
198 | |
199 | def draw_source(self, source_name): | |
200 | self.lcd.cmd_cursor_set_position(1,1) | |
201 | self.lcd.write(b'SOURCE: ') | |
859ae23f | 202 | self.lcd.write(bytes(source_name.ljust(12), 'ascii')) |
ae711167 CE |
203 | |
204 | def draw_preset(self, preset_number): | |
205 | self.lcd.cmd_cursor_set_position(1, 2) | |
206 | self.lcd.write(b'PRESET: ') | |
859ae23f | 207 | self.lcd.write(bytes(str(preset_number + 1).ljust(12), 'ascii')) |
ae711167 CE |
208 | |
209 | def draw_volume_bar(self, percentage, muted): | |
210 | full_blocks = int(percentage / 6) | |
211 | partial_ticks = int(percentage % 6 / 2) | |
212 | ||
213 | #self.lcd.cmd_cursor_set_position(1, 3) | |
214 | #self.lcd.write(b'VOLUME:') | |
215 | ||
216 | #self.lcd.cmd_cursor_set_position(1, 4) | |
217 | if muted: | |
218 | self.lcd.cmd_cursor_set_position(1, 3) | |
219 | self.lcd.write(b'MUTED'.center(20)) | |
220 | self.lcd.cmd_cursor_set_position(1, 4) | |
221 | self.lcd.write(b' '.center(20)) | |
222 | else: | |
223 | for row in [3, 4]: | |
224 | self.lcd.cmd_cursor_set_position(1, row) | |
225 | for i in range(full_blocks): | |
226 | self.lcd.write([2]) | |
227 | ||
228 | if partial_ticks == 2: | |
229 | self.lcd.write([1]) | |
230 | elif partial_ticks == 1: | |
231 | self.lcd.write([0]) | |
232 | else: | |
233 | self.lcd.write(b' ') | |
234 | ||
235 | for i in range(18 - full_blocks - 1): | |
236 | self.lcd.write(b' ') | |
237 | ||
238 | self.lcd.cmd_cursor_set_position(18, 4) | |
239 | if percentage == 0: | |
240 | self.lcd.write (b'min') | |
241 | elif percentage == 100: | |
242 | self.lcd.write (b'max') | |
243 | else: | |
244 | self.lcd.write (bytes(f'{percentage:2d}%', 'ascii')) | |
245 | ||
246 | dspmon = DSPMonitor() | |
247 | asyncio.run(dspmon.run()) |