diff --git a/docs/REPO_OVERVIEW.md b/docs/REPO_OVERVIEW.md new file mode 100644 index 0000000..0fa5168 --- /dev/null +++ b/docs/REPO_OVERVIEW.md @@ -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 +``` + +`main.py` expects `/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..{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. diff --git a/python-backend/audio_analysis.py b/python-backend/audio_analysis.py index a506d02..8d90d55 100644 --- a/python-backend/audio_analysis.py +++ b/python-backend/audio_analysis.py @@ -1,4 +1,3 @@ -from typing import overload import librosa from numba import jit import numpy as np diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example new file mode 100644 index 0000000..856679e --- /dev/null +++ b/python-backend/config.yml.example @@ -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.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, ""). + media_files: [] + + eule: + id: "04b2c3d4e5" + colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"] diff --git a/python-backend/hass_test.py b/python-backend/hass_test.py index ce296a3..13cbf88 100644 --- a/python-backend/hass_test.py +++ b/python-backend/hass_test.py @@ -23,7 +23,8 @@ if __name__ == "__main__": url = sys.argv[1] token = sys.argv[2] - loop = asyncio.get_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) hass = HomeAssistantClient(url, token) async def hass_event(event, event_details): diff --git a/python-backend/host_driver.py b/python-backend/host_driver.py index fa03354..f0c77eb 100644 --- a/python-backend/host_driver.py +++ b/python-backend/host_driver.py @@ -2,7 +2,9 @@ import asyncio from enum import Enum 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_FW_TO_HOST = 0x10c65631 diff --git a/python-backend/led_cmds.py b/python-backend/led_cmds.py index f0e08fe..47c3b79 100644 --- a/python-backend/led_cmds.py +++ b/python-backend/led_cmds.py @@ -26,12 +26,6 @@ class ColorRGBW: assert 0<= other <= 1 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): args = (min(1, e + self.w) for e in (self.r, self.g, self.b) ) return ColorRGBW(*args, 0) diff --git a/python-backend/main.py b/python-backend/main.py index 900be4d..77f476d 100755 --- a/python-backend/main.py +++ b/python-backend/main.py @@ -3,7 +3,7 @@ import asyncio import sys import serial_asyncio -from led_cmds import (ColorRGBW, ColorHSV, EffectCircularConfig, EffectStaticConfig, +from led_cmds import (ColorRGBW, EffectCircularConfig, EffectStaticConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange, EffectReverseSwipe) 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 import os from hass_client import HomeAssistantClient -import argparse from ruamel.yaml import YAML import warnings -from pprint import pprint from typing import Optional from mqtt_json import start_mqtt -import aiohttp yaml = YAML(typ='safe') @@ -38,6 +35,7 @@ def parse_color(color_str: str): def load_config(config_path): + # Schema documented in config.yml.example. with open(os.path.join(config_path, "config.yml")) as cfg_file: cfg = yaml.load(cfg_file) for figure_name, figure_cfg in cfg["figures"].items(): @@ -205,12 +203,12 @@ class Controller: eff_change = EffectRandomTwoColorInterpolationConfig() eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0), *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"] eff_static.color = primary_color 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"] eff_change.color1 = primary_color eff_change.color2 = secondary_color @@ -247,7 +245,8 @@ class Controller: def main(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) coro = serial_asyncio.create_serial_connection(loop, diff --git a/python-backend/mqtt_json.py b/python-backend/mqtt_json.py index 1d2658a..2f8c5fb 100644 --- a/python-backend/mqtt_json.py +++ b/python-backend/mqtt_json.py @@ -2,7 +2,6 @@ from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig, import asyncio import aiomqtt import json -from copy import deepcopy class ShelveLightMqtt: @@ -155,21 +154,6 @@ class ShelveLightMqtt: state_payload = json.dumps(self._state) print("OUT ", state_payload) 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):