Skip to content

Performance & Caching — pagination, cover-cache, in-mem-LRU, async writes#148

Open
wowa1990 wants to merge 19 commits into
splitti:mainfrom
wowa1990:pr-performance-caching
Open

Performance & Caching — pagination, cover-cache, in-mem-LRU, async writes#148
wowa1990 wants to merge 19 commits into
splitti:mainfrom
wowa1990:pr-performance-caching

Conversation

@wowa1990

@wowa1990 wowa1990 commented May 16, 2026

Copy link
Copy Markdown

⚠️ Depends on #151 (Daily Playtime Limit) — this PR's app.component.ts imports CurrentMediaService, PlaytimeService, PlaytimeBlockedOverlayComponent, PlaytimeChipComponent, playtime.model, and buildResumeMedia, all first added in #151. Merge #151 before this one.

Was es macht

  • Spotify-API-Pagination mit mergeMap(8) statt concatMap (Bibliotheks-Loads ~4× schneller bei großen Künstlern)
  • Artist-Direct-Path: Klick auf Künstler holt nur dessen Alben, nicht die ganze Kategorie
  • Swiper Progressive-Render mit setTimeout-Chunks + Fallback (kein Hänger bei vielen Items)
  • Cover-Backend-Cache: /api/spotify/cover/:imageId mit SD-LRU (cache/covers/, cap 2000) + In-Mem-LRU (5% freemem, Pi3-tauglich) + Pending-Fetches-Dedup + Negative-Cache. Frontend rewrited alle Spotify-CDN-Cover-URLs → 30× schneller bei Warm-Hits
  • header.php exec()-Cache (Status-Bar wird einmal pro Request gebaut, nicht pro Block)
  • GitHub-API-Cache für Update-Check
  • mupiboxconfig.json request-memo (M5, $forceReread-Opt-Out für Post-Mutation-Reads)
  • Async cache writes (M3) + in-mem-LRU vor SD-Cache (M4)
  • mergeMap-Concurrency-Cap (L2) auf 5 in updateMedia-Pipeline
  • save_rrd 4×→1×/Min + get_monitor sleep1→sleep5 + skip-if-unchanged (~83% weniger sudo/Tag)
  • Polling-Tuning (M1+M2) per B11-Pattern + mupihat$ refCount=true → 1800 req/h weg
  • L3 VREG-Typo-Fix und battery_soc() else-Branch (Display-only mupihat-Fixes aus Phase 7.8)
  • AR5-19 noImplicitAny: true aktiviert + 37 Type-Fixes
  • Granulare 5%-Battery-Percent-Anzeige (Piecewise-Linear + 32s Moving-Average + Charge-Indicator)

Architektur (kurz)

Cover-Cache ist neuer Backend-Endpoint mit imageId-Validation (Path-Traversal-safe). Alle anderen Caches sind in-process / request-scope. LRU-Caps lesen os.freemem() für Pi3/Pi4-Tauglichkeit. Polling reduziert primär durch shareReplay({ refCount: true }) und längere Intervalle für niedrigfrequente Streams.

Test in Codespaces / lokal

  1. Branch, Setup wie üblich
  2. Konkrete Tests:
    • Spotify-Search mit großem Künstler (z.B. „Benjamin Blümchen") → erwartet alle Alben sichtbar in <2s (vorher ~8-10s)
    • Cover-Endpoint: curl http://127.0.0.1:8200/api/spotify/cover/abc123 zweimal hintereinander → erster Cold-Miss ~170ms, zweiter Warm-Hit ~5ms
    • Network-Tab im Frontend: nach 1 Min kein neuer mupihat-Request pro Subscriber (alle teilen sich denselben Stream)
    • npm run build im backend-api und frontend-box → erwartet clean, keine any-Warnings mehr

Hinweis: RRD-Cadence + mupihat-Display-Anteile sind Box-only.

Offene Punkte

  • In-Mem-LRU-Cap als „5% freemem" gewählt — bei Pi3 mit 1GB knapp aber ok, bei Pi4 großzügig. Falls du fix 50MB lieber hättest, leicht änderbar.

wowa1990 added 19 commits May 16, 2026 16:04
The conf_update script ran ~60 `cat <<< $(jq <FILTER>) > ${CONFIG}`
directly against /etc/mupibox/mupiboxconfig.json. That truncates the
config before jq's output is appended — a jq error or a kill in the
wrong moment leaves the file empty and bricks the box. Phase-3 fixed
this pattern in 12 other scripts; conf_update was overlooked because
it only runs during MuPiBox-Update (manual or via the admin UI).

- update_config() helper: jq → tmpfile → atomic mv (preserves CONFIG
  on jq failure).
- ensure_theme() helper: previous check used `cat $CONFIG | grep
  <theme>` which matches the theme name as a substring anywhere in
  the JSON. ensure_theme uses jq's array-index check against
  installedThemes — exact match, no false positives.
- Fix a malformed jq filter in the BATTERYCONFIG init block (trailing
  string literal that produced a jq parse error on fresh boxes; with
  the old truncate-race that could blank the config).
- 330 lines collapse to ~250, semantics unchanged.
The pagination params on searchAlbums/getArtistAlbums/getShowEpisodes/
getPlaylistTracks come straight from user query strings and were spliced
into the cache-key without validation. A request with ?limit=999999
produced a unique cache file per call; the cache directory could grow
without bound. The SDK call itself was already guarded with
Math.min(limit, 10), but the cache-key bypassed that.

- normalizePagination() clamps limit to [1, 50] and offset to >= 0,
  rejecting NaN/negatives. Both the SDK arg and the cache-key now use
  the clamped value.
- pruneCacheIfNeeded() runs after each saveToCache. When the cache dir
  exceeds 1000 files, it evicts the 200 oldest by mtime — cheap LRU.
index.php fired three blocking external HTTPS requests on every render
— version.json + news.txt + api.github.com. With flaky internet the
admin landing page hung 2-5s; api.github.com rate-limits at 60 req/h
per IP, which a kid hammering F5 reaches quickly.

Add mupibox_cached_url() helper, cache to /tmp/.mupibox.indexcache.*
with a 60min TTL. On fetch failure fall back to the (possibly stale)
cached value rather than serving a blank page or breaking
json_decode. The DEV tag is also cached — only changes once per day
upstream so the 60-min refresh is plenty.
header.php is included on every admin page and previously fired three
exec()s on every render — `sudo iwgetid -r`, `sudo iwconfig wlan0`,
and the `ps -ef | grep websockify` for the VNC nav link. Each exec
forks a sudo helper and (for iw*) opens a netlink socket; across
admin navigation that's ~60 wasted forks/min and visible lag on
the Pi.

Add a tiny mupibox_cached_exec() helper that memoises the result to
/tmp/.mupibox.headercache.<key> with a 5s TTL. The wifi-quality
readout stays current enough for the periodic JS poll in footer.php
that replaces it, while burst navigation no longer fans out into
sudo+exec storms.
Five PHP pages (admin, mupi, mupihat, spotify, smart) had ~15 copies
of the same write pattern:

  $json_object = json_encode($data);
  $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object);
  [sometimes: exec("sudo chmod 755 ...");]
  exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json");

Two failure modes the audit (B8) flagged:

1. Concurrent saves from two admin tabs (e.g. mom edits the playtime
   limit on her phone while dad changes the WiFi on his laptop)
   both write to the same fixed-name /tmp/.mupiboxconfig.json, then
   both sudo mv it -- the second's data overwrites the first's, the
   user whose save "won" sees their settings persist while the other
   silently loses theirs.

2. No flock at any layer means concurrent file_put_contents +
   sudo mv on the same destination can interleave at the file-system
   level, leaving a half-written or torn JSON. Backend reads it,
   json_decode fails, mupiboxconfig is wedged until somebody edits
   it by hand.

Fix: new shared helper AdminInterface/www/includes/save_config.php
exposing save_mupiboxconfig(array $data, ?string &$errorOut = null).
It:
  - Acquires LOCK_EX on /tmp/.mupiboxconfig.lock (flock-serialised)
  - json_encodes with JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
  - Writes to a per-call random /tmp/.mupiboxconfig.<8hex>.json
    (so two writers waiting on the same flock don't also share a
    fixed-name scratch path)
  - sudo mv into place via escapeshellarg
  - Releases the lock in a finally block

Auto-loaded by includes/header.php so every admin page has it
available without per-file require_once. All 15 callsites switched:
  admin.php       2 (write_json() helper + $change==2 branch)
  mupi.php        2 ($change==1, $change==2)
  mupihat.php     5 ($change==1..5)
  spotify.php     2 (OAuth-completion + generic $change branch)
  smart.php       4 ($change==1..4)

The pretty-printed output is a deliberate one-time switch from the
old compact json_encode: matches the template (config/templates/
mupiboxconfig.json) and conf_update.sh's jq output, so the live
config will no longer oscillate between compact (after PHP save)
and pretty (after bash update). All write callsites verified PHP-
syntax-clean on the box.
… (L3)

REG01_Charge_Voltage_Limit.set() was assigning to self.VRE0G instead
of self.VREG, so the public VREG attribute stayed on its __init__
value (0) forever. Every consumer reading .VREG after a register
update -- log_register_values() in mupihat.py, the Admin-UI Battery
Status block -- saw a stale zero.

Observed live as `Charge Voltage Limit: 0` in the mupi_hat journal
on an actively-charging pack where the actual hardware-side VREG
was correctly set to 8400 mV (the BQ25792 internal register held
the right value, only the Python-side mirror was wrong).

One-line fix: VRE0G -> VREG. The __init__ already sets self.VREG
correctly via the same expression; the set() override just needed
the same attribute name.
battery_soc()'s elif chain ended at `elif VBat > v_0 : Bat_SOC = "0%"`,
leaving Bat_SOC at its init default of empty string '' for any VBat
at-or-below v_0 (the BMS cutoff region).

Consequences of the empty value:
  - Telegram low-battery message rendered as "... battery is at "
    with nothing after "at" -- looked like a broken bot.
  - Frontend battery icon fell through every @switch @case to the
    @default branch, which is fine, but the underlying string in
    /tmp/mupihat.json was literally '' instead of '0%'.

Replace the final elif with `else` so anything at or below v_0 reads
as "0%". Bat_Stat is already 'SHUTDOWN' in that region (the second
chain below covers it), so the user gets a coherent SOC + status
pair.
fetchAllPaginatedResults issued every page via concatMap, which is
strictly sequential — page 2 only kicked off after page 1's response
was processed. For an artist with 28 album pages (pageSize=10) that
serialised the whole pagination through the round-trip latency,
adding ~1s to the medialist render.

Switch to mergeMap with concurrency 8 so up to 8 pages fly in
parallel. The backend event-loop is single-threaded and serialises
internally, but pipelining still amortises the per-request frontend
overhead. Output order is no longer page-aligned, but every caller
already sorts on the client before render (localeCompare on title
in medialist.page) so the user-visible order is unchanged.
fetchMediaFromArtist used to call fetchMedia(category) which loads
EVERY entry in the category through updateMedia, then filter the
result to the chosen artist. For a library with mixed sources
(143 library entries, 7 spotify-album-IDs, 8 spotify-artist-IDs)
that meant ~55 backend round-trips fired in parallel from a single
artist click — even though the user only wanted Benjamin Blümchen's
albums. updateMedia's mergeMap has no concurrency cap (L2 in the
backlog), so all 155 items contend for the backend event loop
simultaneously.

Fast path: when the clicked artist is a Spotify artist (we know
artistid from the navigation state's coverMedia), call
spotifyService.getMediaByArtistID directly. ~6 backend calls,
no contention with other artists. Library/local entries keep the
load-then-filter path because data.json may contain multiple rows
for the same artist name with no single API call to retrieve them
as a group.
The swiper for an artist with hundreds of albums (Benjamin Blümchen
has 276) used to render every <swiper-slide> upfront — ~1400 Ionic-
flavoured DOM nodes (276 × ion-card/grid/row/col/img) through CSS
recalc on a single-threaded RPi-Chromium kiosk. The user reported
~6s of empty cards even after the loading spinner cleared.

Three combined changes in swiper.component:

- Progressive render in three phases:
  - t=0:    15 slides (immediate first paint, viewport + swipe-buffer)
  - t=120ms: 60 slides (comfortable swipe range)
  - then:    +30 per requestIdleCallback chunk until full list
  When the user is touching/scrolling, idle callbacks defer — touch
  input wins, slide expansion pauses, UI stays responsive. A 2000ms
  timeout argument is the safety net so we don't get stuck under-
  rendered if idle never arrives.

- cloneDeep (lodash) → structuredClone (native, ~5-10× faster on
  plain-object arrays). Observables on SwiperData.imgSrc aren't
  cloneable, so we keep imgSrc by reference and structuredClone
  the per-slide data payload only. Removes the lodash-es/cloneDeep
  bundle dependency.

- Stable trackBy: `track currentData.name` (instead of object
  identity) so a re-emit of the same logical list doesn't recreate
  every <swiper-slide> DOM node.

- <img loading="lazy" decoding="async" fetchpriority="low"> on the
  template's image (the first two attributes are Chromium-honoured
  for slides scrolled out of view; fetchpriority drops these below
  page-chrome paints).

For shorter swipers (homepage's ~50 artists) this is a no-op
because the initial 15 covers the visible region and stage 2 finishes
the rest within 120ms — imperceptible.
Six iterative refinements after Phase-7.6's progressive-render landed:

Render reliability (swiper.component.ts):
  - effect-driven expansion: reacts to data() arriving late instead of
    bailing when initial array is empty
  - setTimeout-driven chunks (15 → +30 every 80ms) replace the
    requestIdleCallback-driven Stage-3 expansion which stalled on the
    Pi-Chromium kiosk under SDK polling load
  - swiper.update() one microtask after each renderableLimit change so
    swiper-element picks up newly-rendered <swiper-slide> children
  - position-restore effect tracks pageIsShown only (not shownData)
    so progressive-render expansions no longer teleport users back to
    slide 0 mid-swipe; cachedSwiperPosition is kept in sync via a new
    onSlideChange handler

Cover preload (swiper.component.ts):
  - on each slidechange, eagerly fetch next 12 + previous 4 cover URLs
    into the browser image cache via Image() (preloadedSrcs dedupes)
  - eager kick on ionViewDidEnter so first interactions have covers
    ready without waiting for the first slidechange event

Feel tuning (swiper.component.html):
  - speed=900ms, touch-ratio=0.5: a fast flick now travels half the
    distance and animates fluidly — matches a child's pace
  - resistance="true" + resistance-ratio="0.85": rubber-band at the
    list edges instead of free overshoot (which had been teleporting
    rare past-edge swipes back to slide 0)
  - slides-per-group="1": one group per gesture, no multi-jump

Squashed from af64646d 739f90d2 30d1ea3d 21089455 81dc6bd4 629ece36.
… M4)

M3 -- saveToCache used fs.writeFileSync, blocking the event loop 5-30
ms per write on the SD card. During an artist-browse burst that
populates dozens of cache files in a row, the cumulative pause was
seconds of unresponsive frontend. Switched to fs/promises.writeFile;
saveToCache is already async, so the change is a single `await` and
zero ripple.

M4 -- getFromCache used to do existsSync + statSync + readFileSync +
JSON.parse on every hit, ~5-30 ms of SD time per call. With a typical
artist browse hitting the cache hundreds of times, that's the
dominant bottleneck. New in-memory layer in front of the SD cache:

  - memCache: Map<string, CachedSpotifyData>
  - memCacheCap: auto-sized to 5% of os.freemem() / ~10KB-per-entry,
    clamped to [50, 500]. On Pi 4 with 3 GB free this saturates at
    500 entries (~5 MB). On a Pi 3 with 100 MB free it caps around
    50 entries -- same code stays safe on either platform.
  - LRU via Map insertion order: on get/set we delete-then-reinsert
    so the touched entry moves to the tail; when size > cap, we
    evict the first key (oldest).

getFromCache now checks memCache first -- if hit, no syscall at all.
On miss, the SD read is also async (M3 sibling change) so the event
loop stays free, and the result is populated into memCache for the
next hit. saveToCache also populates memCache after the write so
fresh writes are immediately mem-cached.

Hit/miss counters (memHits, memMisses) on the service instance for
future observability. No log on mem-hit to keep journal volume sane;
only stale-mem and actual SD reads log.

tsc --noEmit clean.
admin.php had 6 separate file_get_contents+json_decode pairs reading
/etc/mupibox/mupiboxconfig.json across one HTTP request, plus another
in header.php. On an SD card that's 35-140 ms of pure re-parsing the
same bytes per admin page load.

On closer inspection only 1 of those 6 in admin.php is genuinely
redundant -- the pre-header auth-gate (line 24). The other 5 read
after an external mutation (sudo unzip restore, sudo conf_update.sh,
sudo start_mupibox_update.sh) and need fresh state. header.php's
read is the redundant counterpart to the auth-gate.

New mupibox_config(bool $forceReread = false): array in
includes/save_config.php (now misnamed but fits -- it lives next to
save_mupiboxconfig). Static $cache in the function persists per
request; $forceReread = true is an explicit opt-out for the post-
mutation call sites that need fresh data.

Call sites converted:
- admin.php line 24-27 -- pre-header auth gate, default cache mode
- header.php line 39-42 -- routes through mupibox_config() so the
  shared static avoids a second file_get_contents per request
- admin.php 5× post-mutation reads -- mupibox_config(true) to force
  re-read after the external command finished

In the common case (no admin POST action, just page navigation), this
drops from 2 file_get_contents to 1 + memory hit. In the action case
where mutations chain, each chain link still does exactly one read,
no redundant ones.

All three files php-syntax-clean on the box.
Two scripts dominated the box's sudo audit.log:

save_rrd.sh -- crontab fired it 4×/minute (every 15s), each invocation
issued 3 sudo+rrdtool forks. That's ~17,000 sudo/day for a database
the user looks at via Admin-UI graphs at second-to-minute resolution,
not 15s. crontab.template now invokes it once per minute -- 75%
reduction with no user-visible loss of graph fidelity.

get_monitor.sh -- ran a `while true; sleep 1; sudo vcgencmd
display_power; jq+mv` loop in a systemd service. ~86,400 sudo+fork
+ ~86,400 file rewrites per day, almost all of them writing the
SAME state ("On" / "Off") that was already on disk. Two changes:

  - sleep 1 -> sleep 5. 80% fewer iterations; the kid never notices
    a few-second delay before touch is re-enabled after the screen
    actually blanks.
  - In-process LAST_STATE tracking + write_state() helper. monitor.json
    only gets rewritten when the polled state changes from the
    previously-written one. In typical daily use ("On" for hours,
    "Off" overnight) that drops file rewrites to a handful per day,
    not 86k.

Also tightened existing seed/init paths: switched to consistent
quoting of "${VAR}", added a fallback for /sys/class/backlight read
when vcgencmd returns -1 (was unquoted before, would error if any
backlight glob failed).

Combined audit.log impact: ~83% fewer sudo entries from these two
sources, freeing ~90k log lines/day plus the corresponding CPU.
bash -n syntax-clean.
updateMedia's second mergeMap (the one that flattens
Observable<Observable<Media[]>> coming out of the per-item iif()
chain) had no concurrency parameter -- default unlimited. For a
library of N items, all N inner Observables subscribed in parallel,
each kicking off a Spotify/RSS HTTP fetch + cache lookup. With
N=100+ items the result was a thundering herd against the backend
plus a synchronous cache-write storm on SD.

mergeMap((items) => from(items), 5) caps the concurrent inner
subscriptions to 5. The Spotify SDK service has a 100 ms
minRequestInterval, so 5 concurrent gives ~50 req/s peak which is
well under the Spotify quota. The staggering also smooths the SD
write pressure from saveToCache (now async after M3, but still
sequential per-request).

The first mergeMap (line 520) is intentionally NOT capped because
its upstream emits only a single Observable<Media[]> from the
http.get, so concurrency is irrelevant there -- only one inner
subscription ever.

tsc --noEmit clean.
Cover-Latenz-Optimierung. Nach Phase 7.6 brauchte ein Klick auf einen
Spotify-Artist mit vielen Alben (Benjamin Blümchen ~276) noch ~3 s,
dominiert von i.scdn.co-Cover-Roundtrips: Chromium limitiert
HTTP/1.1-Verbindungen auf 6 parallel pro Host, 276 Cover × 50-150 ms
÷ 6 = ~2.5 s pures CDN-Warten.

Backend (src/backend-api):

  - Neuer CoverCacheService mit drei Layern:
    1. In-Memory-LRU (Map<id, Buffer>; Cap dynamisch via os.freemem(),
       clamped auf [512 KB, 10 MB]; LRU via Map-Insertion-Order)
    2. SD-LRU (cache/covers/<imageId>.jpg; Cap 2000 Files ~150 MB;
       Pruning 400 ältester per mtime bei MAX_FILES Überschreitung)
    3. Upstream-CDN (https://i.scdn.co/image/<id> mit 5 s
       AbortSignal-Timeout)

  - Robustheits-Layer:
    * isValidImageId() /^[A-Za-z0-9]{32,80}$/ wehrt Path-Traversal ab
    * 5-Minuten-Negative-Cache verhindert hammering bei broken Images
    * pendingFetches dedupliziert concurrent same-key requests
      (ein Artist-Klick = 1 CDN-Hit pro imageId, nicht 200×)
    * SD-Hit touched mtime async für sauberes LRU-by-mtime

  - GET /api/spotify/cover/:imageId Endpoint mit
    Cache-Control: public, max-age=31536000, immutable
    (Spotify image-IDs sind content-addressed, Browser darf
    aggressiv cachen)

Frontend (src/frontend-box):

  - utils.ts: neuer localizeCoverUrl() Helper
    * https://i.scdn.co/image/<id> → /api/spotify/cover/<id>
    * undefined/null → '../assets/images/nocover_mupi.png'
    * Alle anderen URLs pass-through (lokale /cover/*, RSS-URLs)

  - spotify.service.ts: 11 callsites umgestellt
    * 9× album/audiobook/episode/playlist cover
    * 2× artistcover / showcover

Verifikation post-deploy:
  Cold-Miss (CDN-Fetch + SD-Write): HTTP 200, 189 KB, 169 ms
  Hot-RAM (gleiche ID):              HTTP 200, 189 KB, 5.5 ms (30× schneller)
  MD5-identisch zwischen den beiden Antworten
  Path-Traversal-Test:               HTTP 404 (validator greift)

Robust gegen Chromium-Cache-Wipes, Box-Reboots, Spotify-CDN-Aussetzer
für bereits gecachte Bilder. Cache überlebt pm2 restart (SD-persistent);
nur das In-Memory-Layer regeneriert sich beim Backend-Start.

Squashed from 5170e387 + 6e69caef (Backup-Tag pre-squashes-backup).
Adds a percent number stacked under the existing 4-bucket battery
icon so users can tell 80% from 75% from 70%, instead of the icon
jumping straight from "75%" to "25%" with nothing in between.

Why 5% steps and not 1%:

  The Li-Ion discharge curve in the plateau zone (~3.7-3.9 V/cell)
  drops only 2-3 mV per 1% energy -- smaller than the BQ25792 ADC's
  noise floor (~10 mV LSB) and tiny compared to the 30-100 mV load
  sag the box's audio + display produce. A 1% display would
  zap-zap-zap constantly between adjacent values for purely
  electrical reasons. 5% steps map to ~90 mV per step which sits
  cleanly above all that noise.

Backend (mupihat_bq25792.py):

  - new record_vbat_sample() / smoothed_vbat() helpers maintaining
    an 8-element rolling buffer of recent VBAT readings (~32 s
    window at the 4 s polling cycle). Without smoothing the
    granular percent would visibly flap when audio loads spike.
  - new battery_percent_granular() returns (int 0-100 rounded to
    5%, source: "voltage" | "charging"). Piecewise-linear
    interpolation across the same v_100..v_0 thresholds the legacy
    battery_soc() already uses, so existing per-pack config drives
    both displays.
  - to_json() exposes Bat_Percent + Bat_PercentSource alongside
    the legacy Bat_SOC string. SOC string stays for Telegram bot
    messages and any other text consumer that wants a label.
  - battery_soc() `>` -> `>=` so a pack sitting exactly at v_100
    renders as "100%" instead of falling one bucket down to "75%".
    Without this fix, a freshly-finished CV charge at VBat=8200
    shows up as "75%" everywhere the legacy string is consumed.

  USB-C mode (sentinel v_100=1 from the "no battery" battery type)
  returns (0, "voltage") and the frontend skips the percent text
  entirely -- the existing plug icon already conveys it.

Backend (mupihat.py):

  periodic_json_dump now calls hat.record_vbat_sample(read_Vbat())
  inside the i2c_lock right after read_all_register() so the
  smoothing buffer is fed once per cycle from the freshly
  refreshed register state.

Frontend (mupihat.ts):

  Interface gains Bat_Percent (number) and Bat_PercentSource
  ("voltage" | "charging").

Frontend (mupihat-icon.component):

  - Icon bucket now driven by Bat_Percent (>=95 / >=70 / >=45 /
    >=20 / else) instead of the legacy Bat_SOC string. Both icon
    and percent text now come from the same source so they can
    never disagree at threshold edges. Backward-compat: if
    Bat_Percent is undefined (older backend), falls back to the
    legacy 4-case Bat_SOC switch.
  - Layout stacks ion-icon ABOVE the percent text (column flex)
    instead of side-by-side. Reason: home.page reserves a fixed
    70x70 px ion-button for status icons, holding BOTH the
    cloud-online/offline indicator AND mupihat-icon. A horizontal
    "<icon> 78%" expansion pushed the cloud icon out of the 70 px
    width. Vertical stacking keeps the horizontal footprint at
    icon-only size, so the cloud icon retains its space, and the
    percent label sits legibly under the bucket icon.
  - :host inline-flex+centered so the component element behaves
    as a single inline cell inside ion-button's children area.
  - During active CC/Taper charge the percent is prefixed with a
    ⚡ symbol — the voltage-based estimate is systematically
    optimistic during charge (pack held above its rest curve by
    the charge current), so the indicator tells the user "this
    number is in-flight, not a settled reading".

Verified post-deploy on the box: cold Pack at VBat=8188 mV
correctly shows Bat_Percent=100 via piecewise interpolation +
smoothing, icon renders as Battery100.svg, percent text reads
"100%". Cloud icon visible alongside as before.

Squashed from 7ce1cb76 + c214943a + 14fe18f4
(Backup-Tag pre-phase12-squash-backup).
…t (AR5-19)

frontend-box's tsconfig.json had no strict-mode flags at all, while
backend-api has full "strict": true. That gap is what enabled the
"(component as any).x.set(...)" pattern across the specs (Backlog L6)
plus a handful of silently-untyped function parameters that were
never caught.

Full "strict": true would have unleashed hundreds of strictNullChecks
errors that require a much larger refactor. noImplicitAny alone is
the high-value subset: it forbids untyped parameters / object indexes
and is what closes the (x as any) loophole, without forcing a big-
bang nullability migration.

37 errors surfaced and fixed:

  media.service.ts reduce() initial value {} -> typed accumulator
    via .reduce<Record<string, ...>>((acc, ...) => ..., {}) for
    mediaCounts (string -> number), covers (string -> string), and
    coverMedia (string -> Media). 11 errors fixed in one block.

  utils.ts keys array: untyped string[] -> (keyof ExtraDataMedia)[],
    plus a narrow biome-ignored (target as any) on the assignment side
    since the key types don't unify across the Media superset.

  utils.spec.ts: ExtraDataMedia-typed `source` literal with explicit
    `null as any` on the nullish properties (the spec deliberately
    exercises null/undefined input to test the skip-on-nullish path).

  add.page.ts, wifi.page.ts: handleLayoutChange(button) -> (button: string).

  player.service.ts: seekPosition(pos) -> (pos: number).

  app.component.ts: .catch(() => null) -> .catch((): null => null) on
    two firstValueFrom promises so the lack-of-return-annotation
    inference doesn't trip TS7011.

tsc --noEmit clean. Full "strict": true (strictNullChecks +
strictPropertyInitialization) is now a clear-cut separate phase --
roughly 250-300 errors mostly in pages/services that need real null
guards. Tracked in audit-round-5 for the next sprint.
…refCount=true (M1, M2)

M1 -- app.component:42 /api/monitor poller had no error handling. A
single 500 response, network blip, or backend restart would push the
error through toSignal, killing the observable forever (no retry, no
recovery). The same B11 pattern that protects current$/local$/
albumStop$/mupihat$ in media.service was just forgotten here. Add
per-request timeout(1000) + catchError-with-empty-object. The map
now requires monitor.monitor !== undefined to flip monitorOff to
true, so a recovered empty response from the catchError doesn't
spuriously show "Display off" -- distinctUntilChanged keeps the last
real value visible until the next successful poll.

M2 -- media.service mupihat$ used `shareReplay({ refCount: false })`,
which keeps the underlying interval(2000) → http.get hot for the
entire app lifetime regardless of whether any UI is subscribed. The
only consumer is mupihat-icon.component, which itself switchMaps
into the stream only when hat_active is true and the icon component
is mounted (toolbar / footer / medialist views). When no view
renders the icon -- e.g. login screen, fullscreen overlays --
~1800 wasted req/h hit /api/mupihat anyway. Switch to refCount=true
so the interval idles when nobody listens; the moment the icon
mounts, polling resumes with the next interval tick (max 2s
latency, indistinguishable in practice).

albumStop$ / network$ also use refCount=false but kept as-is here --
they have different consumer patterns and aren't called out in the
audit. Separate cleanup if needed.

tsc --noEmit clean.
@FriedrichF

Copy link
Copy Markdown

This PR does not work. There are many dependencies which rely on other PRs you submitted.
Like in file app.component.ts it imports current-media.service.ts which is first added in PR "Daily Playtime Limit".

@wowa1990

wowa1990 commented Jun 6, 2026

Copy link
Copy Markdown
Author

Hi @FriedrichF, you're right — thanks for catching this, and apologies for the confusion!

When I submitted the 10 PRs back in May, I sliced them thematically out of a local stack of branches where Phases 1–10 were all merged together. That means a few of the PRs ended up with cross-references to files first introduced in later PRs — they don't resolve standalone.

Concrete cross-PR dependencies:

The other 8 PRs (#144, #145, #147, #149, #150, #151, #152, #153) are standalone, though #152 and #153 build on top of #151 (they touch the same playtime files).

Recommended merge order:

  1. Any of Security Hardening — Pre-Auth, Escape, SSRF, Path-Traversal, CSRF #144, Bash & systemd Reliability — truncate races, jq quoting, Restart=on-failure #145, Spotify-Control & mplayer Robustness — live-reload, decode-then-escape, auto-respawn #147, Frontend Reliability Polish — API-correctness, SDK single-flight, F5-survival #149, Dev-Tooling + Hardware-Profile — backup/restore, deploy_box, 2S3P 15Ah profile #150 — mergeable independently in any order
  2. Daily Playtime Limit — Per-Weekday Caps, Grace, Blocked-Overlay, Resume-on-Cap #151 — introduces playtime + current-media + resume infrastructure
  3. Quiet Hours — Per-Weekday Zeitfenster (Homework / Bedtime) #152, Telegram Parent Controls — /status /extend /release /quietnow /limit + Multi-Chat #153 — build on Daily Playtime Limit — Per-Weekday Caps, Grace, Blocked-Overlay, Resume-on-Cap #151
  4. Backend API Reliability — atomic locks, self-heal, hang-on-missing-file #146, Performance & Caching — pagination, cover-cache, in-mem-LRU, async writes #148 — need Daily Playtime Limit — Per-Weekday Caps, Grace, Blocked-Overlay, Resume-on-Cap #151

I'm updating the descriptions of #146 and #148 right now with explicit Depends on: #151 markers so this is visible up front. If you'd prefer, I'm also happy to close #146 + #148 and resubmit them rebased on top of #151's diff so they're self-contained — just let me know what works best for your review flow.

@FriedrichF

Copy link
Copy Markdown

i am not a maintainer of this project, i am just testing it for my purpose. And hope this helps the maintainers.

I tried to merge your branches in following order: #151 -> #146 -> #148
i still get errors and can't deploy it. For example in player.page.ts there is a reference on editRawResumeAtIndex but this method doesn't exist in MediaService.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants