| 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.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 | |
| 246 | dspmon = DSPMonitor() |
| 247 | asyncio.run(dspmon.run()) |