minidsp-lcd-monitor: clear entire line when displaying preset/input
[clinton/scratch.git] / minidsp-lcd-monitor / minidsp-lcd-monitor.py
... / ...
CommitLineData
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
43import asyncio
44import json
45import pprint
46import serial
47import sys
48import time
49import websockets
50
51# Configuration
52LCD_ROWS = 4
53LCD_COLS = 20
54LCD_CONTRAST = 255
55LCD_BRIGHTNESS = 200
56
57SERIAL_PORT = '/dev/ttyACM0'
58
59MINIDSP_WS = 'ws://127.0.0.1:5380/devices/0'
60
61# LCD control
62class 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
131class 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.ljust(12), '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).ljust(12), '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
246dspmon = DSPMonitor()
247asyncio.run(dspmon.run())