Claude cleanup

This commit is contained in:
2026-08-26 10:38:56 +02:00
parent bd8925a278
commit 7aa7fe4693
8 changed files with 104 additions and 32 deletions

42
docs/REPO_OVERVIEW.md Normal file
View File

@@ -0,0 +1,42 @@
# MusicMouse — repo overview
Orientation doc for AI agents (or humans) working on this repo for the first time. Written from what's actually in the repo — no roadmap speculation beyond `esp-firmware/todo.md`.
## What this is
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant, squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each carry an RFID tag. Placing a figurine on the mouse triggers an RFID read, which starts that figure's music playlist. The mouse also has a rotary encoder + touch buttons (ears/feet) for volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home Assistant integration so a "shelf light" shows up as a smart-home device.
## Repo layout
| Path | What it is |
|---|---|
| `python-backend/` | Python host application — the main runtime. Reads RFID/button/encoder events from the ESP32 over serial, drives playback via VLC, sends LED effect commands, bridges to MQTT/Home Assistant. Start here for backend work. |
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats Music Player" / rebrand concept). Not integrated with the rest of the repo — no build system ties it in. Don't assume it reflects current product direction. |
| `.vscode/` | Editor settings (C++ header associations). |
There is no top-level README elsewhere in the repo; this file plus `esp-firmware/todo.md` and `hardware/pinout.md` are the only prose docs.
## How the pieces talk to each other
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial (`pyserial-asyncio`), implemented in `python-backend/host_driver.py`. Messages are framed with magic tokens (`MAGIC_TOKEN_HOST_TO_FW`/`MAGIC_TOKEN_FW_TO_HOST`) and a `struct`-packed header. The message-ID maps and struct formats in `host_driver.py` must stay byte-for-byte in sync with the firmware's `esp-firmware/src/Messages.h` — there's no shared schema or test verifying this cross-language contract, so a firmware protocol change can silently desync the Python side.
- **`python-backend` ↔ MQTT/Home Assistant**: `python-backend/mqtt_json.py` exposes the shelf LED strip as a Home-Assistant-discoverable JSON-schema MQTT light (`ShelveLightMqtt`), and `main.py` also calls Home Assistant services directly (e.g. toggling room lights) via `hass-client`.
## Running it
```
python python-backend/main.py <config_dir>
```
`main.py` expects `<config_dir>/config.yml` (schema documented in the new `python-backend/config.yml.example` — no real config was previously checked in or documented). In production this is deployed as a systemd service reading music from `/media/musicmouse/`; see `esp-firmware/musicmouse.service` for the unit template — **note its `ExecStart` path (`.../espmusicmouse/host_driver/main.py`) is stale**, referencing the pre-reorg directory layout from before the `bd8925a "Cleaned up repository"` commit moved things to `python-backend/`. Update that path before relying on the service file.
## Config schema (see `python-backend/config.yml.example`)
- `general.{alsa_device, serial_port, hass_url, hass_token, mqtt.{server,user,password}, min_volume, max_volume, volume_increment, button_leds_brightness}`
- `figures.<name>.{id, colors, media_files}``id` is a hex RFID tag id, `colors` is a list of 4 colors (`primary, secondary, background, accent`, each `"#rrggbb"` or `"wNN"`), `media_files` is optional (auto-globbed from the config dir by figure name if omitted).
## Known gaps / notes for agents
- **No automated tests, no lint/formatter config, no CI** anywhere in the repo (neither `python-backend/` nor `esp-firmware/`, aside from a PlatformIO `native` build env for firmware unit testing).
- `python-backend/audio_analysis.py` and the three chord-recognition notebooks (`C5S2_ChordRec_Templates.ipynb`, `C5S3_ChordRec_HMM.ipynb`, `C5S3_HiddenMarkovModel.ipynb`) are university-course exploratory material (chroma/chord-recognition DSP), **not imported by `main.py`** and not part of the running app. They reference stale personal absolute paths.

View File

@@ -1,4 +1,3 @@
from typing import overload
import librosa import librosa
from numba import jit from numba import jit
import numpy as np import numpy as np

View File

@@ -0,0 +1,51 @@
# Example config.yml for the MusicMouse python-backend.
#
# Reverse-engineered from main.py (load_config/Controller) since no schema
# was previously documented. Copy this file to config.yml in the directory
# passed as the CLI argument to main.py, e.g.:
#
# python main.py /media/musicmouse/
#
# main.py reads "<config_dir>/config.yml". Real credentials (hass_token,
# mqtt.password) should never be committed - keep the real config.yml
# outside the repo (e.g. only on the deployed device).
general:
# ALSA output device passed to python-vlc, e.g. "hw:0,0"; omit/null for VLC's default.
alsa_device: "softvol_effects"
# Serial port the ESP32 firmware is connected on.
serial_port: "/dev/ttyUSB0"
# Home Assistant connection used for light/service calls (hass_service()).
hass_url: "http://homeassistant.local:8123"
hass_token: "REPLACE_WITH_LONG_LIVED_ACCESS_TOKEN"
# MQTT broker used for the Home-Assistant-discoverable "shelf light".
mqtt:
server: "homeassistant.local"
user: "musicmouse"
password: "REPLACE_WITH_MQTT_PASSWORD"
# Optional playback/UI tuning (all have defaults if omitted).
min_volume: 0
max_volume: 32
volume_increment: 5 # per rotary-encoder tick
button_leds_brightness: 0.5 # 0..1, brightness of the prev/next button backlight
# One entry per figurine. The key is an arbitrary figure name (also used as
# the subdirectory name under the config dir when media_files is omitted).
figures:
fuchs:
# RFID tag id as a hex string (matched against bytes read from the reader).
id: "04a1b2c3d4"
# Exactly 4 colors: [primary, secondary, background, accent].
# Accepted formats: "#rrggbb" (RGB hex) or "wNN" (white channel hex, e.g. "wff").
colors: ["#ff6600", "#ffcc00", "#331100", "#ffffff"]
# Optional explicit list of media file paths for this figure's playlist.
# If omitted, main.py globs os.path.join(config_dir, "<figure_name>").
media_files: []
eule:
id: "04b2c3d4e5"
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]

View File

@@ -23,7 +23,8 @@ if __name__ == "__main__":
url = sys.argv[1] url = sys.argv[1]
token = sys.argv[2] token = sys.argv[2]
loop = asyncio.get_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
hass = HomeAssistantClient(url, token) hass = HomeAssistantClient(url, token)
async def hass_event(event, event_details): async def hass_event(event, event_details):

View File

@@ -2,7 +2,9 @@ import asyncio
from enum import Enum from enum import Enum
import struct import struct
from led_cmds import * from led_cmds import (EffectStaticConfig, EffectStaticDetailedConfig, EffectAlexaSwipeConfig,
EffectCircularConfig, EffectRandomTwoColorInterpolationConfig,
EffectSwipeAndChange, EffectReverseSwipe)
MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3 MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3
MAGIC_TOKEN_FW_TO_HOST = 0x10c65631 MAGIC_TOKEN_FW_TO_HOST = 0x10c65631

View File

@@ -26,12 +26,6 @@ class ColorRGBW:
assert 0<= other <= 1 assert 0<= other <= 1
return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other) return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other)
def __eq__(self, other:'ColorRGBW'):
return self.r == other.r and self.g == other.g and self.b == other.b and self.w == other.w
def __neq__(self, other:'ColorRGBW'):
return not self == other
def without_white_channel(self, scale=1): def without_white_channel(self, scale=1):
args = (min(1, e + self.w) for e in (self.r, self.g, self.b) ) args = (min(1, e + self.w) for e in (self.r, self.g, self.b) )
return ColorRGBW(*args, 0) return ColorRGBW(*args, 0)

View File

@@ -3,7 +3,7 @@
import asyncio import asyncio
import sys import sys
import serial_asyncio import serial_asyncio
from led_cmds import (ColorRGBW, ColorHSV, EffectCircularConfig, EffectStaticConfig, from led_cmds import (ColorRGBW, EffectCircularConfig, EffectStaticConfig,
EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig,
EffectSwipeAndChange, EffectReverseSwipe) EffectSwipeAndChange, EffectReverseSwipe)
from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges
@@ -12,13 +12,10 @@ from glob import glob
from copy import deepcopy from copy import deepcopy
import os import os
from hass_client import HomeAssistantClient from hass_client import HomeAssistantClient
import argparse
from ruamel.yaml import YAML from ruamel.yaml import YAML
import warnings import warnings
from pprint import pprint
from typing import Optional from typing import Optional
from mqtt_json import start_mqtt from mqtt_json import start_mqtt
import aiohttp
yaml = YAML(typ='safe') yaml = YAML(typ='safe')
@@ -38,6 +35,7 @@ def parse_color(color_str: str):
def load_config(config_path): def load_config(config_path):
# Schema documented in config.yml.example.
with open(os.path.join(config_path, "config.yml")) as cfg_file: with open(os.path.join(config_path, "config.yml")) as cfg_file:
cfg = yaml.load(cfg_file) cfg = yaml.load(cfg_file)
for figure_name, figure_cfg in cfg["figures"].items(): for figure_name, figure_cfg in cfg["figures"].items():
@@ -205,12 +203,12 @@ class Controller:
eff_change = EffectRandomTwoColorInterpolationConfig() eff_change = EffectRandomTwoColorInterpolationConfig()
eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0), eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0),
*mouse_leds_index_ranges[message.touch_button]) *mouse_leds_index_ranges[message.touch_button])
if self.audio_player.is_playing(): if figure and self.audio_player.is_playing():
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"] primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
eff_static.color = primary_color eff_static.color = primary_color
self.protocol.mouse_led_effect(eff_static) self.protocol.mouse_led_effect(eff_static)
if self.audio_player.is_playing(): if figure and self.audio_player.is_playing():
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"] primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
eff_change.color1 = primary_color eff_change.color1 = primary_color
eff_change.color2 = secondary_color eff_change.color2 = secondary_color
@@ -247,7 +245,8 @@ class Controller:
def main(config_path): def main(config_path):
cfg = load_config(config_path) cfg = load_config(config_path)
loop = asyncio.get_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop=loop) hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop=loop)
coro = serial_asyncio.create_serial_connection(loop, coro = serial_asyncio.create_serial_connection(loop,

View File

@@ -2,7 +2,6 @@ from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig,
import asyncio import asyncio
import aiomqtt import aiomqtt
import json import json
from copy import deepcopy
class ShelveLightMqtt: class ShelveLightMqtt:
@@ -155,21 +154,6 @@ class ShelveLightMqtt:
state_payload = json.dumps(self._state) state_payload = json.dumps(self._state)
print("OUT ", state_payload) print("OUT ", state_payload)
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode()) await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
return
direct_ack = False
if direct_ack == True:
state_payload = json.dumps(state)
else:
s = deepcopy(self._state)
if s['state'] == "OFF":
state_payload = json.dumps({"state": "OFF"})
else:
s['color_mode'] = "rgbw"
state_payload = json.dumps(s)
print("OUT ", state_payload)
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
async def start_mqtt(music_mouse_protocol, server, username, password): async def start_mqtt(music_mouse_protocol, server, username, password):