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
|
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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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. |
|
| `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 |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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>) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user