Remove dead code

- wire.with_figure: the device uses dataclasses.replace directly.
- DeviceConnected/DeviceDisconnected: emitted, never subscribed to, and a duplicate
  of ConnectionChanged(target="firmware").
- ScreenService.toggle(): never called.
- GET /api/lirc (LircConfigOut, api.lircConfig): no front-end reads it.
- POST /api/volume's delta_percent (api.nudgeVolume): no front-end sends it.
- LibraryCache.shrink_stored_covers: a one-off migration that re-read every cover
  header on every scan. _INDEX_VERSION 7 rebuilds any cache that could still need it.
- Firmware: the MFRC522 library and its unused globals (lib/nfc/rc522.c is the
  driver in use), and mfrc_version.h, a reader test sketch nothing included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:25:40 +02:00
parent 7a7ad9f0a7
commit 751637d2f1
17 changed files with 10 additions and 257 deletions

View File

@@ -25,7 +25,6 @@ upload_port = /dev/ttyUSB0
monitor_speed = 115200 monitor_speed = 115200
build_src_filter = +<*> -<host_test.cpp> build_src_filter = +<*> -<host_test.cpp>
lib_deps = lib_deps =
miguelbalboa/MFRC522@1.4.12
bxparks/AceButton@1.10.1 bxparks/AceButton@1.10.1
[env:native] [env:native]

View File

@@ -1,7 +1,6 @@
#include <Arduino.h> #include <Arduino.h>
#include "rc522.h" #include "rc522.h"
#include "SPI.h" #include "SPI.h"
#include <MFRC522.h>
#include "rotary_encoder.h" #include "rotary_encoder.h"
#include "containers/LedStripRGBW.h" #include "containers/LedStripRGBW.h"
@@ -21,9 +20,6 @@
#include "TaskLed.h" #include "TaskLed.h"
// -------------------------------------------------- RFID Reader ---------------------------------------- // -------------------------------------------------- RFID Reader ----------------------------------------
MFRC522 rfid;
MFRC522::MIFARE_Key key;
void tagHandler(uint8_t *sn) void tagHandler(uint8_t *sn)
{ {
if (sn != nullptr) if (sn != nullptr)

View File

@@ -1,64 +0,0 @@
#include <SPI.h>
#include <MFRC522.h>
#define RST_PIN 5 // Configurable, see typical pin layout above
#define SS_PIN 23 // Configurable, see typical pin layout above
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance
//*****************************************************************************************//
void setup() {
Serial.begin(115200);
SPI.begin(22, 19, 18, 23); // Init SPI bus
mfrc522.PCD_Init(); // Init MFRC522 card
}
/**
* Helper routine to dump a byte array as hex values to Serial.
*/
void PrintHex(byte *buffer, byte bufferSize) {
for (byte i = 0; i < bufferSize; i++) {
Serial.print(buffer[i] < 0x10 ? " 0" : " ");
Serial.print(buffer[i], HEX);
}
}
//*****************************************************************************************//
void loop() {
// Look for new cards
if ( !mfrc522.PICC_IsNewCardPresent()) {
return;
}
if ( !mfrc522.PICC_ReadCardSerial()) {
return;
}
//mfrc522.PICC_DumpDetailsToSerial(&(mfrc522.uid)); //dump some details about the card
Serial.print("NewCard");
PrintHex(mfrc522.uid.uidByte, mfrc522.uid.size);
Serial.println("");
// Check if Card was removed
bool cardRemoved = false;
int counter = 0;
bool current, previous;
previous = !mfrc522.PICC_IsNewCardPresent();
while(!cardRemoved){
current =!mfrc522.PICC_IsNewCardPresent();
if (current && previous) counter++;
previous = current;
cardRemoved = (counter>2);
delay(50);
}
Serial.println("Card was removed");
delay(500); //change value if you want to read cards faster
mfrc522.PICC_HaltA();
mfrc522.PCD_StopCrypto1();
}

View File

@@ -220,7 +220,7 @@ copied to the device.
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing, and `clock_toggle` when the remote's Guide key asks for the clock. | | `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing, and `clock_toggle` when the remote's Guide key asks for the clock. |
| `POST /api/play` | `{album_id, track_index?}` | | `POST /api/play` | `{album_id, track_index?}` |
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | | `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
| `POST /api/volume` | `{percent}`, `{delta_percent}`, or `{steps}` - the IR remote's step (`volume_increment` device units) per step | | `POST /api/volume` | `{percent}` or `{steps}` - the IR remote's step (`volume_increment` device units) per step |
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits | | `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show | | `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
| `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off | | `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off |

View File

@@ -29,8 +29,6 @@ from musicmouse.events import (
ActiveFigureChanged, ActiveFigureChanged,
ButtonEvent, ButtonEvent,
ConnectionChanged, ConnectionChanged,
DeviceConnected,
DeviceDisconnected,
EventSource, EventSource,
InputEvent, InputEvent,
LedEffectChanged, LedEffectChanged,
@@ -136,7 +134,6 @@ class MusicMouseDevice:
def on_connected(self) -> None: def on_connected(self) -> None:
"""Re-apply memorized state, so a reconnect is invisible from the outside.""" """Re-apply memorized state, so a reconnect is invisible from the outside."""
self._bus.emit(DeviceConnected(port=self.port, source="device"))
self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device")) self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device"))
for zone, effect in self._effects.items(): for zone, effect in self._effects.items():
@@ -146,8 +143,8 @@ class MusicMouseDevice:
if self._effects: if self._effects:
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects)) _log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
def on_disconnected(self, reason: str = "") -> None: def on_disconnected(self, _reason: str = "") -> None:
self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device")) # SerialLink already logs the reason; the event only says the link is down.
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device")) self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
def feed(self, data: bytes) -> None: def feed(self, data: bytes) -> None:

View File

@@ -13,7 +13,7 @@ newlines and on the magic token.
from __future__ import annotations from __future__ import annotations
import struct import struct
from dataclasses import dataclass, replace from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
from typing import TypeAlias from typing import TypeAlias
@@ -345,10 +345,6 @@ class FrameDecoder:
return None return None
def with_figure(event: RfidTokenRead, figure: str | None, *, known: bool) -> RfidTokenRead:
"""Attach a resolved figure name to a decoded tag read."""
return replace(event, figure=figure, known=known)
# ------------------------------------------------- host -> firmware, read back # ------------------------------------------------- host -> firmware, read back
# #

View File

@@ -30,8 +30,6 @@ __all__ = [
"CancelTimerRequested", "CancelTimerRequested",
"ClockStateChanged", "ClockStateChanged",
"ConnectionChanged", "ConnectionChanged",
"DeviceConnected",
"DeviceDisconnected",
"Event", "Event",
"EventSource", "EventSource",
"FigureSelectionChanged", "FigureSelectionChanged",
@@ -175,17 +173,6 @@ class PlaylistFinished(InputEvent):
"""The player reached the end of the playlist.""" """The player reached the end of the playlist."""
@dataclass(frozen=True, slots=True, kw_only=True)
class DeviceConnected(InputEvent):
port: str
@dataclass(frozen=True, slots=True, kw_only=True)
class DeviceDisconnected(InputEvent):
port: str
reason: str | None = None
# -------------------------------------------------------------------------- intents # -------------------------------------------------------------------------- intents

View File

@@ -57,6 +57,8 @@ __all__ = ["Fingerprint", "LibraryCache"]
# 7: a figure's albums may sit in subfolders. `figure` is now the folder directly under # 7: a figure's albums may sit in subfolders. `figure` is now the folder directly under
# Figuren rather than the album folder's own name, and such an album is titled # Figuren rather than the album folder's own name, and such an album is titled
# "Fuchs - <folder>" - neither of which an entry cached before can know. # "Fuchs - <folder>" - neither of which an entry cached before can know.
# The same rescan retired `shrink_stored_covers`, which re-read every cover header on
# every scan to catch pre-384 px covers and covers stored before thumbnails existed.
# #
# Bumping this is cheap by design - see the module docstring. # Bumping this is cheap by design - see the module docstring.
_INDEX_VERSION = 7 _INDEX_VERSION = 7
@@ -119,51 +121,6 @@ class LibraryCache:
) )
return path return path
def _refresh_thumb(self, cover: Path) -> bool:
"""Make ``<id>.thumb.jpg`` exist and be newer than the cover it came from."""
thumb = cover.with_name(f"{cover.stem}.thumb.jpg")
try:
if thumb.exists() and thumb.stat().st_mtime_ns >= cover.stat().st_mtime_ns:
return False
thumb.write_bytes(
shrink_cover(cover.read_bytes(), max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY)
)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
return False
return True
def shrink_stored_covers(self) -> int:
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
by an older version would otherwise never be touched again. Reading a JPEG's
dimensions only parses its header, so once every file is within the limit this
costs one small read per album and nothing else.
Also backfills any missing (or stale) thumbnail. Returns the number of full-size
files actually rewritten.
"""
rewritten = 0
for path in sorted(self.covers.glob("*.jpg")):
if path.name.endswith(".thumb.jpg"):
continue
try:
with Image.open(path) as image:
oversized = max(image.size) > MAX_COVER_PX
except (OSError, UnidentifiedImageError):
continue
if oversized:
try:
shrunk = shrink_cover(path.read_bytes())
path.write_bytes(shrunk)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
continue
rewritten += 1
# Covers stored before thumbnails existed get theirs here, without a rescan.
self._refresh_thumb(path)
return rewritten
# ------------------------------------------------------------------ analysis # ------------------------------------------------------------------ analysis
def load_analysis(self, key: str) -> TrackAnalysis | None: def load_analysis(self, key: str) -> TrackAnalysis | None:

View File

@@ -317,13 +317,6 @@ def scan_library(
figure name to what it holds, which is the one thing the folders cannot say. figure name to what it holds, which is the one thing the folders cannot say.
""" """
cache.prepare() cache.prepare()
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
# and the reuse path below means an unchanged album never rewrites its own. One
# pass here catches them; after the first run every file is already small and this
# is 300-odd header reads.
shrunk = cache.shrink_stored_covers()
if shrunk:
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
known = known or {} known = known or {}
out: dict[str, tuple[Album, Fingerprint]] = {} out: dict[str, tuple[Album, Fingerprint]] = {}
last_report = time.monotonic() last_report = time.monotonic()

View File

@@ -141,9 +141,6 @@ class ScreenService:
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen") self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
return True return True
async def toggle(self) -> None:
await self._run(self.config.toggle_command, sound=True)
async def _run(self, command: str, *, sound: bool) -> None: async def _run(self, command: str, *, sound: bool) -> None:
argv = shlex.split(command) argv = shlex.split(command)
try: try:

View File

@@ -46,7 +46,6 @@ from musicmouse.services.web.schemas import (
HaConfigOut, HaConfigOut,
HaDeviceOut, HaDeviceOut,
LibraryOut, LibraryOut,
LircConfigOut,
PlayerStateOut, PlayerStateOut,
PlayIn, PlayIn,
RemoteMappingIn, RemoteMappingIn,
@@ -68,7 +67,6 @@ from musicmouse.services.web.schemas import (
from musicmouse.services.web.settings import ( from musicmouse.services.web.settings import (
read_settings, read_settings,
to_device_volume, to_device_volume,
to_percent,
write_settings, write_settings,
) )
from musicmouse.services.web.state import snapshot from musicmouse.services.web.state import snapshot
@@ -234,18 +232,10 @@ def build_router(
return emit( return emit(
VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web") VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web")
) )
if body.percent is not None: if body.percent is None:
target = body.percent raise HTTPException(status_code=422, detail="percent or steps required")
elif body.delta_percent is not None:
target = to_percent(app.player.volume, general) + body.delta_percent
else:
raise HTTPException(
status_code=422, detail="percent, delta_percent or steps required"
)
return emit( return emit(
SetVolumeRequested( SetVolumeRequested(volume=to_device_volume(body.percent, general), source="web")
volume=to_device_volume(max(0, min(100, target)), general), source="web"
)
) )
# ------------------------------------------------------------- parent mode # ------------------------------------------------------------- parent mode
@@ -321,12 +311,6 @@ def build_router(
# -------------------------------------------------------------- IR remote # -------------------------------------------------------------- IR remote
@router.get("/lirc")
def get_lirc_config() -> LircConfigOut:
if app.config.general.lirc is None:
raise HTTPException(status_code=404, detail="lirc not configured")
return LircConfigOut(connected=app.state.lirc_connected)
@router.post("/remote/press/{digit}", status_code=204) @router.post("/remote/press/{digit}", status_code=204)
def press_remote(digit: Digit) -> Response: def press_remote(digit: Digit) -> Response:
# A keyboard's number keys, doing what the IR remote's do. An unassigned key # A keyboard's number keys, doing what the IR remote's do. An unassigned key

View File

@@ -26,7 +26,6 @@ __all__ = [
"HaConfigOut", "HaConfigOut",
"HaDeviceOut", "HaDeviceOut",
"LibraryOut", "LibraryOut",
"LircConfigOut",
"PlayIn", "PlayIn",
"PlayerStateOut", "PlayerStateOut",
"RemoteMappingIn", "RemoteMappingIn",
@@ -261,15 +260,13 @@ class SeekIn(BaseModel):
class VolumeIn(BaseModel): class VolumeIn(BaseModel):
"""An absolute percentage, a relative percentage, or a number of steps; exactly """An absolute percentage or a number of steps; exactly one of the two.
one of the three.
``steps`` moves by ``volume_increment`` device units per step - the same step the ``steps`` moves by ``volume_increment`` device units per step - the same step the
IR remote's volume keys and the rotary encoder take - rather than by a percentage. IR remote's volume keys and the rotary encoder take - rather than by a percentage.
""" """
percent: int | None = Field(default=None, ge=0, le=100) percent: int | None = Field(default=None, ge=0, le=100)
delta_percent: int | None = Field(default=None, ge=-100, le=100)
steps: int | None = Field(default=None, ge=-10, le=10) steps: int | None = Field(default=None, ge=-10, le=10)
@@ -325,13 +322,6 @@ class HaConfigOut(BaseModel):
scenes: list[HaDeviceOut] scenes: list[HaDeviceOut]
class LircConfigOut(BaseModel):
"""Presence-only, like ``HaConfigOut``: there is nothing secret in a host/port,
but the frontend only needs to know whether the remote is set up and connected."""
connected: bool
class RemoteSlotOut(BaseModel): class RemoteSlotOut(BaseModel):
digit: str digit: str
target_kind: Literal["album", "series"] target_kind: Literal["album", "series"]

View File

@@ -916,46 +916,3 @@ class TestCoverDownscaling:
assert thumb.format == "JPEG" assert thumb.format == "JPEG"
assert "progressive" not in thumb.info assert "progressive" not in thumb.info
assert abs(thumb.width / thumb.height - 2400 / 1800) < 0.02 assert abs(thumb.width / thumb.height - 2400 / 1800) < 0.02
def test_thumbnails_are_backfilled_for_covers_stored_without_one(
self, tmp_path: Path
) -> None:
from PIL import Image
from musicmouse.library.cache import THUMB_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
cache.cover_path("old").write_bytes(self._jpeg((384, 384)))
assert not cache.thumb_path("old").exists()
assert cache.shrink_stored_covers() == 0 # nothing oversized...
with Image.open(cache.thumb_path("old")) as thumb: # ...but the thumb appeared
assert max(thumb.size) == THUMB_COVER_PX
# Steady state: the thumbnail is not regenerated, and is not mistaken for a cover.
before = cache.thumb_path("old").stat().st_mtime_ns
assert cache.shrink_stored_covers() == 0
assert cache.thumb_path("old").stat().st_mtime_ns == before
assert not cache.thumb_path("old.thumb").exists()
def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None:
"""The scanner reuses an unchanged album without re-reading its tags, so a
cover stored before the limit existed would otherwise never be rewritten."""
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
stale = cache.cover_path("old")
stale.write_bytes(self._jpeg((3000, 3000)))
fresh = cache.cover_path("new")
fresh.write_bytes(self._jpeg((320, 320)))
fresh_before = fresh.read_bytes()
assert cache.shrink_stored_covers() == 1
with Image.open(stale) as image:
assert max(image.size) == MAX_COVER_PX
assert fresh.read_bytes() == fresh_before
# Second pass has nothing left to do - the cheap steady state.
assert cache.shrink_stored_covers() == 0

View File

@@ -65,13 +65,6 @@ async def album_id(sim: Simulation, title: str) -> str:
return next(a.id for a in sim.app.library.albums if a.title == title) return next(a.id for a in sim.app.library.albums if a.title == title)
# ------------------------------------------------------------------------------ lirc
async def test_lirc_is_a_404_when_unconfigured(client: httpx2.AsyncClient) -> None:
assert (await client.get("/api/lirc")).status_code == 404
# --------------------------------------------------------------------------- reading # --------------------------------------------------------------------------- reading

View File

@@ -288,24 +288,6 @@ async def test_volume_scales_across_the_allowed_range(client: Client, sim: Simul
assert (await client.get("/api/state")).json()["volume"] == 50 assert (await client.get("/api/state")).json()["volume"] == 50
async def test_volume_steps_are_relative_to_the_percentage(client: Client, sim: Simulation) -> None:
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
await client.post("/api/volume", json={"delta_percent": 10})
await sim.bus.drain()
assert (await client.get("/api/state")).json()["volume"] == 60
assert sim.player.volume == 36
async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -> None:
await client.post("/api/volume", json={"percent": 95})
await sim.bus.drain()
await client.post("/api/volume", json={"delta_percent": 20})
await sim.bus.drain()
assert (await client.get("/api/state")).json()["volume"] == 100
async def test_remote_steps_move_by_the_volume_increment(client: Client, sim: Simulation) -> None: async def test_remote_steps_move_by_the_volume_increment(client: Client, sim: Simulation) -> None:
"""The +/- buttons take the IR remote's step, in device units, not a percentage.""" """The +/- buttons take the IR remote's step, in device units, not a percentage."""
await client.post("/api/volume", json={"percent": 50}) await client.post("/api/volume", json={"percent": 50})

View File

@@ -4,7 +4,6 @@ import type {
Album, Album,
HaConfig, HaConfig,
HaEntityState, HaEntityState,
LircConfig,
PlayerState, PlayerState,
RemoteMapping, RemoteMapping,
RemoteSlotInput, RemoteSlotInput,
@@ -55,9 +54,6 @@ async function fetchOrNullOnError<T>(path: string): Promise<T | null> {
/** `null` means the room-control page isn't configured, not an error. */ /** `null` means the room-control page isn't configured, not an error. */
const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha"); const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha");
/** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
/** `null` means the typing game isn't configured, not an error - the tab still shows, /** `null` means the typing game isn't configured, not an error - the tab still shows,
* just with a "not set up" placeholder instead of a lesson map. */ * just with a "not set up" placeholder instead of a lesson map. */
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> => const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
@@ -96,7 +92,6 @@ export const api = {
previous: () => post("/previous"), previous: () => post("/previous"),
seek: (position: number) => post("/seek", { position }), seek: (position: number) => post("/seek", { position }),
setVolume: (percent: number) => post("/volume", { percent }), setVolume: (percent: number) => post("/volume", { percent }),
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
/** One step of the IR remote's volume keys per unit, in the device's own increment. */ /** One step of the IR remote's volume keys per unit, in the device's own increment. */
stepVolume: (steps: number) => post("/volume", { steps }), stepVolume: (steps: number) => post("/volume", { steps }),
toggleScreen: () => post("/screen/toggle"), toggleScreen: () => post("/screen/toggle"),
@@ -118,7 +113,6 @@ export const api = {
trackDetail: fetchTrackDetail, trackDetail: fetchTrackDetail,
lircConfig: fetchLircConfig,
pressRemote: (digit: string) => post(`/remote/press/${digit}`), pressRemote: (digit: string) => post(`/remote/press/${digit}`),
remoteMapping: () => request<RemoteMapping>("/remote/mapping"), remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) => saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>

View File

@@ -171,11 +171,6 @@ export interface RemoteSlotInput {
track_index?: number; track_index?: number;
} }
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */
export interface LircConfig {
connected: boolean;
}
// --------------------------------------------------------------------------- tippen // --------------------------------------------------------------------------- tippen
// //
// The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't // The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't