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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user