minidsp-lcd-monitor: simplify lookup of changed attributes
[clinton/scratch.git] / minidsp-lcd-monitor / minidsp-lcd-monitor.py
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'])
183 self.update_screen(self.dspattrs | parsed['master'], force=force_redisplay)
184
185 def update_screen(self, attrs=None, force=False):
186 if attrs == None:
187 attrs = self.dspattrs
188 if attrs['source'] != self.dspattrs['source'] or force:
189 self.draw_source(attrs['source'])
190 if attrs['preset'] != self.dspattrs['preset'] or force:
191 self.draw_preset(attrs['preset'])
192 if attrs['volume'] != self.dspattrs['volume'] or attrs['mute'] != self.dspattrs['mute'] or force:
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'])
196
197 self.dspattrs = attrs
198
199 def draw_source(self, source_name):
200 self.lcd.cmd_cursor_set_position(1,1)
201 self.lcd.write(b'SOURCE: ')
202 self.lcd.write(bytes(source_name, 'ascii'))
203
204 def draw_preset(self, preset_number):
205 self.lcd.cmd_cursor_set_position(1, 2)
206 self.lcd.write(b'PRESET: ')
207 self.lcd.write(bytes(str(preset_number + 1), 'ascii'))
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())