Claude cleanup
This commit is contained in:
42
docs/REPO_OVERVIEW.md
Normal file
42
docs/REPO_OVERVIEW.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
51
python-backend/config.yml.example
Normal file
51
python-backend/config.yml.example
Normal 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"]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user