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:
@@ -25,7 +25,6 @@ upload_port = /dev/ttyUSB0
|
||||
monitor_speed = 115200
|
||||
build_src_filter = +<*> -<host_test.cpp>
|
||||
lib_deps =
|
||||
miguelbalboa/MFRC522@1.4.12
|
||||
bxparks/AceButton@1.10.1
|
||||
|
||||
[env:native]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include <Arduino.h>
|
||||
#include "rc522.h"
|
||||
#include "SPI.h"
|
||||
#include <MFRC522.h>
|
||||
#include "rotary_encoder.h"
|
||||
|
||||
#include "containers/LedStripRGBW.h"
|
||||
@@ -21,9 +20,6 @@
|
||||
#include "TaskLed.h"
|
||||
|
||||
// -------------------------------------------------- RFID Reader ----------------------------------------
|
||||
MFRC522 rfid;
|
||||
MFRC522::MIFARE_Key key;
|
||||
|
||||
void tagHandler(uint8_t *sn)
|
||||
{
|
||||
if (sn != nullptr)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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. |
|
||||
| `POST /api/play` | `{album_id, track_index?}` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -29,8 +29,6 @@ from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
ButtonEvent,
|
||||
ConnectionChanged,
|
||||
DeviceConnected,
|
||||
DeviceDisconnected,
|
||||
EventSource,
|
||||
InputEvent,
|
||||
LedEffectChanged,
|
||||
@@ -136,7 +134,6 @@ class MusicMouseDevice:
|
||||
|
||||
def on_connected(self) -> None:
|
||||
"""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"))
|
||||
|
||||
for zone, effect in self._effects.items():
|
||||
@@ -146,8 +143,8 @@ class MusicMouseDevice:
|
||||
if self._effects:
|
||||
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
|
||||
|
||||
def on_disconnected(self, reason: str = "") -> None:
|
||||
self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device"))
|
||||
def on_disconnected(self, _reason: str = "") -> None:
|
||||
# SerialLink already logs the reason; the event only says the link is down.
|
||||
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
|
||||
|
||||
def feed(self, data: bytes) -> None:
|
||||
|
||||
@@ -13,7 +13,7 @@ newlines and on the magic token.
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import TypeAlias
|
||||
|
||||
@@ -345,10 +345,6 @@ class FrameDecoder:
|
||||
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
|
||||
#
|
||||
|
||||
@@ -30,8 +30,6 @@ __all__ = [
|
||||
"CancelTimerRequested",
|
||||
"ClockStateChanged",
|
||||
"ConnectionChanged",
|
||||
"DeviceConnected",
|
||||
"DeviceDisconnected",
|
||||
"Event",
|
||||
"EventSource",
|
||||
"FigureSelectionChanged",
|
||||
@@ -175,17 +173,6 @@ class PlaylistFinished(InputEvent):
|
||||
"""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
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ __all__ = ["Fingerprint", "LibraryCache"]
|
||||
# 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
|
||||
# "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.
|
||||
_INDEX_VERSION = 7
|
||||
@@ -119,51 +121,6 @@ class LibraryCache:
|
||||
)
|
||||
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
|
||||
|
||||
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
||||
|
||||
@@ -317,13 +317,6 @@ def scan_library(
|
||||
figure name to what it holds, which is the one thing the folders cannot say.
|
||||
"""
|
||||
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 {}
|
||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||
last_report = time.monotonic()
|
||||
|
||||
@@ -141,9 +141,6 @@ class ScreenService:
|
||||
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
|
||||
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:
|
||||
argv = shlex.split(command)
|
||||
try:
|
||||
|
||||
@@ -46,7 +46,6 @@ from musicmouse.services.web.schemas import (
|
||||
HaConfigOut,
|
||||
HaDeviceOut,
|
||||
LibraryOut,
|
||||
LircConfigOut,
|
||||
PlayerStateOut,
|
||||
PlayIn,
|
||||
RemoteMappingIn,
|
||||
@@ -68,7 +67,6 @@ from musicmouse.services.web.schemas import (
|
||||
from musicmouse.services.web.settings import (
|
||||
read_settings,
|
||||
to_device_volume,
|
||||
to_percent,
|
||||
write_settings,
|
||||
)
|
||||
from musicmouse.services.web.state import snapshot
|
||||
@@ -234,18 +232,10 @@ def build_router(
|
||||
return emit(
|
||||
VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web")
|
||||
)
|
||||
if body.percent is not None:
|
||||
target = body.percent
|
||||
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"
|
||||
)
|
||||
if body.percent is None:
|
||||
raise HTTPException(status_code=422, detail="percent or steps required")
|
||||
return emit(
|
||||
SetVolumeRequested(
|
||||
volume=to_device_volume(max(0, min(100, target)), general), source="web"
|
||||
)
|
||||
SetVolumeRequested(volume=to_device_volume(body.percent, general), source="web")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- parent mode
|
||||
@@ -321,12 +311,6 @@ def build_router(
|
||||
|
||||
# -------------------------------------------------------------- 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)
|
||||
def press_remote(digit: Digit) -> Response:
|
||||
# A keyboard's number keys, doing what the IR remote's do. An unassigned key
|
||||
|
||||
@@ -26,7 +26,6 @@ __all__ = [
|
||||
"HaConfigOut",
|
||||
"HaDeviceOut",
|
||||
"LibraryOut",
|
||||
"LircConfigOut",
|
||||
"PlayIn",
|
||||
"PlayerStateOut",
|
||||
"RemoteMappingIn",
|
||||
@@ -261,15 +260,13 @@ class SeekIn(BaseModel):
|
||||
|
||||
|
||||
class VolumeIn(BaseModel):
|
||||
"""An absolute percentage, a relative percentage, or a number of steps; exactly
|
||||
one of the three.
|
||||
"""An absolute percentage or a number of steps; exactly one of the two.
|
||||
|
||||
``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.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -325,13 +322,6 @@ class HaConfigOut(BaseModel):
|
||||
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):
|
||||
digit: str
|
||||
target_kind: Literal["album", "series"]
|
||||
|
||||
@@ -916,46 +916,3 @@ class TestCoverDownscaling:
|
||||
assert thumb.format == "JPEG"
|
||||
assert "progressive" not in thumb.info
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------ lirc
|
||||
|
||||
|
||||
async def test_lirc_is_a_404_when_unconfigured(client: httpx2.AsyncClient) -> None:
|
||||
assert (await client.get("/api/lirc")).status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- reading
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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:
|
||||
"""The +/- buttons take the IR remote's step, in device units, not a percentage."""
|
||||
await client.post("/api/volume", json={"percent": 50})
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
Album,
|
||||
HaConfig,
|
||||
HaEntityState,
|
||||
LircConfig,
|
||||
PlayerState,
|
||||
RemoteMapping,
|
||||
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. */
|
||||
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,
|
||||
* just with a "not set up" placeholder instead of a lesson map. */
|
||||
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
|
||||
@@ -96,7 +92,6 @@ export const api = {
|
||||
previous: () => post("/previous"),
|
||||
seek: (position: number) => post("/seek", { position }),
|
||||
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. */
|
||||
stepVolume: (steps: number) => post("/volume", { steps }),
|
||||
toggleScreen: () => post("/screen/toggle"),
|
||||
@@ -118,7 +113,6 @@ export const api = {
|
||||
|
||||
trackDetail: fetchTrackDetail,
|
||||
|
||||
lircConfig: fetchLircConfig,
|
||||
pressRemote: (digit: string) => post(`/remote/press/${digit}`),
|
||||
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
|
||||
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
|
||||
|
||||
@@ -171,11 +171,6 @@ export interface RemoteSlotInput {
|
||||
track_index?: number;
|
||||
}
|
||||
|
||||
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */
|
||||
export interface LircConfig {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- tippen
|
||||
//
|
||||
// The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't
|
||||
|
||||
Reference in New Issue
Block a user