Skip to content

← Tornar a la llista

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.

3 min de lectura

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é.

Diagrama de l'arquitectura del host: blocs (core, protocol, transport, importers, server) units pel bus d'esdeveniments.

El context

El host té tres feines simultànies que cal coordinar:

  1. Llegir mèdia (imatges, vídeos) i convertir-les en frames RGB amb la resolució de la tira.
  2. Aplicar transformacions de color (gamma, kelvin, brightness) frame a frame.
  3. 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 — un TRANSPORT_ERROR que també peti no genera un altre TRANSPORT_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.