minidsp-lcd-monitor: simple volume display for minidsp 2x4HD
authorClinton Ebadi <clinton@unknownlamer.org>
Sat, 12 Feb 2022 19:26:38 +0000 (14:26 -0500)
committerClinton Ebadi <clinton@unknownlamer.org>
Sat, 12 Feb 2022 20:14:25 +0000 (15:14 -0500)
After getting a minidsp 2x4HD to use as a crossover, I realized it was
a bit inconvenient not having a volume display.

This just listens for updates from minidsp-rs and shows the current
source/preset/volume using an Adafruit character lcd usb backback with
a 20x4 character lcd.

minidsp-lcd-monitor/minidsp-lcd-monitor.py [new file with mode: 0755]

diff --git a/minidsp-lcd-monitor/minidsp-lcd-monitor.py b/minidsp-lcd-monitor/minidsp-lcd-monitor.py
new file mode 100755 (executable)
index 0000000..43a73dc
--- /dev/null
@@ -0,0 +1,249 @@
+#!/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())