Arquitectura del host: events, settings, scheduler
Tres decisions van canviar com vaig pensar el host. Cap d'elles és espectacular per separat. Juntes, fan tota la diferència.
Tres decisions van canviar com penso aquest host. Cap és espectacular per separat — totes són “el que tocava fer”. Juntes han evitat tres mesos de bugs que ja sé que no tindré.
El context
El host té tres feines simultànies que cal coordinar:
- Llegir mèdia (imatges, vídeos) i convertir-les en frames RGB amb la resolució de la tira.
- Aplicar transformacions de color (gamma, kelvin, brightness) frame a frame.
- Encodar i enviar pel serial al ritme que mana el scheduler.
I a sobre, ha de poder ser controlat per dos clients diferents: un procés Unity per stdin (legacy contract), i una UI web per WebSocket (nou). Sense duplicar lògica entre les dues vies.
Si tot això viu en un sol thread amb print() per
informar-ne, ets a la mateixa pel·lícula d’abans. Tres
decisions canvien la mecànica.
La decisió
1. EventBus en comptes de print().
scripts/core/events.py exposa un pub/sub thread-safe amb
14 tipus d’esdeveniments ben definits:
PLAYBACK_*— started, paused, stopped, frame-sent, metrics.TRANSPORT_*— connected, disconnected, error, rx.RESOURCE_*— loaded, error.CACHE_*— evicted, cleared.SETTINGS_CHANGED.
Els productors (orchestrator, scheduler, transport)
emeten esdeveniments. Els consumidors (cli/ per a
Unity, server/ per a la UI web) els formaten. El core
no sap a qui parla — només sap què ha passat. Afegir un
client nou no requereix tocar cap línia del core.
2. Settings immutables.
Settings és un pydantic.BaseModel amb frozen=True. No
es muta — es substitueix. SettingsStore.patch(...) fa
això: pren les claus del patch, les fon amb la current,
re-valida el conjunt sencer i commuta atòmicament la
referència. Després emet SETTINGS_CHANGED i programa una
escriptura debounced (0.5 s) a disc.
El debounce no és cosmètica: és el que evita que mil moviments d’un slider de FPS provoquin mil writes al disc. La UI envia patches a 60 Hz; el disc rep com a molt 2/s.
3. FrameScheduler amb time.perf_counter.
L’antic codi feia time.sleep(1/fps) entre frames. Sembla
correcte. No ho és: time.time() no és monotònic (pot
saltar enrere amb NTP) i sleep acumula deriva. Al
començament és invisible; després d’una hora de playback,
el frame 100 000 ja no toca on hauria.
El FrameScheduler calcula
target_t = start_t + idx / fps amb time.perf_counter()
(monotònic, alta resolució) i sleep fins exactament aquell
moment. Si arribem tard — perquè el frame anterior ha
trigat més del pressupost —, no dormim i comptem el
frame com a “dropped” a la finestra de mètriques.
Cada segon emet PLAYBACK_METRICS amb measured_fps,
dropped, latency_p50_ms, latency_p99_ms. La UI
dibuixa un gràfic a partir d’aquests events sense saber res
del scheduler.
class Settings(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
requested_fps: int = Field(default=30, gt=0, le=120)
gamma: float = Field(default=0.4, gt=0)
# ...
def patch(self, partial: dict[str, Any]) -> Settings:
with self._lock:
merged = {**self._current.model_dump(), **partial}
new = Settings.model_validate(merged) # validators run
self._current = new
self._bus.publish(EventType.SETTINGS_CHANGED, ...)
if self._persist_path:
self._schedule_write() # debounced
return new
Per què l’EventBus no es trenca si un subscriber peta. Els subscribers es criden al thread del publisher (cap allocation de threads per event), però dins d’un try/except: si un subscriber llança, l’excepció es captura i es re-emet com a
TRANSPORT_ERROR. La cascada està protegida — unTRANSPORT_ERRORque també peti no genera un altreTRANSPORT_ERROR, simplement es descarta. Així un mal listener (un WebSocket caigut a mig missatge, per exemple) no pot mai impedir que els altres rebin el seu event.
El que ve
Tenim un host estructurat. Però tot això hauria estat inviable sense una manera de planificar el treball abans d’escriure’l. Pròxim post: BMAD i la disciplina de branques per història.