--- /dev/null
+#!/usr/bin/python3
+# minidsp-lcd-monitor.py --- A simple LCD monitor for the minidsp 2x4HD
+
+# Copyright (C) 2022 Clinton Ebadi <clinton@unknownlamer.org>
+
+# Author: Clinton Ebadi <clinton@unknownlamer.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+# Hardware needed:
+#
+# * Adafruit USB+Serial RGB Backlight Character LCD Backpack
+# <https://learn.adafruit.com/usb-plus-serial-backpack/overview>
+# <https://learn.adafruit.com/usb-plus-serial-backpack/command-reference>
+# (hard requirement is a 20x4 character lcd with 5x8 cells)
+# * Adafruit 20x4 RGB Character LCD (Negative looks cooler but isn't required)
+# <https://www.adafruit.com/product/498>
+#
+# Software dependencies:
+#
+# * minidsp-rs daemon with HTTP API enabled
+# <https://minidsp-rs.pages.dev/daemon/http>
+
+# TODO (probably for someone else, patches welcome):
+# - Configuration file
+# - Support different sizes of LCDs or at least controlling which data
+# is shown and on which line.
+# - More consistent naming scheme for LCDDriver methods
+# - Remove extra pip from bottom of volume bar? Actual characters
+# don't use it so might look better that way.
+
+import asyncio
+import json
+import pprint
+import serial
+import sys
+import time
+import websockets
+
+# Configuration
+LCD_ROWS = 4
+LCD_COLS = 20
+LCD_CONTRAST = 255
+LCD_BRIGHTNESS = 200
+
+SERIAL_PORT = '/dev/ttyACM0'
+
+MINIDSP_WS = 'ws://127.0.0.1:5380/devices/0'
+
+# LCD control
+class LCDDriver:
+ def __init__(self, port = SERIAL_PORT, cols = LCD_COLS, rows = LCD_ROWS, contrast = LCD_CONTRAST, brightness = LCD_BRIGHTNESS):
+ self.lcd_rows = rows
+ self.lcd_cols = cols
+ self.lcd_contrast = contrast
+ self.lcd_brightness = brightness
+ self.serio = serial.Serial(port, 9600, timeout=1)
+
+ #self.cmd_set_display_size(20, 4)
+ self.cmd_clear_screen ()
+ self.cmd_set_contrast (contrast)
+ self.cmd_set_brightness(brightness)
+ self.cmd_backlight_enable ()
+
+ # Low level command sending, use cmd_FOO functions instead
+ def send_command(self, codes):
+ self.serio.write(b'\xFE')
+ self.serio.write(bytes(codes))
+
+ # high level interface
+ def write(self, txt):
+ self.serio.write(txt)
+
+ # https://learn.adafruit.com/usb-plus-serial-backpack/command-reference
+ def cmd_backlight_enable(self, state=True):
+ if state:
+ # Adafruit backpack doesn't support timeout
+ self.send_command([0x42, 0])
+ else:
+ self.send_command([0x46])
+
+ def cmd_autoscroll(self, enable=True):
+ if enable:
+ self.send_command(b'\x51')
+ else:
+ self.send_command(b'\x52')
+
+ def cmd_set_brightness(self, brightness):
+ self.send_command([0x98, brightness])
+ self.lcd_brightness = brightness
+
+ def cmd_set_backlight_color(self, r, g, b):
+ self.send_command([0xD0, r, g, b])
+
+ def cmd_set_display_size(self, cols, rows):
+ self.send_command([0xD1, cols, rows])
+
+ def cmd_set_contrast(self, contrast):
+ self.send_command([0x50, contrast])
+ self.lcd_contrast = contrast
+
+ def cmd_clear_screen (self):
+ self.send_command(b'\x58')
+
+ def cmd_cursor_set_position(self, col, row):
+ self.send_command([0x47, col, row])
+
+ def cmd_cursor_home(self):
+ self.send_command(b'\x48')
+
+ def cmd_cursor_forward(self):
+ self.send_command(b'\x4A')
+
+ def cmd_cursor_back(self):
+ self.send_command(b'\x4B')
+
+ def cmd_character_set(self, slot, chars):
+ self.send_command([0x4E, slot] + chars)
+
+class DSPMonitor:
+ # Volume bar characters
+ #
+ # Bars are set every other character column so that they appear even
+ # despite gaps between characters. Using 17 characters and making each
+ # visible bar a 2% increment gives us a max of 102%.
+ custom_bar_1 = [0x0,0x10,0x10,0x10,0x10,0x10,0x10,0x0]
+ custom_bar_2 = [0x0,0x14,0x14,0x14,0x14,0x14,0x14,0x0]
+ custom_bar_3 = [0x0,0x15,0x15,0x15,0x15,0x15,0x15,0x0]
+ custom_dB = [0x6,0x5,0x5,0x6,0xd,0x15,0x15,0x1e] # dB, kind of...
+
+ minidsp_min_volume_db = -127
+
+ def __init__(self, wsurl = MINIDSP_WS):
+ self.wsurl = wsurl
+ self.dspattrs = { 'preset': None, 'source': None, 'volume': None, 'mute': None }
+ self.lcd = LCDDriver ()
+
+ self.lcd.cmd_character_set(0, self.custom_bar_1)
+ self.lcd.cmd_character_set(1, self.custom_bar_2)
+ self.lcd.cmd_character_set(2, self.custom_bar_3)
+ self.lcd.cmd_character_set(3, self.custom_dB)
+
+ self.lcd.cmd_autoscroll(False)
+ self.lcd.cmd_set_backlight_color(255, 0, 100)
+ self.lcd.cmd_cursor_home()
+ self.lcd.write(b'DSP Monitor Ready')
+
+ async def run(self):
+ self.lcd.cmd_clear_screen()
+ self.lcd.cmd_cursor_home()
+ self.lcd.write(b'DSP Monitor Connecting...')
+ # pings disabled as minidsp-rs does not appear to support them
+ async for websocket in websockets.connect(self.wsurl, ping_interval=None):
+ try:
+ force_redisplay = True
+ self.lcd.cmd_clear_screen()
+ self.lcd.cmd_cursor_home()
+ async for message in websocket:
+ await self.process_ws_message(message, force_redisplay)
+ force_redisplay = False
+ except websockets.ConnectionClosed:
+ self.lcd.cmd_clear_screen()
+ self.lcd.cmd_cursor_home()
+ self.lcd.write(b'Connection Lost'.center(20))
+ time.sleep(0.5)
+ continue
+
+ async def process_ws_message(self,message, force_redisplay=False):
+ print (message);
+ parsed = json.loads(message)
+ pprint.pp (parsed['master'])
+ self.update_screen(parsed['master'], force=force_redisplay)
+
+ def update_screen(self, attrs=None, force=False):
+ if attrs == None:
+ attrs = self.dspattrs
+ if attrs.get('source', self.dspattrs['source']) != self.dspattrs['source'] or force:
+ self.draw_source(attrs['source'])
+ self.dspattrs['source'] = attrs['source']
+ if attrs.get('preset', self.dspattrs['preset']) != self.dspattrs['preset'] or force:
+ self.draw_preset(attrs['preset'])
+ self.dspattrs['preset'] = attrs['preset']
+ if attrs.get('volume', self.dspattrs['volume']) != self.dspattrs['volume'] or attrs.get('mute', self.dspattrs['mute']) != self.dspattrs['mute'] or force:
+ pct = int(abs((self.minidsp_min_volume_db - attrs['volume']) / abs(self.minidsp_min_volume_db)) * 100)
+ print('pct {}, min {}, vol {}'.format(pct, self.minidsp_min_volume_db, attrs['volume']))
+ self.draw_volume_bar (pct, attrs['mute'])
+ self.dspattrs['volume'] = attrs['volume']
+ self.dspattrs['mute'] = attrs['mute']
+
+ def draw_source(self, source_name):
+ self.lcd.cmd_cursor_set_position(1,1)
+ self.lcd.write(b'SOURCE: ')
+ self.lcd.write(bytes(source_name, 'ascii'))
+
+ def draw_preset(self, preset_number):
+ self.lcd.cmd_cursor_set_position(1, 2)
+ self.lcd.write(b'PRESET: ')
+ self.lcd.write(bytes(str(preset_number + 1), 'ascii'))
+
+ def draw_volume_bar(self, percentage, muted):
+ full_blocks = int(percentage / 6)
+ partial_ticks = int(percentage % 6 / 2)
+
+ #self.lcd.cmd_cursor_set_position(1, 3)
+ #self.lcd.write(b'VOLUME:')
+
+ #self.lcd.cmd_cursor_set_position(1, 4)
+ if muted:
+ self.lcd.cmd_cursor_set_position(1, 3)
+ self.lcd.write(b'MUTED'.center(20))
+ self.lcd.cmd_cursor_set_position(1, 4)
+ self.lcd.write(b' '.center(20))
+ else:
+ for row in [3, 4]:
+ self.lcd.cmd_cursor_set_position(1, row)
+ for i in range(full_blocks):
+ self.lcd.write([2])
+
+ if partial_ticks == 2:
+ self.lcd.write([1])
+ elif partial_ticks == 1:
+ self.lcd.write([0])
+ else:
+ self.lcd.write(b' ')
+
+ for i in range(18 - full_blocks - 1):
+ self.lcd.write(b' ')
+
+ self.lcd.cmd_cursor_set_position(18, 4)
+ if percentage == 0:
+ self.lcd.write (b'min')
+ elif percentage == 100:
+ self.lcd.write (b'max')
+ else:
+ self.lcd.write (bytes(f'{percentage:2d}%', 'ascii'))
+
+dspmon = DSPMonitor()
+asyncio.run(dspmon.run())