43a73dc3efdbd7d9870b71e63fb1f3f4af72e69e
2 # minidsp-lcd-monitor.py --- A simple LCD monitor for the minidsp 2x4HD
4 # Copyright (C) 2022 Clinton Ebadi <clinton@unknownlamer.org>
6 # Author: Clinton Ebadi <clinton@unknownlamer.org>
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.
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.
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/>.
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>
30 # Software dependencies:
32 # * minidsp-rs daemon with HTTP API enabled
33 # <https://minidsp-rs.pages.dev/daemon/http>
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.
57 SERIAL_PORT
= '/dev/ttyACM0'
59 MINIDSP_WS
= 'ws://127.0.0.1:5380/devices/0'
63 def __init__(self
, port
= SERIAL_PORT
, cols
= LCD_COLS
, rows
= LCD_ROWS
, contrast
= LCD_CONTRAST
, brightness
= LCD_BRIGHTNESS
):
66 self
.lcd_contrast
= contrast
67 self
.lcd_brightness
= brightness
68 self
.serio
= serial
.Serial(port
, 9600, timeout
=1)
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 ()
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
))
81 # high level interface
85 # https://learn.adafruit.com/usb-plus-serial-backpack/command-reference
86 def cmd_backlight_enable(self
, state
=True):
88 # Adafruit backpack doesn't support timeout
89 self
.send_command([0x42, 0])
91 self
.send_command([0x46])
93 def cmd_autoscroll(self
, enable
=True):
95 self
.send_command(b
'\x51')
97 self
.send_command(b
'\x52')
99 def cmd_set_brightness(self
, brightness
):
100 self
.send_command([0x98, brightness
])
101 self
.lcd_brightness
= brightness
103 def cmd_set_backlight_color(self
, r
, g
, b
):
104 self
.send_command([0xD0, r
, g
, b
])
106 def cmd_set_display_size(self
, cols
, rows
):
107 self
.send_command([0xD1, cols
, rows
])
109 def cmd_set_contrast(self
, contrast
):
110 self
.send_command([0x50, contrast
])
111 self
.lcd_contrast
= contrast
113 def cmd_clear_screen (self
):
114 self
.send_command(b
'\x58')
116 def cmd_cursor_set_position(self
, col
, row
):
117 self
.send_command([0x47, col
, row
])
119 def cmd_cursor_home(self
):
120 self
.send_command(b
'\x48')
122 def cmd_cursor_forward(self
):
123 self
.send_command(b
'\x4A')
125 def cmd_cursor_back(self
):
126 self
.send_command(b
'\x4B')
128 def cmd_character_set(self
, slot
, chars
):
129 self
.send_command([0x4E, slot
] + chars
)
132 # Volume bar characters
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...
142 minidsp_min_volume_db
= -127
144 def __init__(self
, wsurl
= MINIDSP_WS
):
146 self
.dspattrs
= { 'preset': None, 'source': None, 'volume': None, 'mute': None }
147 self
.lcd
= LCDDriver ()
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
)
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')
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):
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))
179 async def process_ws_message(self
,message
, force_redisplay
=False):
181 parsed
= json
.loads(message
)
182 pprint
.pp (parsed
['master'])
183 self
.update_screen(parsed
['master'], force
=force_redisplay
)
185 def update_screen(self
, attrs
=None, force
=False):
187 attrs
= self
.dspattrs
188 if attrs
.get('source', self
.dspattrs
['source']) != self
.dspattrs
['source'] or force
:
189 self
.draw_source(attrs
['source'])
190 self
.dspattrs
['source'] = attrs
['source']
191 if attrs
.get('preset', self
.dspattrs
['preset']) != self
.dspattrs
['preset'] or force
:
192 self
.draw_preset(attrs
['preset'])
193 self
.dspattrs
['preset'] = attrs
['preset']
194 if attrs
.get('volume', self
.dspattrs
['volume']) != self
.dspattrs
['volume'] or attrs
.get('mute', self
.dspattrs
['mute']) != self
.dspattrs
['mute'] or force
:
195 pct
= int(abs((self
.minidsp_min_volume_db
- attrs
['volume']) / abs(self
.minidsp_min_volume_db
)) * 100)
196 print('pct {}, min {}, vol {}'.format(pct
, self
.minidsp_min_volume_db
, attrs
['volume']))
197 self
.draw_volume_bar (pct
, attrs
['mute'])
198 self
.dspattrs
['volume'] = attrs
['volume']
199 self
.dspattrs
['mute'] = attrs
['mute']
201 def draw_source(self
, source_name
):
202 self
.lcd
.cmd_cursor_set_position(1,1)
203 self
.lcd
.write(b
'SOURCE: ')
204 self
.lcd
.write(bytes(source_name
, 'ascii'))
206 def draw_preset(self
, preset_number
):
207 self
.lcd
.cmd_cursor_set_position(1, 2)
208 self
.lcd
.write(b
'PRESET: ')
209 self
.lcd
.write(bytes(str(preset_number
+ 1), 'ascii'))
211 def draw_volume_bar(self
, percentage
, muted
):
212 full_blocks
= int(percentage
/ 6)
213 partial_ticks
= int(percentage
% 6 / 2)
215 #self.lcd.cmd_cursor_set_position(1, 3)
216 #self.lcd.write(b'VOLUME:')
218 #self.lcd.cmd_cursor_set_position(1, 4)
220 self
.lcd
.cmd_cursor_set_position(1, 3)
221 self
.lcd
.write(b
'MUTED'.center(20))
222 self
.lcd
.cmd_cursor_set_position(1, 4)
223 self
.lcd
.write(b
' '.center(20))
226 self
.lcd
.cmd_cursor_set_position(1, row
)
227 for i
in range(full_blocks
):
230 if partial_ticks
== 2:
232 elif partial_ticks
== 1:
237 for i
in range(18 - full_blocks
- 1):
240 self
.lcd
.cmd_cursor_set_position(18, 4)
242 self
.lcd
.write (b
'min')
243 elif percentage
== 100:
244 self
.lcd
.write (b
'max')
246 self
.lcd
.write (bytes(f
'{percentage:2d}%', 'ascii'))
248 dspmon
= DSPMonitor()
249 asyncio
.run(dspmon
.run())