From: Clinton Ebadi Date: Sat, 12 Feb 2022 19:26:38 +0000 (-0500) Subject: minidsp-lcd-monitor: simple volume display for minidsp 2x4HD X-Git-Url: http://git.hcoop.net/clinton/scratch.git/commitdiff_plain/ae71116762d9ccf24b67a5162c61c6e7625723bb minidsp-lcd-monitor: simple volume display for minidsp 2x4HD 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. --- diff --git a/minidsp-lcd-monitor/minidsp-lcd-monitor.py b/minidsp-lcd-monitor/minidsp-lcd-monitor.py new file mode 100755 index 0000000..43a73dc --- /dev/null +++ b/minidsp-lcd-monitor/minidsp-lcd-monitor.py @@ -0,0 +1,249 @@ +#!/usr/bin/python3 +# minidsp-lcd-monitor.py --- A simple LCD monitor for the minidsp 2x4HD + +# Copyright (C) 2022 Clinton Ebadi + +# Author: Clinton Ebadi + +# 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 . + +# Hardware needed: +# +# * Adafruit USB+Serial RGB Backlight Character LCD Backpack +# +# +# (hard requirement is a 20x4 character lcd with 5x8 cells) +# * Adafruit 20x4 RGB Character LCD (Negative looks cooler but isn't required) +# +# +# Software dependencies: +# +# * minidsp-rs daemon with HTTP API enabled +# + +# 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())