diff --git a/README.md b/README.md index bd96c17..a6ebbec 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ and serves the box's own screens: your Syncphony server's address, and Save — boxd checks the server's `/healthz`, writes it to `syncphony.txt` (still the single source of truth), and restarts the kiosk into your server's pairing screen. That - listener is on the LAN only while the box is unconfigured (later, or for - 10 minutes after a long OK press once the remote lands in stage 3), and + listener is on the LAN only while the box is unconfigured (or for the + 10 minutes after a long OK press on the TV remote), and it never offers anything beyond the URL and the box's name. - **Offline.** The kiosk never navigates straight at the server: it opens a local start page that checks `/healthz` first. If the server can't be @@ -83,6 +83,30 @@ and serves the box's own screens: Logs go to journald: `journalctl -u boxd -u syncphony-kiosk`. +## The TV remote (HDMI-CEC) + +The box talks CEC through the kernel framework (`cec-ctl`), not libcec — +see [ADR 0009](docs/adr/0009-hdmi-cec.md). Three behaviors, all under +`cec=` in `syncphony.txt`: + +- **Wake and switch.** When music starts, a TV in standby (or on another + input) gets *Image View On* + *Active Source* — queue a song from the + couch and the room turns itself on. Rate-limited to once per playback + start and once every 5 minutes, so it never fights whoever switched to + the game console. +- **The remote's keys.** Arrows, OK, Back and Play/Pause on the TV remote + become keyboard events (injected through uinput) — `/tv` already knows + them: arrows steer the visuals, right skips, OK clicks (the "Play here + instead" button is focused, so OK takes over as speaker), Play/Pause + plays and pauses. Holding **OK for 3 seconds** opens the setup window + and shows the box's IP and version on the TV. +- **Optional standby.** `cec_standby=on` puts a TV the box switched on + (and nobody has taken away since) into standby after the room has been + idle for `cec_standby_minutes` (default 30). Off by default. + +CEC support varies wildly by TV brand — what works where is recorded in +[docs/hardware.md](docs/hardware.md). + ## The kiosk session On boot the box starts `syncphony-kiosk.service`: the unprivileged `kiosk` @@ -253,7 +277,9 @@ the box from booting; bad values are logged and fall back to defaults. | `audio` | `hdmi`, `hdmi2`, `analog`, `usb` | `hdmi` | where the sound goes (`analog` is the Pi 4's jack — a Pi 5 has none; use `usb` for a DAC) | | `resolution` | e.g. `1920x1080@60` | auto | forces the HDMI mode (takes a reboot; the box reboots once on its own) | | `rotate` | `0`, `90`, `180`, `270` | `0` | rotates the screen (takes a reboot) | -| `cec` | `on`, `off` | `on` | switch the TV's input when music starts | +| `cec` | `on`, `off` | `on` | switch the TV's input when music starts (HDMI-CEC; the remote's keys drive the box too — [docs/hardware.md](docs/hardware.md) for the TV matrix) | +| `cec_standby` | `on`, `off` | `off` | put the TV in standby once the room has been idle (only ever a TV the box switched on itself) | +| `cec_standby_minutes` | `1`–`240` | `30` | how many idle minutes before that standby | On every boot, `syncphony-box-config.service` runs `boxd config apply`: it parses the file into `config.env` (kept on `/data`; `/etc/syncphony-box/config.env` @@ -298,6 +324,11 @@ has its own page: [docs/testing.md](docs/testing.md). - `aplay /usr/share/sounds/alsa/Front_Center.wav` (or `pw-play` on the same file) plays the test tone from the chosen output, on both a Pi 4 and a Pi 5. +- HDMI-CEC ([docs/testing.md](docs/testing.md), "The CEC gauntlet"): a + TV in standby wakes and switches when a song is queued; the remote's + keys drive `/tv`; a 3-second OK shows the box's IP and version; the + box never fights a hand-switch to another input. Record the TV in + [docs/hardware.md](docs/hardware.md). - `pactl info` shows the expected `Default Sink` for each `audio=` value — set it on the card, boot, check, and confirm the sink is at 100%. - Cold boot with the TV **off**: turn the TV on, pair with audio — sound @@ -348,8 +379,9 @@ has its own page: [docs/testing.md](docs/testing.md). ``` pi-gen/ pi-gen submodule (pinned, never edited) stage-syncphony/ custom pi-gen stage (packages, kiosk session, - read-only root, ...) -boxd/ the box's Go helper daemon (bridge, config, screens) + read-only root, CEC, ...) +boxd/ the box's Go helper daemon (bridge, config, screens, + watchdogs, CEC) config pi-gen build configuration build.sh wraps pi-gen's build-docker.sh (builds boxd first, then appends the /data partition after export) @@ -360,6 +392,7 @@ tests/ bats tests pinning the syncphony.txt and /data behavior (used by CI) docs/adr/ architecture decision records docs/persistent-paths.md the list of everything that survives a power cut +docs/hardware.md tested boards and the TV CEC compatibility matrix docs/testing.md the hardware test procedures (power pulls & co) ``` diff --git a/boxd/internal/cec/cec.go b/boxd/internal/cec/cec.go new file mode 100644 index 0000000..513c18b --- /dev/null +++ b/boxd/internal/cec/cec.go @@ -0,0 +1,380 @@ +// Package cec is boxd's HDMI-CEC half (MAD-807, ADR 0009). It talks to +// the kernel CEC framework through cec-ctl (v4l-utils) — not libcec — +// claims the playback logical address on the Pi's CEC adapters when boxd +// starts, watches the bus with `cec-ctl --monitor`, and does three things +// with what it sees: +// +// - Wake and switch: on a playing bridge event, Image View On + Active +// Source, so a TV in standby (or showing the game console) comes on +// and shows the box. Rate-limited to once per playback start and at +// most once every 5 minutes, so it never fights whoever holds the TV. +// - Remote keys: CEC User Control Pressed (arrows, OK, Back, Play, +// Pause) become Linux key events through uinput; cage hands them to +// Chromium like any keyboard, and /tv already knows those keys. A +// 3-second hold of OK is the box's one remote gesture: it opens the +// setup window and shows the box's IP and version. +// - Optional standby (cec_standby=on, off by default): when the room +// has been idle for cec_standby_minutes and the TV is showing the +// box because the box switched it on — and nobody else has taken the +// TV since (the monitor watches for foreign Active Source) — send +// Standby. +// +// Everything hardware-facing is a func field, stubbed in tests, in the +// spirit of boxd's vcgencmd hook. A machine with no /dev/cec (a dev box, +// a Pi with CEC disabled) simply runs with all of this inert. +package cec + +import ( + "io" + "log" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// wakeEvery rate-limits the wake-and-switch: however often playback +// starts, the TV is only seized once every 5 minutes, so someone who +// switched to the game console mid-party isn't fought for the input. +const wakeEvery = 5 * time.Minute + +// Config is the CEC-relevant slice of syncphony.txt. +type Config struct { + Enabled bool // cec=on|off + SecondHDMI bool // audio=hdmi2: the TV is on /dev/cec1 + Standby bool // cec_standby=on|off (off by default) + StandbyAfter time.Duration // cec_standby_minutes= (default 30m) +} + +// Controller owns the adapters, the monitor processes and the uinput +// keyboard. Construct with New; Start claims the adapters and spawns the +// monitors; WakeAndSwitch and Housekeeping are called by the daemon. +type Controller struct { + mu sync.Mutex + log *log.Logger + cfg Config + + // The hardware edges, stubbed in tests. + cecCtl func(dev string, args ...string) (string, error) + startMonitor func(dev string) (io.ReadCloser, func(), error) + openKeydev func() (keyDevice, error) + listDevices func() []string + now func() time.Time + + adapters []*adapter + keys *remoteKeys + lastWake time.Time + takenOver bool // the TV is on the box because the box switched it on +} + +// adapter is one /dev/cecN: its device path and the physical address the +// kernel last reported for it (refreshed whenever the box transmits as +// active source — a hotplug or a different TV socket can change it). +type adapter struct { + dev string + pa string + rc io.ReadCloser + stop func() +} + +// New builds a controller. onLongOK fires when OK is held for 3 seconds; +// it is called from the monitor goroutine. +func New(cfg Config, lg *log.Logger, onLongOK func()) *Controller { + if cfg.StandbyAfter <= 0 { + cfg.StandbyAfter = 30 * time.Minute + } + c := &Controller{ + log: lg, + cfg: cfg, + cecCtl: func(dev string, args ...string) (string, error) { + return runCecCtl(dev, args...) + }, + startMonitor: realMonitor, + openKeydev: func() (keyDevice, error) { return openUinput() }, + listDevices: realDevices, + now: time.Now, + } + c.keys = newRemoteKeys(onLongOK) + return c +} + +// realDevices lists the CEC devices this box has: /dev/cec0 always, and +// /dev/cec1 when the TV is on the second HDMI port (audio=hdmi2). +func realDevices() []string { + devs := []string{"/dev/cec0"} + if _, err := os.Stat("/dev/cec1"); err == nil { + devs = append(devs, "/dev/cec1") + } + return devs +} + +// Start registers the adapters as playback devices and starts their +// monitors, plus the uinput keyboard for the remote's keys. Devices that +// don't exist are skipped (with one log line); a box with none runs on +// without CEC. It reports how many adapters came up. +func (c *Controller) Start() int { + if !c.cfg.Enabled { + return 0 + } + devs := c.listDevices() + if !c.cfg.SecondHDMI { + // The TV is on HDMI0; cec1 (if it exists) isn't ours to claim. + devs = devs[:1] + } + started := 0 + for _, dev := range devs { + if _, err := c.cecCtl(dev, "--playback"); err != nil { + c.log.Printf("boxd: cec: %s: cannot register as a playback device: %v", dev, err) + continue + } + a := &adapter{dev: dev} + if out, err := c.cecCtl(dev); err == nil { + a.pa = parsePhysAddr(out) + } + rc, stop, err := c.startMonitor(dev) + if err != nil { + c.log.Printf("boxd: cec: %s: cannot monitor: %v", dev, err) + continue + } + a.rc, a.stop = rc, stop + c.mu.Lock() + c.adapters = append(c.adapters, a) + c.mu.Unlock() + go c.watchMonitor(a) + started++ + c.log.Printf("boxd: cec: %s up (physical address %s)", dev, orUnknown(a.pa)) + } + if started > 0 { + if kd, err := c.openKeydev(); err != nil { + c.log.Printf("boxd: cec: no uinput keyboard (/dev/uinput): %v — the TV remote's keys are off", err) + } else { + c.mu.Lock() + c.keys.attach(kd) + c.mu.Unlock() + } + } + return started +} + +// Close stops the monitors and releases the uinput device. +func (c *Controller) Close() { + c.mu.Lock() + defer c.mu.Unlock() + for _, a := range c.adapters { + if a.stop != nil { + a.stop() + } + } + c.adapters = nil + if c.keys != nil { + c.keys.close() + } +} + +// activeDev is the adapter the TV is plugged into: /dev/cec1 for +// audio=hdmi2 when it came up, /dev/cec0 otherwise. +func (c *Controller) activeDev() *adapter { + c.mu.Lock() + defer c.mu.Unlock() + var fallback *adapter + for _, a := range c.adapters { + if c.cfg.SecondHDMI && a.dev == "/dev/cec1" { + return a + } + if a.dev == "/dev/cec0" { + fallback = a + } + } + return fallback +} + +// WakeAndSwitch turns the TV on (Image View On) and switches it to the +// box (Active Source). Called once per playback start by the bridge; the +// rate limit here is the second lock — never more than once every +// wakeEvery — so the box doesn't fight whoever holds the TV. +func (c *Controller) WakeAndSwitch() { + if !c.cfg.Enabled { + return + } + a := c.activeDev() + if a == nil { + return + } + c.mu.Lock() + if !c.lastWake.IsZero() && c.now().Sub(c.lastWake) < wakeEvery { + c.mu.Unlock() + return + } + c.lastWake = c.now() + c.mu.Unlock() + + // A fresh read of the physical address: hotplugs and different TV + // sockets change it, and an Active Source with a stale address does + // nothing on some TVs. + if out, err := c.cecCtl(a.dev); err == nil { + if pa := parsePhysAddr(out); pa != "" { + c.mu.Lock() + a.pa = pa + c.mu.Unlock() + } + } + c.mu.Lock() + pa := a.pa + c.mu.Unlock() + if pa == "" { + c.log.Printf("boxd: cec: %s: no physical address; cannot switch the TV", a.dev) + return + } + if _, err := c.cecCtl(a.dev, "--to=0", "--image-view-on"); err != nil { + c.log.Printf("boxd: cec: %s: image-view-on: %v", a.dev, err) + } + if _, err := c.cecCtl(a.dev, "--to=0", "--active-source=phys-addr="+pa); err != nil { + c.log.Printf("boxd: cec: %s: active-source %s: %v", a.dev, pa, err) + return + } + c.mu.Lock() + c.takenOver = true + c.mu.Unlock() + c.log.Printf("boxd: cec: woke the TV and switched it to this box (%s, %s)", a.dev, pa) +} + +// Housekeeping runs the optional standby: cec_standby=on only, after the +// room has been idle for the configured minutes, and only while the TV +// is on the box because the box switched it on — a TV someone else took +// over (or that was never seized) is left alone. +func (c *Controller) Housekeeping(idleFor time.Duration) { + if !c.cfg.Enabled || !c.cfg.Standby { + return + } + c.mu.Lock() + taken := c.takenOver + c.mu.Unlock() + if !taken || idleFor < c.cfg.StandbyAfter { + return + } + a := c.activeDev() + if a == nil { + return + } + if _, err := c.cecCtl(a.dev, "--to=0", "--standby"); err != nil { + c.log.Printf("boxd: cec: %s: standby: %v", a.dev, err) + return + } + c.mu.Lock() + c.takenOver = false + c.mu.Unlock() + c.log.Printf("boxd: cec: room idle for %s; TV to standby", idleFor.Round(time.Minute)) +} + +// watchMonitor reads one adapter's monitor output until it stops. A +// monitor that dies (a driver reload, a transient open) is restarted +// after a pause, for as long as the controller holds the adapter. +func (c *Controller) watchMonitor(a *adapter) { + for { + c.readMonitor(a) + c.mu.Lock() + held := c.holds(a) + c.mu.Unlock() + if !held { + return + } + time.Sleep(10 * time.Second) + rc, stop, err := c.startMonitor(a.dev) + if err != nil { + c.log.Printf("boxd: cec: %s: monitor restart failed: %v", a.dev, err) + continue + } + c.mu.Lock() + a.rc, a.stop = rc, stop + c.mu.Unlock() + c.log.Printf("boxd: cec: %s: monitor restarted", a.dev) + } +} + +func (c *Controller) holds(a *adapter) bool { + for _, x := range c.adapters { + if x == a { + return true + } + } + return false +} + +// readMonitor parses one monitor run's lines into events. +func (c *Controller) readMonitor(a *adapter) { + c.mu.Lock() + rc := a.rc + c.mu.Unlock() + if rc == nil { + return + } + p := &monitorParser{} + readLines(rc, func(line string) { + for _, ev := range p.line(line) { + c.handleEvent(a, ev) + } + }) +} + +// handleEvent dispatches one parsed monitor event: remote keys to the +// uinput state machine, a foreign Active Source to the takeover flag. +func (c *Controller) handleEvent(a *adapter, ev monitorEvent) { + switch ev.kind { + case evKeyPress: + c.mu.Lock() + keys := c.keys + c.mu.Unlock() + if keys != nil { + keys.press(ev.ui) + } + case evKeyRelease: + c.mu.Lock() + keys := c.keys + c.mu.Unlock() + if keys != nil { + keys.release(ev.ui) + } + case evActiveSource: + if !ev.received { + return // our own Active Source announcement + } + c.mu.Lock() + ours := a.pa + c.mu.Unlock() + if ours != "" && ev.pa != ours { + c.takenOver = false + c.log.Printf("boxd: cec: %s became the active source; leaving the TV alone", ev.pa) + } + } +} + +// --- cec-ctl plumbing --------------------------------------------------------- + +var physAddrRE = regexp.MustCompile(`Physical Address\s*:\s*([0-9a-f]+(?:\.[0-9a-f]+){3})`) + +// parsePhysAddr reads the physical address out of cec-ctl's device info +// ("Physical Address : 1.0.0.0"). Empty means it wasn't there. +func parsePhysAddr(out string) string { + if m := physAddrRE.FindStringSubmatch(out); m != nil { + return m[1] + } + return "" +} + +// parseUICmdHex parses a UI command code like "0x44". +func parseUICmdHex(s string) (byte, bool) { + v, err := strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 8) + if err != nil { + return 0, false + } + return byte(v), true +} + +func orUnknown(s string) string { + if s == "" { + return "unknown" + } + return s +} diff --git a/boxd/internal/cec/cec_test.go b/boxd/internal/cec/cec_test.go new file mode 100644 index 0000000..be7f27d --- /dev/null +++ b/boxd/internal/cec/cec_test.go @@ -0,0 +1,434 @@ +package cec + +import ( + "bytes" + "io" + "log" + "strings" + "sync" + "testing" + "time" + "unsafe" +) + +// --- monitor parsing ----------------------------------------------------------- + +func TestMonitorParserParsesRemoteKeys(t *testing.T) { + p := &monitorParser{} + var got []monitorEvent + for _, line := range []string{ + "Received from TV (0 to 8): USER_CONTROL_PRESSED (0x44):", + "\tui-cmd: select (0x00)", + "Received from TV (0 to 8): USER_RELEASED (0x45):", + "\tui-cmd: select (0x00)", + "Received from TV (0 to 8): USER_CONTROL_PRESSED (0x44):", + "\tui-cmd: right (0x04)", + } { + got = append(got, p.line(line)...) + } + if len(got) != 3 { + t.Fatalf("want 3 events, got %d: %+v", len(got), got) + } + if got[0].kind != evKeyPress || got[0].ui != uiSelect { + t.Fatalf("event 0: %+v", got[0]) + } + if got[1].kind != evKeyRelease || got[1].ui != uiSelect { + t.Fatalf("event 1: %+v", got[1]) + } + if got[2].kind != evKeyPress || got[2].ui != uiRight { + t.Fatalf("event 2: %+v", got[2]) + } +} + +func TestMonitorParserParsesActiveSource(t *testing.T) { + p := &monitorParser{} + var got []monitorEvent + for _, line := range []string{ + "Received from TV (0 to 15): ACTIVE_SOURCE (0x82):", + "\tphys-addr: 2.0.0.0", + "Transmitted by Playback 1 (8 to 15): ACTIVE_SOURCE (0x82):", + "\tphys-addr: 1.0.0.0", + } { + got = append(got, p.line(line)...) + } + if len(got) != 2 { + t.Fatalf("want 2 events, got %d: %+v", len(got), got) + } + if !got[0].received || got[0].pa != "2.0.0.0" { + t.Fatalf("received event: %+v", got[0]) + } + if got[1].received || got[1].pa != "1.0.0.0" { + t.Fatalf("transmitted event: %+v", got[1]) + } +} + +func TestMonitorParserSkipsOtherTraffic(t *testing.T) { + p := &monitorParser{} + for _, line := range []string{ + "Received from TV (0 to 8): REPORT_POWER_STATUS (0x90):", + "\tpower-status: on", + "Received from TV (0 to 15): SET_STREAM_PATH (0x82):", + "", + "Poll: TV (0 to 8)", + } { + if evs := p.line(line); len(evs) != 0 { + t.Fatalf("line %q produced %+v", line, evs) + } + } +} + +func TestParsePhysAddr(t *testing.T) { + info := "Driver Name : vc4\n" + + "\tPhysical Address : 1.0.0.0\n" + + "\tLogical Address Mask : 0x0100\n" + if got := parsePhysAddr(info); got != "1.0.0.0" { + t.Fatalf("got %q", got) + } + if got := parsePhysAddr("nothing here"); got != "" { + t.Fatalf("got %q", got) + } +} + +// --- key mapping --------------------------------------------------------------- + +func TestMapKey(t *testing.T) { + for _, tc := range []struct { + ui byte + want uint16 + }{ + {uiSelect, keyEnter}, + {uiUp, keyUp}, + {uiDown, keyDown}, + {uiLeft, keyLeft}, + {uiRight, keyRight}, + {uiExit, keyEsc}, + {uiPlay, keyPlayPause}, + {uiPause, keyPlayPause}, + } { + got, ok := mapKey(tc.ui) + if !ok || got != tc.want { + t.Fatalf("ui 0x%02x: got %d,%v want %d", tc.ui, got, ok, tc.want) + } + } + for _, ui := range []byte{0x09, 0x45, 0x30, 0xff} { // menus, stop, digits: not ours + if _, ok := mapKey(ui); ok { + t.Fatalf("ui 0x%02x should be unmapped", ui) + } + } +} + +// --- the remote-keys state machine ---------------------------------------------- + +// recordingKeys builds a remoteKeys whose injections are recorded as +// "code:down|up" strings. +func recordingKeys() (*remoteKeys, func() []string) { + var mu sync.Mutex + var log []string + r := newRemoteKeys(nil) + r.setInject(func(code uint16, down bool) { + mu.Lock() + defer mu.Unlock() + s := "up" + if down { + s = "down" + } + log = append(log, itoa(int(code))+":"+s) + }) + return r, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), log...) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [8]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +func TestShortOKClicksOnRelease(t *testing.T) { + old := longOKDelay + longOKDelay = 5 * time.Second + defer func() { longOKDelay = old }() + r, log := recordingKeys() + r.press(uiSelect) + if got := log(); len(got) != 0 { + t.Fatalf("Enter must wait for the release, got %v", got) + } + r.release(uiSelect) + if got := strings.Join(log(), " "); got != "28:down 28:up" { + t.Fatalf("want one Enter click, got %q", got) + } +} + +func TestLongOKFiresGestureAndSwallowsTheClick(t *testing.T) { + old := longOKDelay + longOKDelay = 20 * time.Millisecond + defer func() { longOKDelay = old }() + + var fired sync.Mutex + n := 0 + r := newRemoteKeys(func() { fired.Lock(); n++; fired.Unlock() }) + r.setInject(func(code uint16, down bool) {}) + r.press(uiSelect) + waitFor(t, func() bool { + fired.Lock() + defer fired.Unlock() + return n == 1 + }) + r.release(uiSelect) // the late release must not click + fired.Lock() + defer fired.Unlock() + if n != 1 { + t.Fatalf("gesture fired %d times", n) + } +} + +func TestHeldKeyIsReleasedByTheGuard(t *testing.T) { + old := keyReleaseGuard + keyReleaseGuard = 20 * time.Millisecond + defer func() { keyReleaseGuard = old }() + r, log := recordingKeys() + r.press(uiRight) + waitFor(t, func() bool { + return strings.Join(log(), " ") == "106:down 106:up" + }) + r.release(uiRight) // a late release is a no-op, not a second up + if got := log(); len(got) != 2 { + t.Fatalf("late release injected again: %v", got) + } +} + +func TestSelectRepeatsAreIgnoredWhileHeld(t *testing.T) { + old := longOKDelay + longOKDelay = 40 * time.Millisecond + defer func() { longOKDelay = old }() + r, log := recordingKeys() + r.press(uiSelect) + r.press(uiSelect) // the TV repeats Pressed while held + r.press(uiSelect) + if got := log(); len(got) != 0 { + t.Fatalf("repeats must not click: %v", got) + } + r.release(uiSelect) + if got := strings.Join(log(), " "); got != "28:down 28:up" { + t.Fatalf("want one click after repeats, got %q", got) + } +} + +// --- the controller: wake, standby, takeover ------------------------------------ + +// testController builds a controller with every hardware edge stubbed. +type ctlCalls struct { + mu sync.Mutex + calls []string +} + +func (c *ctlCalls) add(s string) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls = append(c.calls, s) +} + +func (c *ctlCalls) list() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.calls...) +} + +func testController(t *testing.T, cfg Config) (*Controller, *ctlCalls) { + t.Helper() + calls := &ctlCalls{} + c := New(cfg, log.New(io.Discard, "", 0), nil) + c.cecCtl = func(dev string, args ...string) (string, error) { + calls.add(dev + " " + strings.Join(args, " ")) + pa := "1.0.0.0" + if strings.HasSuffix(dev, "1") { + pa = "2.0.0.0" + } + return "\tPhysical Address : " + pa + "\n", nil + } + c.startMonitor = func(dev string) (io.ReadCloser, func(), error) { + return io.NopCloser(&bytes.Buffer{}), func() {}, nil + } + c.openKeydev = func() (keyDevice, error) { return nopKeydev{}, nil } + return c, calls +} + +type nopKeydev struct{} + +func (nopKeydev) Press(uint16) error { return nil } +func (nopKeydev) Release(uint16) error { return nil } +func (nopKeydev) Close() error { return nil } + +func TestWakeAndSwitchSendsBothAndRatesLimits(t *testing.T) { + c, calls := testController(t, Config{Enabled: true}) + c.mu.Lock() + c.adapters = []*adapter{{dev: "/dev/cec0", pa: "1.0.0.0"}} + c.mu.Unlock() + + c.WakeAndSwitch() + got := strings.Join(calls.list(), ";") + for _, want := range []string{ + "/dev/cec0 --to=0 --image-view-on", + "/dev/cec0 --to=0 --active-source=phys-addr=1.0.0.0", + } { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } + c.mu.Lock() + taken := c.takenOver + c.mu.Unlock() + if !taken { + t.Fatal("wake should mark the TV as taken over by the box") + } + + // Within the window: nothing more is sent, however often playback starts. + before := len(calls.list()) + c.WakeAndSwitch() + if n := len(calls.list()); n != before { + t.Fatalf("rate limit failed: %d extra calls", n-before) + } + + // Past the window it fires again. + c.mu.Lock() + c.lastWake = time.Now().Add(-wakeEvery - time.Second) + c.mu.Unlock() + c.WakeAndSwitch() + if n := len(calls.list()); n == before { + t.Fatal("should wake again after the window") + } +} + +func TestWakePicksTheSecondHDMIAdapter(t *testing.T) { + c, calls := testController(t, Config{Enabled: true, SecondHDMI: true}) + c.mu.Lock() + c.adapters = []*adapter{ + {dev: "/dev/cec0", pa: "1.0.0.0"}, + {dev: "/dev/cec1", pa: "2.0.0.0"}, + } + c.mu.Unlock() + c.WakeAndSwitch() + got := strings.Join(calls.list(), ";") + if !strings.Contains(got, "/dev/cec1 --to=0 --active-source=phys-addr=2.0.0.0") { + t.Fatalf("hdmi2 should drive cec1: %q", got) + } +} + +func TestStandbyOnlyWhenTakenOverAndIdleEnough(t *testing.T) { + c, calls := testController(t, Config{Enabled: true, Standby: true, StandbyAfter: 30 * time.Minute}) + c.mu.Lock() + c.adapters = []*adapter{{dev: "/dev/cec0", pa: "1.0.0.0"}} + c.mu.Unlock() + + // Not idle long enough, and not taken over: nothing. + c.Housekeeping(10 * time.Minute) + c.mu.Lock() + c.takenOver = true + c.mu.Unlock() + c.Housekeeping(10 * time.Minute) + if got := calls.list(); len(got) != 0 { + t.Fatalf("standby fired early: %v", got) + } + + // Idle long enough with the TV ours: one standby, and the flag clears. + c.Housekeeping(31 * time.Minute) + got := calls.list() + if len(got) != 1 || !strings.Contains(got[0], "--standby") { + t.Fatalf("want one standby, got %v", got) + } + c.mu.Lock() + taken := c.takenOver + c.mu.Unlock() + if taken { + t.Fatal("standby should clear the takeover flag") + } + c.Housekeeping(time.Hour) // nothing more: the TV isn't ours anymore + if n := len(calls.list()); n != 1 { + t.Fatalf("standby repeated: %v", calls.list()) + } +} + +func TestStandbyOffByDefault(t *testing.T) { + c, calls := testController(t, Config{Enabled: true}) // Standby: false + c.mu.Lock() + c.adapters = []*adapter{{dev: "/dev/cec0", pa: "1.0.0.0"}} + c.takenOver = true + c.mu.Unlock() + c.Housekeeping(time.Hour) + if got := calls.list(); len(got) != 0 { + t.Fatalf("standby must be opt-in: %v", got) + } +} + +func TestForeignActiveSourceClearsTakeover(t *testing.T) { + c, _ := testController(t, Config{Enabled: true, Standby: true, StandbyAfter: time.Millisecond}) + c.mu.Lock() + c.adapters = []*adapter{{dev: "/dev/cec0", pa: "1.0.0.0"}} + c.takenOver = true + c.mu.Unlock() + + // The monitor sees the game console become active (a foreign PA). + traffic := "Received from Tuner 1 (4 to 15): ACTIVE_SOURCE (0x82):\n" + + "\tphys-addr: 2.0.0.0\n" + + "Received from TV (0 to 15): ACTIVE_SOURCE (0x82):\n" + + "\tphys-addr: 1.0.0.0\n" // a TV re-announcing us: not a takeover + c.mu.Lock() + a := c.adapters[0] + a.rc = io.NopCloser(strings.NewReader(traffic)) + c.mu.Unlock() + c.readMonitor(a) + + c.mu.Lock() + taken := c.takenOver + c.mu.Unlock() + if taken { + t.Fatal("a foreign Active Source must clear the takeover flag") + } +} + +func TestDisabledControllerDoesNothing(t *testing.T) { + c, calls := testController(t, Config{Enabled: false}) + c.mu.Lock() + c.adapters = []*adapter{{dev: "/dev/cec0", pa: "1.0.0.0"}} + c.mu.Unlock() + c.WakeAndSwitch() + c.Housekeeping(time.Hour) + if got := calls.list(); len(got) != 0 { + t.Fatalf("cec=off must be inert, got %v", got) + } +} + +// --- the uinput ABI assumption ---------------------------------------------------- + +func TestUinputStructSizes(t *testing.T) { + if got := unsafeSize(inputEvent{}); got != 24 { + t.Fatalf("input_event is %d bytes, want 24", got) + } + if got := unsafeSize(uinputUserDev{}); got != 1116 { + t.Fatalf("uinput_user_dev is %d bytes, want 1116", got) + } +} + +func unsafeSize[T any](v T) int { return int(unsafe.Sizeof(v)) } + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("condition never met") + } + time.Sleep(5 * time.Millisecond) + } +} diff --git a/boxd/internal/cec/keys.go b/boxd/internal/cec/keys.go new file mode 100644 index 0000000..ab8f086 --- /dev/null +++ b/boxd/internal/cec/keys.go @@ -0,0 +1,205 @@ +package cec + +import ( + "sync" + "time" +) + +// The remote's keys. CEC User Control Pressed/Released arrive as pairs; +// they become Linux key events through the injected uinput keyboard, +// which cage passes to Chromium like any keyboard. /tv already knows the +// keys (tvKeyOf in the web app): Enter clicks, arrows steer, Escape +// leaves, MediaPlayPause plays and pauses. +// +// Linux key codes from linux/input-event-codes.h. +const ( + keyEsc = 1 + keyEnter = 28 + keyUp = 103 + keyLeft = 105 + keyRight = 106 + keyDown = 108 + keyPlayPause = 164 +) + +// CEC UI command codes (HDMI CEC 1.4 table "UI Command codes") for the +// keys /tv uses. Everything else on the remote is ignored — number keys, +// colored keys, volume (the TV keeps its own volume, as it should). +const ( + uiSelect = 0x00 + uiUp = 0x01 + uiDown = 0x02 + uiLeft = 0x03 + uiRight = 0x04 + uiExit = 0x0d + uiPlay = 0x44 + uiPause = 0x46 +) + +// mapKey maps a CEC UI command to the Linux key event it becomes. +func mapKey(ui byte) (uint16, bool) { + switch ui { + case uiSelect: + return keyEnter, true + case uiUp: + return keyUp, true + case uiDown: + return keyDown, true + case uiLeft: + return keyLeft, true + case uiRight: + return keyRight, true + case uiExit: + return keyEsc, true + case uiPlay, uiPause: + return keyPlayPause, true + } + return 0, false +} + +// Vars so the tests can shorten the waits; the values are the product. +var ( + // longOKDelay: OK held this long is the box gesture, not a click. + longOKDelay = 3 * time.Second + // keyReleaseGuard: a key whose User Control Released never arrives + // (some TVs and repeaters lose them) is released anyway, so nothing + // sticks down in Chromium. + keyReleaseGuard = time.Second +) + +// remoteKeys is the press/release state machine between the monitor and +// the uinput keyboard. +// +// OK is special (hold-to-gesture): the Enter keydown is held back until +// the release says it was a short press — otherwise the 3-second hold +// would click whatever is focused on /tv before the gesture fires. A +// hold fires onLongOK instead, and the eventual release is swallowed. +type remoteKeys struct { + mu sync.Mutex + inject func(code uint16, down bool) + onLongOK func() + timers map[byte]*time.Timer + forced map[byte]bool // the release guard already released this key + okLong bool // OK is past the long-press point + dev keyDevice +} + +func newRemoteKeys(onLongOK func()) *remoteKeys { + return &remoteKeys{ + onLongOK: onLongOK, + timers: make(map[byte]*time.Timer), + forced: make(map[byte]bool), + } +} + +// attach wires a real uinput keyboard (Start calls it once the adapters +// are up; until then presses are counted but go nowhere). +func (r *remoteKeys) attach(dev keyDevice) { + r.mu.Lock() + defer r.mu.Unlock() + r.dev = dev + r.inject = func(code uint16, down bool) { + if down { + dev.Press(code) //nolint:errcheck — logged in the uinput layer + } else { + dev.Release(code) //nolint:errcheck + } + } +} + +// close releases the uinput device. +func (r *remoteKeys) close() { + r.mu.Lock() + defer r.mu.Unlock() + for _, t := range r.timers { + t.Stop() + } + r.timers = nil + if r.dev != nil { + r.dev.Close() //nolint:errcheck + r.dev = nil + } +} + +func (r *remoteKeys) press(ui byte) { + r.mu.Lock() + defer r.mu.Unlock() + if r.timers == nil { + return // closed + } + if ui == uiSelect { + // Hold back Enter; a short release clicks, a 3 s hold gestures. + // Some TVs keep sending User Control Pressed while OK is held — + // the repeats are ignored so the hold can mature. + if r.timers[ui] != nil { + return + } + r.okLong = false + r.timers[ui] = time.AfterFunc(longOKDelay, func() { + r.mu.Lock() + r.okLong = true + onLongOK := r.onLongOK + r.mu.Unlock() + if onLongOK != nil { + onLongOK() + } + }) + return + } + code, ok := mapKey(ui) + if !ok { + return + } + if r.inject != nil { + r.inject(code, true) + } + r.forced[ui] = false + // No User Control Released within the guard → release anyway; the + // late release, if it ever comes, is then a no-op. + r.timers[ui] = time.AfterFunc(keyReleaseGuard, func() { + r.mu.Lock() + r.forced[ui] = true + inject := r.inject + r.mu.Unlock() + if inject != nil { + inject(code, false) + } + }) +} + +func (r *remoteKeys) release(ui byte) { + r.mu.Lock() + defer r.mu.Unlock() + if r.timers == nil { + return + } + if t := r.timers[ui]; t != nil { + t.Stop() + delete(r.timers, ui) + } + if ui == uiSelect { + if r.okLong { // the gesture already happened + r.okLong = false + return + } + if r.inject != nil { // a short press: click now + r.inject(keyEnter, true) + r.inject(keyEnter, false) + } + return + } + if r.forced[ui] { // the guard beat the release to it + delete(r.forced, ui) + return + } + if code, ok := mapKey(ui); ok && r.inject != nil { + r.inject(code, false) + } +} + +// setInject overrides the injection edge (tests). +func (r *remoteKeys) setInject(fn func(code uint16, down bool)) { + r.mu.Lock() + defer r.mu.Unlock() + r.inject = fn +} diff --git a/boxd/internal/cec/monitor.go b/boxd/internal/cec/monitor.go new file mode 100644 index 0000000..6505eef --- /dev/null +++ b/boxd/internal/cec/monitor.go @@ -0,0 +1,132 @@ +package cec + +import ( + "bufio" + "context" + "io" + "os/exec" + "regexp" + "strings" + "time" +) + +// The monitor side. `cec-ctl -d /dev/cecN --monitor` prints every message +// the adapter sees, one message per line followed by tab-indented operand +// lines (v4l-utils' cec-log format, stable across the versions Debian +// ships): +// +// Transmitted by Playback 1 (8 to 0): IMAGE_VIEW_ON (0x04) +// Received from TV (0 to 8): USER_CONTROL_PRESSED (0x44): +// ui-cmd: select (0x00) +// Received from TV (0 to 15): ACTIVE_SOURCE (0x82): +// phys-addr: 1.0.0.0 +// +// The parser keeps the pending opcode from a header line and completes it +// from the operand line that follows, so both the exact spacing and any +// future single-line variant still parse. + +type eventKind int + +const ( + evKeyPress eventKind = iota + evKeyRelease + evActiveSource +) + +type monitorEvent struct { + kind eventKind + ui byte // for press/release: the CEC UI command code + pa string // for active source: the physical address + received bool // true when someone else sent it, false when we did +} + +type monitorParser struct { + pending string // the opcode whose operands are expected next + received bool // ...and whether it was received or transmitted +} + +var ( + uiCmdRE = regexp.MustCompile(`ui-cmd:.*\(0x([0-9a-fA-F]{2})\)`) + monPaRE = regexp.MustCompile(`phys-addr:\s*([0-9a-f]+(?:\.[0-9a-f]+){3})`) +) + +// line feeds one monitor line and returns the events it completed (at +// most one per line in practice; the slice keeps the signature stable). +func (p *monitorParser) line(s string) []monitorEvent { + if strings.HasPrefix(s, "\t") || strings.HasPrefix(s, " ") { + defer func() { p.pending = "" }() + switch p.pending { + case "USER_CONTROL_PRESSED", "USER_RELEASED": + if m := uiCmdRE.FindStringSubmatch(s); m != nil { + if ui, ok := parseUICmdHex(m[1]); ok { + kind := evKeyPress + if p.pending == "USER_RELEASED" { + kind = evKeyRelease + } + return []monitorEvent{{kind: kind, ui: ui}} + } + } + case "ACTIVE_SOURCE": + if m := monPaRE.FindStringSubmatch(s); m != nil { + return []monitorEvent{{kind: evActiveSource, pa: m[1], received: p.received}} + } + } + return nil + } + switch { + case strings.Contains(s, "USER_CONTROL_PRESSED"): + p.pending = "USER_CONTROL_PRESSED" + case strings.Contains(s, "USER_RELEASED"): + p.pending = "USER_RELEASED" + case strings.Contains(s, "ACTIVE_SOURCE"): + p.pending = "ACTIVE_SOURCE" + p.received = strings.HasPrefix(s, "Received") + } + return nil +} + +// readLines calls fn for every line of rc until EOF, guarding against a +// monitor that never ends its last line. +func readLines(rc io.Reader, fn func(string)) { + sc := bufio.NewScanner(rc) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + fn(sc.Text()) + } +} + +// runCecCtl runs cec-ctl once for an adapter and returns its stdout. +// Bounded, so a wedged CEC bus can't hang boxd's callers. +func runCecCtl(dev string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + full := append([]string{"-d", dev}, args...) + out, err := exec.CommandContext(ctx, "cec-ctl", full...).Output() + return string(out), err +} + +// realMonitor spawns `cec-ctl -d --monitor` and returns its stdout +// plus a stop function that kills the process. +func realMonitor(dev string) (io.ReadCloser, func(), error) { + cmd := exec.Command("cec-ctl", "-d", dev, "--monitor") + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, err + } + if err := cmd.Start(); err != nil { + return nil, nil, err + } + done := make(chan struct{}) + go func() { + cmd.Wait() //nolint:errcheck — the stop path reports what it needs + close(done) + }() + stop := func() { + cmd.Process.Kill() //nolint:errcheck — best effort; EOF follows + select { + case <-done: + case <-time.After(2 * time.Second): + } + } + return stdout, stop, nil +} diff --git a/boxd/internal/cec/uinput.go b/boxd/internal/cec/uinput.go new file mode 100644 index 0000000..4e9b78e --- /dev/null +++ b/boxd/internal/cec/uinput.go @@ -0,0 +1,140 @@ +package cec + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +// The injected keyboard. uinput is the kernel's "make a keyboard" API: +// create a virtual device, tell it which keys it has, write input_event +// structs. cage (like any Wayland compositor) picks the device up through +// libinput and Chromium sees ordinary key events — no shell, no root +// helper, just /dev/uinput (which the udev rule in +// stage-syncphony/08-cec grants boxd's group). + +// From linux/input.h and linux/uinput.h. +const ( + evSyn = 0x00 + evKey = 0x01 + + synReport = 0x00 + + uiSetEvBit = 0x40045564 // _IOW('U', 100, int) + uiSetKeyBit = 0x40045565 // _IOW('U', 101, int) + uiDevCreate = 0x5501 // _IO('U', 1) +) + +// inputEvent is linux input_event for a 64-bit kernel: a timeval (two +// longs — kernel time), then type, code, value. 24 bytes. +type inputEvent struct { + Sec int64 + Usec int64 + Type uint16 + Code uint16 + Value int32 +} + +// uinputUserDev is linux uinput_user_dev: a name, an input_id, and the +// absolute-axis arrays (zeroed for a keyboard). 1116 bytes. +type uinputUserDev struct { + Name [80]byte + Bustype uint16 + Vendor uint16 + Product uint16 + Version uint16 + FFEffectsMax int32 + Absmax [64]int32 + Absmin [64]int32 + Absfuzz [64]int32 + Absflat [64]int32 +} + +// keyDevice is the keyboard remoteKeys injects through; an interface so +// tests can record instead of typing. +type keyDevice interface { + Press(code uint16) error + Release(code uint16) error + Close() error +} + +// uinputDev is a real /dev/uinput keyboard. SB_UINPUT points it at +// another node in dev and tests, mirroring the SB_* hooks. +type uinputDev struct { + f *os.File +} + +// theKeys is every key mapKey can produce, plus the ones /tv might grow; +// registering them all up front keeps the device from surprising libinput +// with a new keycode later. +var theKeys = []uint16{keyEsc, keyEnter, keyUp, keyDown, keyLeft, keyRight, keyPlayPause} + +func openUinput() (keyDevice, error) { + path := "/dev/uinput" + if p := os.Getenv("SB_UINPUT"); p != "" { + path = p + } + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + d := &uinputDev{f: os.NewFile(uintptr(fd), path)} + if err := d.ioctl(uiSetEvBit, int64(evKey)); err != nil { + d.f.Close() + return nil, err + } + for _, k := range theKeys { + if err := d.ioctl(uiSetKeyBit, int64(k)); err != nil { + d.f.Close() + return nil, err + } + } + var u uinputUserDev + copy(u.Name[:], "Syncphony Box remote") + u.Bustype = 0x06 // BUS_VIRTUAL + u.Vendor, u.Product, u.Version = 0x0001, 0x0001, 0x0001 + if _, err := d.f.Write(unsafeSlice(u)); err != nil { + d.f.Close() + return nil, fmt.Errorf("write uinput_user_dev: %w", err) + } + if err := d.ioctlRaw(uiDevCreate); err != nil { + d.f.Close() + return nil, err + } + return d, nil +} + +func (d *uinputDev) Press(code uint16) error { return d.event(code, 1) } +func (d *uinputDev) Release(code uint16) error { return d.event(code, 0) } + +func (d *uinputDev) Close() error { return d.f.Close() } + +func (d *uinputDev) event(code uint16, value int32) error { + ev := inputEvent{Type: evKey, Code: code, Value: value} + if _, err := d.f.Write(unsafeSlice(ev)); err != nil { + return err + } + syn := inputEvent{Type: evSyn, Code: synReport} + _, err := d.f.Write(unsafeSlice(syn)) + return err +} + +func (d *uinputDev) ioctl(req uint, arg int64) error { + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, d.f.Fd(), uintptr(req), uintptr(arg)) + if errno != 0 { + return fmt.Errorf("ioctl 0x%x: %v", req, errno) + } + return nil +} + +func (d *uinputDev) ioctlRaw(req uint) error { + return d.ioctl(req, 0) +} + +// unsafeSlice renders any of the fixed structs above as the bytes the +// kernel expects. The two types have no padding on any Go platform the +// box builds for (arm64/amd64: both are naturally aligned). +func unsafeSlice[T any](v T) []byte { + return unsafe.Slice((*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v)) +} diff --git a/boxd/internal/config/config.go b/boxd/internal/config/config.go index f913b03..fe5dd67 100644 --- a/boxd/internal/config/config.go +++ b/boxd/internal/config/config.go @@ -9,6 +9,7 @@ import ( "io" "os" "regexp" + "strconv" "strings" ) @@ -42,14 +43,16 @@ const ( // Config is the validated result of parsing syncphony.txt. Invalid values // fall back to defaults with a warning; parsing never fails. type Config struct { - ServerURL string - Name string - Hostname string // slug of Name; empty when Name is unset or unusable - Audio string - Resolution string - Rotate string - CEC string - Warnings []string + ServerURL string + Name string + Hostname string // slug of Name; empty when Name is unset or unusable + Audio string + Resolution string + Rotate string + CEC string + CECStandby string // cec_standby: on puts the TV in standby when the room goes quiet + CECStandbyMinutes int // cec_standby_minutes: how long quiet before that standby + Warnings []string // ProfileDir is not a syncphony.txt key: it is always the default (or // the SB_PROFILE hook, applied by Apply) so the Chromium profile path @@ -59,7 +62,11 @@ type Config struct { // Defaults returns the config when nothing is set in the file. func Defaults() Config { - return Config{Audio: "hdmi", Rotate: "0", CEC: "on", ProfileDir: DefaultProfileDir} + return Config{ + Audio: "hdmi", Rotate: "0", CEC: "on", + CECStandby: "off", CECStandbyMinutes: 30, + ProfileDir: DefaultProfileDir, + } } func (c *Config) warn(format string, args ...any) { @@ -162,12 +169,37 @@ func (c *Config) apply(key, value string) { default: c.warn("invalid cec '%s' (want on or off); using %s", value, c.CEC) } + case "cec_standby": + value = strings.ToLower(value) + switch value { + case "": + case "on", "off": + c.CECStandby = value + default: + c.warn("invalid cec_standby '%s' (want on or off); using %s", value, c.CECStandby) + } + case "cec_standby_minutes": + switch { + case value == "": + case !minutesRE.MatchString(value): + c.warn("invalid cec_standby_minutes '%s' (want 1 to 240); using %d", value, c.CECStandbyMinutes) + default: + n, _ := strconv.Atoi(value) + if n < 1 || n > 240 { + c.warn("invalid cec_standby_minutes '%s' (want 1 to 240); using %d", value, c.CECStandbyMinutes) + } else { + c.CECStandbyMinutes = n + } + } default: c.warn("ignoring unknown key '%s'", key) } } -var resolutionRE = regexp.MustCompile(`^[0-9]{3,5}x[0-9]{3,5}(@[0-9]{1,3})?$`) +var ( + resolutionRE = regexp.MustCompile(`^[0-9]{3,5}x[0-9]{3,5}(@[0-9]{1,3})?$`) + minutesRE = regexp.MustCompile(`^[0-9]+$`) +) func stripBOM(b []byte) []byte { if len(b) >= 3 && b[0] == 0xef && b[1] == 0xbb && b[2] == 0xbf { @@ -214,6 +246,8 @@ func (c Config) EnvContent() string { fmt.Fprintf(&b, "RESOLUTION=%s\n", shellQuote(c.Resolution)) fmt.Fprintf(&b, "ROTATE=%s\n", shellQuote(c.Rotate)) fmt.Fprintf(&b, "CEC=%s\n", shellQuote(c.CEC)) + fmt.Fprintf(&b, "CEC_STANDBY=%s\n", shellQuote(c.CECStandby)) + fmt.Fprintf(&b, "CEC_STANDBY_MINUTES=%d\n", c.CECStandbyMinutes) fmt.Fprintf(&b, "PROFILE_DIR=%s\n", shellQuote(c.ProfileDir)) return b.String() } diff --git a/boxd/internal/config/config_test.go b/boxd/internal/config/config_test.go index fb44cef..b5d6b4f 100644 --- a/boxd/internal/config/config_test.go +++ b/boxd/internal/config/config_test.go @@ -25,7 +25,8 @@ func wantWarning(t *testing.T, c Config, substr string) { func TestMissingFileYieldsDefaults(t *testing.T) { c := ParseFile(filepath.Join(t.TempDir(), "nope.txt")) if c.ServerURL != "" || c.Name != "" || c.Hostname != "" || - c.Audio != "hdmi" || c.Resolution != "" || c.Rotate != "0" || c.CEC != "on" { + c.Audio != "hdmi" || c.Resolution != "" || c.Rotate != "0" || c.CEC != "on" || + c.CECStandby != "off" || c.CECStandbyMinutes != 30 { t.Fatalf("got %+v", c) } } @@ -38,9 +39,10 @@ func TestBOMAndCRLF(t *testing.T) { } func TestAllKeysTogether(t *testing.T) { - c := parse(t, "server_url=https://s.example\nname=Den TV\naudio=analog\nresolution=1920x1080@60\nrotate=180\ncec=off\n") + c := parse(t, "server_url=https://s.example\nname=Den TV\naudio=analog\nresolution=1920x1080@60\nrotate=180\ncec=off\ncec_standby=on\ncec_standby_minutes=15\n") if c.ServerURL != "https://s.example" || c.Name != "Den TV" || c.Hostname != "den-tv" || - c.Audio != "analog" || c.Resolution != "1920x1080@60" || c.Rotate != "180" || c.CEC != "off" { + c.Audio != "analog" || c.Resolution != "1920x1080@60" || c.Rotate != "180" || c.CEC != "off" || + c.CECStandby != "on" || c.CECStandbyMinutes != 15 { t.Fatalf("got %+v", c) } } @@ -52,8 +54,9 @@ func TestUnknownKeysAndLinesWarn(t *testing.T) { } func TestInvalidValuesFallBack(t *testing.T) { - c := parse(t, "server_url=ftp://nope.example\naudio=spdif\nrotate=45\ncec=yes\nresolution=1080p\n") - if c.ServerURL != "" || c.Audio != "hdmi" || c.Rotate != "0" || c.CEC != "on" || c.Resolution != "" { + c := parse(t, "server_url=ftp://nope.example\naudio=spdif\nrotate=45\ncec=yes\nresolution=1080p\ncec_standby=maybe\ncec_standby_minutes=0\n") + if c.ServerURL != "" || c.Audio != "hdmi" || c.Rotate != "0" || c.CEC != "on" || c.Resolution != "" || + c.CECStandby != "off" || c.CECStandbyMinutes != 30 { t.Fatalf("got %+v", c) } wantWarning(t, c, "invalid server_url 'ftp://nope.example'") @@ -61,6 +64,23 @@ func TestInvalidValuesFallBack(t *testing.T) { wantWarning(t, c, "invalid rotate '45'") wantWarning(t, c, "invalid cec 'yes'") wantWarning(t, c, "invalid resolution '1080p'") + wantWarning(t, c, "invalid cec_standby 'maybe'") + wantWarning(t, c, "invalid cec_standby_minutes '0'") +} + +func TestCECStandbyMinutesRange(t *testing.T) { + c := parse(t, "cec_standby_minutes=240\n") + if c.CECStandbyMinutes != 240 { + t.Fatalf("240 should be accepted, got %d", c.CECStandbyMinutes) + } + c = parse(t, "cec_standby_minutes=241\n") + if c.CECStandbyMinutes != 30 { + t.Fatalf("241 should fall back, got %d", c.CECStandbyMinutes) + } + c = parse(t, "cec_standby_minutes=ten\n") + if c.CECStandbyMinutes != 30 { + t.Fatalf("non-numeric should fall back, got %d", c.CECStandbyMinutes) + } } func TestDuplicateLastWinsAndCaseInsensitive(t *testing.T) { @@ -94,6 +114,8 @@ func TestEnvContentIsSourceable(t *testing.T) { "AUDIO='hdmi'": true, "ROTATE='0'": true, "CEC='on'": true, + "CEC_STANDBY='off'": true, + "CEC_STANDBY_MINUTES=30": true, } { if !strings.Contains(env, want+"\n") { t.Errorf("env missing %q:\n%s", want, env) diff --git a/boxd/internal/daemon/bridge.go b/boxd/internal/daemon/bridge.go index 68f8178..187621b 100644 --- a/boxd/internal/daemon/bridge.go +++ b/boxd/internal/daemon/bridge.go @@ -110,15 +110,19 @@ func (d *Daemon) guard(next http.HandlerFunc) http.HandlerFunc { func (d *Daemon) handleInfo(w http.ResponseWriter, r *http.Request) { d.mu.Lock() - th := d.therm + th, cecUp := d.therm, d.cecUp d.mu.Unlock() // The ADR 0016 core; the thermal fields (MAD-806) ride along once a // sample exists — stage 4's Screens list reads them ("52 °C", // "running hot / throttled"), and everyone else ignores them. + caps := []string{"reboot", "reload"} + if cecUp { + caps = append(caps, "cec") + } info := map[string]any{ "version": d.version, "name": d.displayName(), - "capabilities": []string{"reboot", "reload"}, // cec arrives in stage 3 + "capabilities": caps, } if th.valid() { // No sample (no vcgencmd here) means no fields: absent reads @@ -164,6 +168,12 @@ func (d *Daemon) handleEvents(w http.ResponseWriter, r *http.Request) { case "playing": if previous != "playing" { // once per playback start, not per repeat d.flushDisk(ev.Type) + // And the TV joins in: wake it and switch it to the box + // (MAD-807). Once per playback start; cec-ctl's own rate + // limit is the second lock. + if d.wakeTV != nil { + go d.wakeTV() + } } } d.log.Printf("boxd: event type=%s room=%s", ev.Type, room) diff --git a/boxd/internal/daemon/daemon.go b/boxd/internal/daemon/daemon.go index 7252332..ba3aa69 100644 --- a/boxd/internal/daemon/daemon.go +++ b/boxd/internal/daemon/daemon.go @@ -17,13 +17,15 @@ import ( "syscall" "time" + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/cec" "github.com/madeofpendletonwool/syncphony-box/boxd/internal/config" ) const ( bridgeAddr = "127.0.0.1:8099" // ADR 0016: loopback only lanAddr = ":80" // the setup form, open only while unconfigured - setupWindow = 10 * time.Minute // after SIGUSR1 (stage 3 wires the OK long-press) + setupWindow = 10 * time.Minute // after SIGUSR1 or a long OK press on the remote + infoWindow = 30 * time.Second // how long the info screen holds the TV after that press healthzCache = 2 * time.Second // Pairing durability (MAD-804, ADR 0006): Chromium writes its cookie @@ -84,8 +86,12 @@ type Daemon struct { // feed readings without a Pi. vcgencmd func(args ...string) (string, error) - startedAt time.Time // boxd start; watchdog graces count from here - setupUntil time.Time // LAN setup window open until then (zero = closed) + startedAt time.Time // boxd start; watchdog graces count from here + setupUntil time.Time // LAN setup window open until then (zero = closed) + infoUntil time.Time // the info screen holds /start until then (zero = off) + cec *cec.Controller + cecUp bool // the CEC adapters registered; drives /v1/info's capabilities + wakeTV func() // the CEC wake-and-switch; a func field so tests can count calls lastBeat time.Time // last /v1/heartbeat from the page lastEvent string // last /v1/events type (the nightly reload waits out "playing") lastEventAt time.Time // when it arrived @@ -115,9 +121,23 @@ func New(version string, log *log.Logger, run config.RunCommand) *Daemon { }, } d.reloadConfig() + d.cec = cec.New(d.cecConfig(d.cfg), log, d.onRemoteLongPress) + d.wakeTV = d.cec.WakeAndSwitch return d } +// cecConfig is the CEC slice of syncphony.txt for the controller, built +// once at startup (cec= and its neighbors are boot settings; a change +// lands on the next boot, like resolution=). +func (d *Daemon) cecConfig(cfg config.Config) cec.Config { + return cec.Config{ + Enabled: cfg.CEC == "on", + SecondHDMI: cfg.Audio == "hdmi2", + Standby: cfg.CECStandby == "on", + StandbyAfter: time.Duration(cfg.CECStandbyMinutes) * time.Minute, + } +} + // reloadConfig re-reads syncphony.txt and updates everything derived from // it. Called at start and after the setup form saves. func (d *Daemon) reloadConfig() { @@ -199,6 +219,14 @@ func (d *Daemon) Run() error { d.log.Printf("boxd: %s bridge on http://%s (server: %s)", d.version, bridgeAddr, d.serverOrNone()) go d.watch() go d.sampleThermalsLoop() + go func() { + // CEC (MAD-807): register as a playback device on the adapters + // and watch the bus. cec=off or no /dev/cec leaves this inert. + n := d.cec.Start() + d.mu.Lock() + d.cecUp = n > 0 + d.mu.Unlock() + }() sig := make(chan os.Signal, 4) signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT, syscall.SIGUSR1, syscall.SIGUSR2) @@ -250,6 +278,29 @@ func (d *Daemon) closeSetupWindow() { d.mu.Unlock() } +// onRemoteLongPress is the box's one remote gesture (MAD-807): OK held +// for 3 seconds on the TV remote. It opens the setup window — the phone +// form at http://.local — and puts the box's IP and version on +// the TV by restarting the kiosk onto the info screen; the page goes back +// to the room by itself (and the speaker resumes, MAD-804). +func (d *Daemon) onRemoteLongPress() { + now := time.Now() + d.mu.Lock() + d.setupUntil = now.Add(setupWindow) + d.infoUntil = now.Add(infoWindow) + d.mu.Unlock() + d.log.Printf("boxd: cec: OK long-press: setup window open for %s; showing box info", setupWindow) + d.restartKiosk("box info (OK long press)", false) +} + +// infoWindowOpen reports whether /start should show the box's info +// screen instead of connecting (the long-press set it). +func (d *Daemon) infoWindowOpen() bool { + d.mu.Lock() + defer d.mu.Unlock() + return time.Now().Before(d.infoUntil) +} + func (d *Daemon) windowState() string { if d.setupWindowOpen() { return "open — box unconfigured" diff --git a/boxd/internal/daemon/daemon_test.go b/boxd/internal/daemon/daemon_test.go index 70d5597..5d9671d 100644 --- a/boxd/internal/daemon/daemon_test.go +++ b/boxd/internal/daemon/daemon_test.go @@ -98,6 +98,33 @@ func TestInfo(t *testing.T) { } } +func TestInfoCapabilityCECOnlyWhenUp(t *testing.T) { + hasCEC := func(t *testing.T, d *Daemon) bool { + t.Helper() + var info struct { + Capabilities []string `json:"capabilities"` + } + rec := get(t, d.bridgeMux(), "/v1/info") + json.Unmarshal(rec.Body.Bytes(), &info) + for _, c := range info.Capabilities { + if c == "cec" { + return true + } + } + return false + } + d, _ := newTestDaemon(t, "server_url=https://s.example\n") + if hasCEC(t, d) { + t.Fatal("cec must not be claimed before the adapters registered") + } + d.mu.Lock() + d.cecUp = true + d.mu.Unlock() + if !hasCEC(t, d) { + t.Fatal("cec should be a capability once the adapters are up") + } +} + func TestEventsAcceptedAndRejected(t *testing.T) { d, _ := newTestDaemon(t, "server_url=https://s.example\n") mux := d.bridgeMux() @@ -183,6 +210,56 @@ func TestPlayingFlushesOncePerStartAndRateLimited(t *testing.T) { } } +// countingWakes swaps the daemon's CEC wake for a counter (MAD-807): the +// TV wake must fire once per playback start — not per repeat of the +// playing event — and never for other events. +func countingWakes(d *Daemon) func() int { + var mu sync.Mutex + n := 0 + d.mu.Lock() + d.wakeTV = func() { + mu.Lock() + n++ + mu.Unlock() + } + d.mu.Unlock() + return func() int { + mu.Lock() + defer mu.Unlock() + return n + } +} + +func TestPlayingWakesTheTVOncePerStart(t *testing.T) { + d, _ := newTestDaemon(t, "server_url=https://s.example\n") + wakes := countingWakes(d) + mux := d.bridgeMux() + + post(t, mux, "/v1/events", `{"type":"playing","roomId":"r1"}`) + waitFor(t, func() bool { return wakes() == 1 }) + + // A repeat of playing is not a new start. + time.Sleep(20 * time.Millisecond) + post(t, mux, "/v1/events", `{"type":"playing","roomId":"r1"}`) + time.Sleep(50 * time.Millisecond) + if got := wakes(); got != 1 { + t.Fatalf("repeat playing woke %d times", got) + } + + // Pause, then play again: a new start. + post(t, mux, "/v1/events", `{"type":"paused"}`) + post(t, mux, "/v1/events", `{"type":"playing","roomId":"r1"}`) + waitFor(t, func() bool { return wakes() == 2 }) + + // Other events never wake. + post(t, mux, "/v1/events", `{"type":"idle"}`) + post(t, mux, "/v1/events", `{"type":"paired"}`) + time.Sleep(50 * time.Millisecond) + if got := wakes(); got != 2 { + t.Fatalf("wakes: %d", got) + } +} + // --- bridge: origin, host, CORS/PNA ----------------------------------------- func TestOriginAllowedOnlyForConfiguredServer(t *testing.T) { @@ -274,6 +351,39 @@ func TestRebootAndReloadRunSystemctl(t *testing.T) { // --- screens ---------------------------------------------------------------- +// TestRemoteLongPressOpensSetupAndShowsInfo: the box's one remote gesture +// (MAD-807). A 3-second OK hold opens the LAN setup window and puts the +// box's IP and version on the TV via the info screen on /start. +func TestRemoteLongPressOpensSetupAndShowsInfo(t *testing.T) { + d, rec := newTestDaemon(t, "server_url=https://s.example\nname=Den TV\n") + d.onRemoteLongPress() + + if !d.setupWindowOpen() { + t.Fatal("setup window not open after the long press") + } + waitFor(t, func() bool { + return strings.Contains(strings.Join(rec.calls(), ";"), "systemctl restart syncphony-kiosk.service") + }) + + body := get(t, d.bridgeMux(), "/start").Body.String() + for _, want := range []string{"Den TV", "test-version"} { + if !strings.Contains(body, want) { + t.Errorf("info screen should show %q:\n%s", want, body) + } + } +} + +func TestInfoScreenYieldsBackToStart(t *testing.T) { + d, _ := newTestDaemon(t, "server_url=https://s.example\n") + d.mu.Lock() + d.infoUntil = time.Now().Add(-time.Second) + d.mu.Unlock() + body := get(t, d.bridgeMux(), "/start").Body.String() + if !strings.Contains(body, "Connecting to") { + t.Fatalf("the ordinary start screen should be back: %s", body[:min(200, len(body))]) + } +} + func TestStartScreenRedirectsToSetupWhenUnconfigured(t *testing.T) { d, _ := newTestDaemon(t, "") rec := get(t, d.bridgeMux(), "/start") diff --git a/boxd/internal/daemon/pages.go b/boxd/internal/daemon/pages.go index 1e64c2a..9cd1bb6 100644 --- a/boxd/internal/daemon/pages.go +++ b/boxd/internal/daemon/pages.go @@ -82,6 +82,81 @@ type startScreenData struct { Network string } +type infoScreenData struct { + Name string + Version string + Server string + Target string + IPs []string + Network string +} + +// infoScreenHTML is what a 3-second OK hold on the TV remote shows +// (MAD-807): the box's name, version, IPs and server, big enough to read +// from the couch, while the setup window is open for a phone. It holds +// the TV for a few seconds, then navigates to the room the same way the +// start screen does. +func infoScreenHTML(d infoScreenData) string { + targetJS, err := json.Marshal(d.Target) + if err != nil { + targetJS = []byte(`""`) + } + b := &strings.Builder{} + t := template.Must(template.New("info").Parse(` + + + + +Syncphony Box — info + + + +
+

{{.Name}}

+

Syncphony Box {{.Version}}

+ {{if .IPs}}

On this network: {{range .IPs}}{{.}} {{end}}

{{end}} +

Showing {{.Server}} · network: {{.Network}}

+

Setup is open for ten minutes: set this box from a phone on + {{.SetupURL}}

+

Back to the room in 8

+
+ + +`)) + t.Execute(b, struct { + Name string + Version string + Server string + SetupURL string + TargetJS template.JS + IPs []string + Network string + }{d.Name, d.Version, d.Server, "http://" + hostname() + ".local", template.JS(targetJS), d.IPs, d.Network}) + return b.String() +} + type storageScreenData struct { Name string IPs []string diff --git a/boxd/internal/daemon/screens.go b/boxd/internal/daemon/screens.go index 3e95d5e..fdd0336 100644 --- a/boxd/internal/daemon/screens.go +++ b/boxd/internal/daemon/screens.go @@ -74,7 +74,9 @@ func (d *Daemon) handleSetupScreen(w http.ResponseWriter, r *http.Request) { // handleStartScreen serves the page the kiosk opens when a server is // configured: it checks the server before navigating, so the box never // shows Chromium's error page — it shows the offline screen and returns to -// /tv on its own once /healthz answers. +// /tv on its own once /healthz answers. After a long OK press on the +// remote it shows the box's info screen first (MAD-807), then hands the +// TV back the same way. func (d *Daemon) handleStartScreen(w http.ResponseWriter, r *http.Request) { if !storageHealthy() { d.handleStorageScreen(w, r) @@ -84,6 +86,17 @@ func (d *Daemon) handleStartScreen(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/setup", http.StatusFound) return } + if d.infoWindowOpen() { + writePage(w, infoScreenHTML(infoScreenData{ + Name: d.displayName(), + Version: d.version, + Server: d.serverURL(), + Target: d.targetURL(), + IPs: boxIPs(), + Network: networkState(), + })) + return + } st := startScreenData{ Server: d.serverURL(), Target: d.targetURL(), diff --git a/boxd/internal/daemon/watchdog.go b/boxd/internal/daemon/watchdog.go index 5f4a3f1..5ce041f 100644 --- a/boxd/internal/daemon/watchdog.go +++ b/boxd/internal/daemon/watchdog.go @@ -153,6 +153,17 @@ func (d *Daemon) watch() { // fault. (Thermals run in their own loop regardless.) continue } + // CEC housekeeping (MAD-807): the optional good-night — a TV the + // box switched on goes to standby once the room has been idle + // for the configured minutes. Off unless cec_standby=on. + d.mu.Lock() + ev, evAt := d.lastEvent, d.lastEventAt + d.mu.Unlock() + if ev == "idle" && !evAt.IsZero() { + d.cec.Housekeeping(time.Since(evAt)) + } else { + d.cec.Housekeeping(0) + } ok := d.serverReachable() now := time.Now() d.mu.Lock() diff --git a/docs/adr/0009-hdmi-cec.md b/docs/adr/0009-hdmi-cec.md new file mode 100644 index 0000000..9bd0fa9 --- /dev/null +++ b/docs/adr/0009-hdmi-cec.md @@ -0,0 +1,88 @@ +# ADR 0009: HDMI-CEC — wake the TV, remote keys + +- Status: Accepted (hardware verification pending — see docs/hardware.md) +- Date: 2026-10-15 +- Deciders: Phase 11 plan (MAD-797), CEC issue (MAD-807) + +## Context + +The box hangs from the TV's HDMI port, and HDMI carries a wire the box +can use: CEC. Two things come with it. One-way: when music starts, the +box can bring the TV out of standby and switch it to its own input, so +queueing a song from the couch turns the room on. Two-way: the TV +remote's keys can drive `/tv`, so nobody needs a keyboard to skip a song +or take over the speaker from the couch. + +CEC is famously uneven — Samsung's Anynet+, LG's SimpLink and Sony's +Bravia Sync all implement different subsets. The decisions here aim for +the safe common core and degrade quietly where a TV doesn't play along. + +## Decision + +### The kernel CEC framework through cec-ctl, not libcec + +The Pi's `vc4` driver already exposes the CEC adapter as `/dev/cec0` +(`/dev/cec1` for the second HDMI port), and `cec-ctl` (v4l-utils, in +stage-syncphony/08-cec) drives the whole kernel API from a command line. +libcec would add a C++ library dependency for the same bus. boxd claims +the playback logical address (`cec-ctl -d /dev/cec0 --playback`) on both +adapters when it starts — audio=hdmi2 means the TV is on cec1 — and +runs `cec-ctl --monitor` per adapter, parsing the stable textual format +(`USER_CONTROL_PRESSED (0x44):` + `\tui-cmd: select (0x00)`) into events. +No CEC at all (a dev machine) logs once and runs inert. + +### Wake and switch, twice rate-limited + +On a `playing` bridge event (ADR 0016) — once per playback start, not +per repeat — boxd sends `` and `` (with a +freshly re-read physical address, because hotplugs change it) to the +adapter the TV is on. A second lock inside the controller bounds it to +once every 5 minutes no matter how many playback starts happen, so the +box never fights whoever switched the TV to the game console. +`cec=off` in syncphony.txt disables all of it. + +### Remote keys through uinput + +CEC `` becomes Linux key events through a uinput +keyboard boxd creates (`/dev/uinput`; the udev rule in +stage-syncphony/08-cec grants the `cec` group — access comes from the +group, not from boxd's root). cage hands the device to libinput and +Chromium sees an ordinary keyboard. The map is the keys `/tv` knows: +arrows → arrow keys, OK → Enter (the "Play here" button is +auto-focused, so Enter clicks it), Back → Escape, Play/Pause → +MediaPlayPause. A release that never arrives (some TVs lose them) is +synthesized after a second, so nothing sticks down. + +**OK is hold-to-gesture.** The Enter keydown is held back until the +release proves a short press; holding OK for 3 seconds instead opens the +setup window (the phone form at `http://.local`, the same +window as SIGUSR1) and puts the box's name, version and IPs on the TV by +restarting the kiosk onto an info screen that walks back to the room by +itself (the speaker resumes, MAD-804). Without the hold-back, the 3 +second press would click whatever is focused before the gesture fired. + +### Optional standby, only for a TV the box switched on + +`cec_standby=on` (off by default) plus `cec_standby_minutes` (default +30): after the room has been idle (`idle` bridge events) that long, +send `` — but only while the TV is on the box *because the box +switched it on*. The monitor watches for a foreign `` +announcing a different physical address and drops the claim the moment +someone else takes the TV; a TV the box never seized (the user picked +the input themselves) is never stood down, and neither is one the +game console now owns. + +## Consequences + +- `/v1/info` gains the `cec` capability once the adapters registered; + stage 4's Screens list can show it. +- `cec`, `cec_standby`, `cec_standby_minutes` join syncphony.txt; like + `resolution=`, they apply at boot. +- The one web-app gap this surfaced is fixed on the Syncphony side: + `/tv`'s `tvKeyOf` now treats `MediaPlayPause` as play/pause (it + previously knew only space and K), so the remote's play/pause key + works out of the box. +- What CI can't cover: real TVs. The brand matrix (Samsung Anynet+, LG + SimpLink, Sony Bravia Sync) lives in docs/hardware.md, and the + on-hardware gauntlet in docs/testing.md — both marked pending until + the bench runs. diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 0000000..198a13f --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,50 @@ +# Hardware: boards, TVs and CEC + +What the project has verified on real hardware, and what to check when +you add a piece of your own. CEC is the uneven one: every brand ships +its own subset (and its own menu name for it), so the table below is the +record the CEC issue (MAD-807, ADR 0009) asked for. + +## Boards + +| board | status | notes | +|---|---|---| +| Pi 5 | pending bench | HDMI0 is the port next to the USB-C power input; that port is `cec0` | +| Pi 4 (2 GB+) | pending bench | HDMI0 is next to the power input too; the 3.5 mm jack makes `audio=analog` possible | + +Both ports: plugging into HDMI1 instead of HDMI0 means `audio=hdmi2` in +`syncphony.txt` (the box then claims `/dev/cec1`). Power: use the +official power supply — under-voltage shows up as audio dropouts long +before anything else (the watchdog's thermals log flags it; see ADR +0008). + +## TV CEC compatibility + +The done-when for MAD-807: with the TV in standby, queueing a song from +a phone turns the TV on, switches it to the box and plays; the remote's +OK takes over as speaker; play/pause works. Rows fill in as benches +run; **the table starts pending** — no brand has been verified yet. + +| TV brand (CEC name) | wake + switch | remote keys | OK long-press | standby | notes | +|---|---|---|---|---|---| +| Samsung (Anynet+) | pending | pending | pending | pending | Anynet+ often needs "Auto Turn On"/"Auto Power Off" enabled in the TV's Device Connector settings | +| LG (SimpLink) | pending | pending | pending | pending | SimpLink must be on; some LG remotes send OK as 0x00 only after a first pairing | +| Sony (Bravia Sync) | pending | pending | pending | pending | usually the most complete subset; control mode must be set to "Normal" not "Off" | +| (yours here) | | | | | file a hardware report (issue template) with the TV model | + +### Checking a TV in five minutes + +```sh +sudo apt install v4l-utils # if it isn't already +cec-ctl -d /dev/cec0 # note the "Physical Address" line (e.g. 1.0.0.0) +cec-ctl -d /dev/cec0 --playback -S # claim playback, show the topology +# TV off / on another input — use the physical address just shown: +cec-ctl -d /dev/cec0 --to 0 --image-view-on +cec-ctl -d /dev/cec0 --to 0 --active-source phys-addr=1.0.0.0 +cec-ctl -d /dev/cec0 --monitor # then press keys on the TV remote +``` + +If `--monitor` shows `USER_CONTROL_PRESSED` lines when you press arrow +keys and OK, the box will drive `/tv` on this TV. If nothing appears, +the TV is probably sending its keys only to its own logical address — +check the TV's CEC menu first, then record it here either way. diff --git a/docs/testing.md b/docs/testing.md index ba389ac..881484e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -163,3 +163,51 @@ journalctl --list-boots | wc -l # one per pull boot, reboot, SSH in with it — works (the identity restore/save round trip). SSH host keys identical across two reboots: `ssh-keygen -lf /data/system/ssh/ssh_host_ed25519_key.pub`. + +## The CEC gauntlet (MAD-807) + +**Done-when:** with the TV in standby, queueing a song from a phone +turns the TV on, switches it to the box and plays. The TV remote's OK +takes over as speaker when someone else is playing, and play/pause +works. Run on at least two TV brands (Samsung "Anynet+", LG "SimpLink", +Sony "Bravia Sync" — enable CEC in the TV's settings first) and record +the results in [docs/hardware.md](hardware.md). + +Watch the daemon's view of the bus while you do: + +```sh +journalctl -u boxd -f +``` + +1. **Wake and switch.** TV in standby (or on the console's input), music + off. Queue a song from a phone → within a few seconds the TV comes on + showing the box and the music plays through it. `boxd` logs + `woke the TV and switched it to this box`. +2. **No fight.** Switch the TV to the game console while music plays. + Pause, then play again from the phone (a new playback start) → the + box must *not* yank the TV back within the 5-minute rate limit. + `cec-ctl -d /dev/cec0 --monitor` should show the console's + `ACTIVE_SOURCE`, and boxd logs `became the active source; leaving the + TV alone`. +3. **Remote keys.** Arrows step the visuals, right skips (or votes), + play/pause toggles, Back leaves to the stage view, and OK clicks + whatever is focused — with someone else's phone the speaker, the + "Play here instead" button is focused, so one OK takes the room. +4. **OK long-press.** Hold OK for 3 seconds → the setup window opens + (the phone form answers at `http://.local` for ten + minutes) and the TV shows the box's name, version and IPs, then + walks back to the room by itself and the speaker resumes. A short OK + press must still click — not open the info screen. +5. **Optional standby.** `cec_standby=on` (and a short + `cec_standby_minutes` for the test) in `syncphony.txt`, reboot. Play + music (the box seizes the TV), stop it, wait out the minutes → the TV + goes to standby. Then switch the TV to the console by hand, play and + stop again → the box must leave the TV on. +6. **`cec=off`.** Set it, reboot, put the TV in standby, queue a song → + the music plays (through the TV if it was already on the box) but + nothing is switched or woken; the remote's keys do nothing. + +Remote keys are per-TV uneven: if arrows arrive but OK doesn't (or OK +arrives as something odd), capture `cec-ctl -d /dev/cec0 --monitor` +output while pressing keys and add the TV to the table in +[docs/hardware.md](hardware.md). diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c731701..80f6e42 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -19,7 +19,9 @@ # pointing at /data, and the stock root-resize replaced by the data grow; # and the self-healing pieces are wired: the systemd hardware watchdog # drop-in, timesyncd enabled and ordered before the kiosk, and vcgencmd -# for boxd's thermals. +# for boxd's thermals. The HDMI-CEC pieces are in place too: cec-ctl +# installed, the cec group with its udev rule for /dev/cec* and +# /dev/uinput, boxd granted the group, and uinput loaded at boot. set -euo pipefail @@ -97,7 +99,7 @@ fi # --- packages --------------------------------------------------------------- for pkg in cage chromium fonts-noto-color-emoji libgl1-mesa-dri cloud-init avahi-daemon \ pipewire pipewire-bin pipewire-alsa pipewire-pulse wireplumber pulseaudio-utils alsa-utils \ - libraspberrypi-bin systemd-timesyncd; do + libraspberrypi-bin systemd-timesyncd v4l-utils; do if sudo awk -v p="${pkg}" ' $0 == "Package: " p { inpkg = 1 } inpkg && /^Status: / { @@ -277,6 +279,41 @@ else check "vcgencmd installed (thermals)" "missing or not executable" fi +# --- HDMI-CEC (MAD-807) -------------------------------------------------------- +if sudo test -x "${WORK}/root/usr/bin/cec-ctl"; then + check "cec-ctl installed (CEC)" ok +else + check "cec-ctl installed (CEC)" "missing or not executable" +fi + +if sudo grep -q '^cec:' "${WORK}/root/etc/group"; then + check "cec group exists" ok +else + check "cec group exists" "missing" +fi + +if sudo grep -q 'KERNEL=="uinput", GROUP="cec"' \ + "${WORK}/root/etc/udev/rules.d/60-syncphony-cec.rules" && + sudo grep -q 'KERNEL=="cec\[0-9\]\*", GROUP="cec"' \ + "${WORK}/root/etc/udev/rules.d/60-syncphony-cec.rules"; then + check "udev rule grants cec and uinput to the group" ok +else + check "udev rule grants cec and uinput to the group" "rule missing or wrong" +fi + +if sudo grep -q '^SupplementaryGroups=cec' \ + "${WORK}/root/etc/systemd/system/boxd.service.d/50-cec.conf"; then + check "boxd holds the cec group" ok +else + check "boxd holds the cec group" "drop-in missing or wrong" +fi + +if sudo grep -q '^uinput$' "${WORK}/root/etc/modules-load.d/syncphony-cec.conf"; then + check "uinput loaded at boot" ok +else + check "uinput loaded at boot" "modules-load entry missing" +fi + # The boot config apply is boxd now: same unit, new engine. if sudo grep -q '^ExecStart=/usr/lib/syncphony-box/boxd config apply$' \ "${WORK}/root/etc/systemd/system/syncphony-box-config.service"; then @@ -311,7 +348,7 @@ else check "syncphony.txt ships fully commented out" "active lines: ${active}" fi -for key in server_url name audio resolution rotate cec; do +for key in server_url name audio resolution rotate cec cec_standby cec_standby_minutes; do if sudo grep -q "^#${key}=" "${WORK}/boot/syncphony.txt"; then check "syncphony.txt documents ${key}" ok else diff --git a/stage-syncphony/03-config/files/syncphony.txt b/stage-syncphony/03-config/files/syncphony.txt index 982fff6..c3b717f 100644 --- a/stage-syncphony/03-config/files/syncphony.txt +++ b/stage-syncphony/03-config/files/syncphony.txt @@ -18,3 +18,7 @@ #rotate=0 # Switch the TV to this input when music starts (HDMI-CEC): on/off. #cec=on +# Put the TV in standby after the room has been idle (HDMI-CEC): on/off. +#cec_standby=off +# How many idle minutes before that standby: 1 to 240. +#cec_standby_minutes=30 diff --git a/stage-syncphony/08-cec/00-packages b/stage-syncphony/08-cec/00-packages new file mode 100644 index 0000000..8228602 --- /dev/null +++ b/stage-syncphony/08-cec/00-packages @@ -0,0 +1,4 @@ +# HDMI-CEC (MAD-807, ADR 0009): cec-ctl (v4l-utils) is how boxd talks to +# the kernel CEC framework — registering the playback logical address, +# watching the bus, and waking/switching/standing-by the TV. +v4l-utils diff --git a/stage-syncphony/08-cec/00-run.sh b/stage-syncphony/08-cec/00-run.sh new file mode 100644 index 0000000..95f28b8 --- /dev/null +++ b/stage-syncphony/08-cec/00-run.sh @@ -0,0 +1,33 @@ +#!/bin/bash -e + +# HDMI-CEC (MAD-807, ADR 0009). The pieces at the image level: +# +# - v4l-utils (00-packages): cec-ctl, boxd's whole CEC interface to the +# kernel framework. +# - The cec group and its udev rule: /dev/cec* and /dev/uinput grant +# access by group, not by root — boxd gets them through +# SupplementaryGroups (the drop-in below). +# - uinput in modules-load.d, so the injected keyboard's device node +# exists before boxd starts. +# +# boxd itself (registering as a playback device, the monitor, the +# wake/switch rate limit, the remote keys, the OK long press) lives in +# boxd/internal/cec. + +FILES="$(dirname "$0")/files" + +on_chroot << EOF +getent group cec >/dev/null || groupadd --system cec +EOF + +install -v -d -m 0755 "${ROOTFS_DIR}/etc/udev/rules.d" +install -v -m 0644 "${FILES}/60-syncphony-cec.rules" \ + "${ROOTFS_DIR}/etc/udev/rules.d/60-syncphony-cec.rules" + +install -v -d -m 0755 "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d" +install -v -m 0644 "${FILES}/50-cec.conf" \ + "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d/50-cec.conf" + +install -v -d -m 0755 "${ROOTFS_DIR}/etc/modules-load.d" +install -v -m 0644 "${FILES}/uinput.conf" \ + "${ROOTFS_DIR}/etc/modules-load.d/syncphony-cec.conf" diff --git a/stage-syncphony/08-cec/files/50-cec.conf b/stage-syncphony/08-cec/files/50-cec.conf new file mode 100644 index 0000000..0da1e77 --- /dev/null +++ b/stage-syncphony/08-cec/files/50-cec.conf @@ -0,0 +1,5 @@ +# HDMI-CEC (MAD-807): boxd drives /dev/cec* and /dev/uinput through the +# cec group the udev rule grants, whatever else the service needs root +# for (systemctl, the boot-partition remount). +[Service] +SupplementaryGroups=cec diff --git a/stage-syncphony/08-cec/files/60-syncphony-cec.rules b/stage-syncphony/08-cec/files/60-syncphony-cec.rules new file mode 100644 index 0000000..110f0b0 --- /dev/null +++ b/stage-syncphony/08-cec/files/60-syncphony-cec.rules @@ -0,0 +1,5 @@ +# HDMI-CEC and the remote's keys (MAD-807): the CEC adapters and the +# uinput keyboard boxd injects through belong to this group — access comes +# from this rule, not from boxd's root. +KERNEL=="cec[0-9]*", GROUP="cec", MODE="0660" +KERNEL=="uinput", GROUP="cec", MODE="0660" diff --git a/stage-syncphony/08-cec/files/uinput.conf b/stage-syncphony/08-cec/files/uinput.conf new file mode 100644 index 0000000..e5c5593 --- /dev/null +++ b/stage-syncphony/08-cec/files/uinput.conf @@ -0,0 +1,4 @@ +# The uinput module (boxd's injected keyboard for the TV remote's keys, +# MAD-807). Loaded at boot so the device node exists before boxd starts; +# harmless where the kernel built it in. +uinput diff --git a/tests/syncphony-box-config.bats b/tests/syncphony-box-config.bats index ed0839c..0d2dee9 100644 --- a/tests/syncphony-box-config.bats +++ b/tests/syncphony-box-config.bats @@ -51,6 +51,8 @@ cmdline_is() { echo "$output" | grep -Fxq "RESOLUTION=''" echo "$output" | grep -Fxq "ROTATE='0'" echo "$output" | grep -Fxq "CEC='on'" + echo "$output" | grep -Fxq "CEC_STANDBY='off'" + echo "$output" | grep -Fxq "CEC_STANDBY_MINUTES=30" } @test "the shipped template is fully commented out (all defaults)" { @@ -61,6 +63,7 @@ cmdline_is() { echo "$output" | grep -Fxq "AUDIO='hdmi'" echo "$output" | grep -Fxq "ROTATE='0'" echo "$output" | grep -Fxq "CEC='on'" + echo "$output" | grep -Fxq "CEC_STANDBY='off'" } @test "accepts a UTF-8 BOM and CRLF line endings (Notepad)" { @@ -73,8 +76,8 @@ cmdline_is() { echo "$output" | grep -Fxq "AUDIO='usb'" } -@test "all six keys parse together" { - txt 'server_url=https://s.example\nname=Den TV\naudio=analog\nresolution=1920x1080@60\nrotate=180\ncec=off\n' +@test "all keys parse together" { + txt 'server_url=https://s.example\nname=Den TV\naudio=analog\nresolution=1920x1080@60\nrotate=180\ncec=off\ncec_standby=on\ncec_standby_minutes=15\n' run "${SCRIPT}" parse "${TXT}" [ "$status" -eq 0 ] echo "$output" | grep -Fxq "SERVER_URL='https://s.example'" @@ -84,6 +87,8 @@ cmdline_is() { echo "$output" | grep -Fxq "RESOLUTION='1920x1080@60'" echo "$output" | grep -Fxq "ROTATE='180'" echo "$output" | grep -Fxq "CEC='off'" + echo "$output" | grep -Fxq "CEC_STANDBY='on'" + echo "$output" | grep -Fxq "CEC_STANDBY_MINUTES=15" } @test "unknown keys warn on stderr and are ignored" { @@ -104,17 +109,21 @@ cmdline_is() { } @test "invalid audio, rotate, cec and resolution fall back to defaults" { - txt 'audio=spdif\nrotate=45\ncec=yes\nresolution=1080p\n' + txt 'audio=spdif\nrotate=45\ncec=yes\nresolution=1080p\ncec_standby=maybe\ncec_standby_minutes=0\n' run "${SCRIPT}" parse "${TXT}" [ "$status" -eq 0 ] echo "$output" | grep -Fxq "AUDIO='hdmi'" echo "$output" | grep -Fxq "ROTATE='0'" echo "$output" | grep -Fxq "CEC='on'" echo "$output" | grep -Fxq "RESOLUTION=''" + echo "$output" | grep -Fxq "CEC_STANDBY='off'" + echo "$output" | grep -Fxq "CEC_STANDBY_MINUTES=30" echo "$output" | grep -q "invalid audio 'spdif'" echo "$output" | grep -q "invalid rotate '45'" echo "$output" | grep -q "invalid cec 'yes'" echo "$output" | grep -q "invalid resolution '1080p'" + echo "$output" | grep -q "invalid cec_standby 'maybe'" + echo "$output" | grep -q "invalid cec_standby_minutes '0'" } @test "resolution accepts WxH and WxH@refresh" {