1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/server/wire.ts
T
karti f81d5218d4 feat: SFO, LAX, both bridges, a road that reads as a road, and aeroplanes that move
**The aeroplanes were stuck because the wire could not describe motion.**
`WireAircraft` carried position, altitude and heading and nothing else, so the
client could only interpolate between the last two observations: every aircraft
replayed a segment it had already flown, arrived at the newest known point, and
sat still until the next poll landed five to fifteen seconds later. The feed had
the missing numbers the whole time and the server threw them away. Sampled live
from `api.adsb.lol/v2/point` while writing this — `gs` ground speed, `track`,
`baro_rate`, plus `r` registration and `t` type designator. They are on the wire
now in SI, aircraft dead-reckon along their own track and correct toward the
truth when a fix lands, and the click card an anonymous visitor gets says
"B739 · N68834". That last part is the enrichment FR24 was wanted for, obtained
from an ODbL feed we may actually republish.

**SFO and LAX exist.** A new `engine/airports.ts` composes an airport from
runways, taxiways, aprons and terminal masses, with markings drawn on a canvas
rather than modelled; the pattern of the runways is what the eye recognises from
altitude, long before any building does. SFO is the two crossing pairs on the bay
fill; LAX is the four parallels either side of the terminal horseshoe, plus the
Southland fields under the traffic that actually flies there.

**The Golden Gate and the Bay Bridge are those bridges.** One kit in
`engine/bridges.ts`, because a suspension bridge is a repeated tower, a catenary
main cable, a series of hangers and a deck — so both are configurations rather
than two private implementations. The Bay Bridge carries the real 2013 topology:
two suspension towers west of Yerba Buena, one east, then the piered causeway.
The freeway stopped being a wireframe overlay and became a road, with shoulders,
a median, and lane markings as texture.

**And the board got faster while all of that landed.** California went from
728,744 triangles and 562 draw calls to 391,169 and 371 — headroom from 2.8% to
47.8%. The Bay Area board is 506,550 triangles lighter than before this work.
Two things paid for it:

- `transmission: 0.08` on the aircraft cockpit glass. three.js runs a full
  transmission backdrop pass whenever any rendered material has transmission
  above zero, re-drawing the entire opaque scene into a second target every
  frame — so the city was rendering terrain, every block and every freeway piece
  TWICE. Measured by patching only that number in a copy of the built bundle:
  703,267 tris / 562 draws with it, 398,608 / 371 without. The material was
  already `transparent: true, opacity: 0.86`, so it was buying nothing.
- Flatness-adaptive terrain LOD, which collapses runs of lattice cells wherever
  the height and colour agree with the quad replacing them. The coastline is
  provably untouched — a patch collapses only when every point is on land and
  agrees about `park` — and a test asserts the drawn footprint matches the
  cell-by-cell area to 1e-6. `createTerrain` got *faster*: the vertices it stops
  emitting cost more than the flatness scan costs to run.

**The budget now watches the boards this was built on.** There was no `bay-area`
or `socal` cell — so SFO, LAX and both bridges all landed in frames nothing
measured, which is how a cap you do not have looks from the inside. Both are in
the matrix now with caps set from measurement, and the rationale lives in the
harness because JSON cannot hold a comment.

Two known defects ship with this, both recorded in TODO.md rather than hidden:

- `bay-area.desktop` drops about one frame in twenty (p50 16.7, p95 33.3). It is
  desktop-only and not fill rate — mobile runs the same 2.26 M triangles at a
  comparable pixel count and holds 16.7 flat — which points at the 2048 shadow
  map desktop uses against handheld's 1024. Measured at the commit before this
  work with the same harness: identical p95 33.3. Pre-existing, and invisible
  until the cell existed.
- The aeroplane glyph is still about 1.5x the Golden Gate's main span at chapter
  zoom, down from 2.5x. `GLYPH_MAX_SCALE` is 52 because the raw scale at the far
  end of the California orbit is 51.0 at a 60-degree field of view, and 26 —
  tried first — put the glyph at 0.0123 of the frame against the 0.012 where the
  wings stop resolving. The real fix is to clamp against the camera's focus
  distance rather than the aircraft's, which is a signature change.

Tests 1020 -> 1137. Typecheck, build, eight budget cells, no-binaries,
provenance, zero-config boot, dependency licences, arena source hashes and the
UI smoke across two viewports and two access tiers all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:06:12 -07:00

626 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The HTTP contract: every body that crosses between the browser build and the
* Tera API, and nothing else.
*
* **This file is types only.** It compiles to nothing, which is the whole point.
* The browser can import it without paying for a runtime module, and the server
* can import it without the browser package becoming one of its dependencies —
* so one declaration of each body serves both sides and there is no second
* implementation to drift. Everything that imports from here must use
* `import type`, which `verbatimModuleSyntax` already enforces.
*
* Two types here deliberately mirror engine types rather than importing them:
* `WireSimRoute` is structurally `SimRoute` from `engine/flights.ts`. The server
* must be able to build one and it must not pull three.js in to do it, so the
* shape is restated. `WireMarker`, by contrast, genuinely *extends* `Marker`,
* because a marker off the wire is handed straight to `setMarkers()` and the two
* being the same type is what guarantees that stays true.
*
* Routes, all under `/api/v1`:
*
* | route | body | cacheable |
* | ---------------- | --------------- | --------- |
* | `GET /health` | `HealthBody` | no |
* | `GET /flights` | `FlightsBody` | yes |
* | `GET /satellites` | `SatellitesBody` | yes |
* | `GET /weather` | `WeatherBody` | yes |
* | `GET /markers` | `MarkersBody` | yes |
* | `GET /offices/:id` | `OfficeDoc` | public offices only |
* | `GET /offices/:id/presence` | `PresenceBody` | never |
* | `GET /offices/:id/devices` | `DevicesBody` | never |
* | `POST /offices/:id/devices/command` | `DeviceCommandResultBody` | never |
*
* Note the last two. Reading device state and commanding a device are two
* routes and two methods, and that is a **security boundary rather than REST
* taste**: a command that rode in the read body could be replayed by any shared
* cache that had kept a copy of the GET, and turning a microphone on by
* replaying a cached read is precisely the outcome the fail-closed
* `private, no-store` default in CONTRACT.md §5 exists to prevent. Neither
* route is ever `publicCache`d, and the command route is the only body in this
* file that goes *up* the wire.
*
* See CONTRACT.md §5.
*/
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
import type { Marker, SatelliteGroup } from "../engine/types.ts";
import type { Office, Presence } from "../interiors/types.ts";
export type { SatelliteGroup };
/** Path prefix every route lives under. Stated here so both sides read it once. */
export type ApiBase = "/api/v1";
// ---- Errors ---------------------------------------------------------------
/**
* The only error shape. `error` is a stable machine token; `message` is for a
* human reading a log and may change without notice.
*
* Note what is absent: there is no `403`. A request for something the caller may
* not see gets `not_found`, because an endpoint that distinguishes "does not
* exist" from "exists, but not for you" is an enumeration oracle. See
* CONTRACT.md §6.
*/
export interface ErrorBody {
error: "not_found" | "bad_request" | "unauthorized" | "upstream_unavailable" | "internal";
message: string;
}
// ---- Health ---------------------------------------------------------------
export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo";
export type FlightsSourceId = "sim" | "adsb" | "dump1090";
export type SatellitesSourceId = "none" | "celestrak";
export type MarkersSourceId = "none" | "file";
/**
* Where device readings come from.
*
* `none` is the default and serves an empty array — a box nobody has told about
* any hardware has no hardware, which renders as a studio whose panels say so
* rather than as an error. `sim` is the deterministic state machine in
* `src/devices/sim.ts`, the same module the arena wraps, and it is what this
* build ships. `homeassistant` is named here and implemented nowhere: it is the
* door a `first-party-sensor` provenance comes through, and naming it in the
* union now is what stops the next person from adding a second, differently
* shaped source field when they build it.
*/
export type DevicesSourceId = "none" | "sim" | "homeassistant";
export type AuthMode = "none" | "sso" | "jwt";
/**
* One place this box will answer about. Structurally `Region` in
* `server/src/regions.ts`, restated here for the same reason `WireSimRoute` is:
* this file is the contract and the server's own module is an implementation of
* it, and the browser must not have to import server code to read a body.
*/
export interface WireRegion {
/** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
id: string;
lat: number;
lng: number;
/** Kilometres. */
radiusKm: number;
}
/**
* What this deployment turned out to be, once the environment had its say.
*
* `degraded` is the load-bearing field. A source configured without what it
* needs is demoted rather than fatal (CONTRACT.md §5.1), and this is where the
* demotion is visible to anybody who did not read the boot log — without it, a
* misconfigured contact string looks exactly like a clear day.
*/
export interface HealthBody {
ok: true;
service: "tera-api";
version: string;
uptimeSeconds: number;
sources: {
weather: WeatherSourceId;
flights: FlightsSourceId;
satellites: SatellitesSourceId;
markers: MarkersSourceId;
/**
* Newer than the four above it, and read defensively by
* `feedsFrom()` in `src/access.ts` for exactly that reason: a browser
* meeting a server one version behind this one sees `undefined` and
* concludes the box has no devices, which is the safe direction for a
* missing field to fall. Its job is to let a client know that asking is
* pointless before it opens a watch that will 404 forever.
*/
devices: DevicesSourceId;
};
auth: {
mode: AuthMode;
/** Where a browser sends someone to sign in. `null` unless mode is `sso`. */
entryUrl: string | null;
};
/**
* Every place this box will answer about, in the config's order, so the first
* entry is what a request with no query gets.
*
* Here rather than in a `HealthBodyWithRegions` alias next to the route.
* Weather and flights refuse a place this box does not serve, so a client
* that guesses `?city=` earns a 400 it cannot explain; publishing the
* allowlist turns that into one question asked once. It gives nothing away —
* knowing what is served is not the same as widening it, and the ids are the
* names of cities the map already draws.
*/
regions: WireRegion[];
/** One human sentence per demotion. Empty on a fully-configured box. */
degraded: string[];
}
// ---- Flights --------------------------------------------------------------
/**
* A leg the simulator flies. Structurally identical to `SimRoute` in
* `engine/flights.ts`; see the note at the top of this file for why it is
* restated rather than imported.
*/
export interface WireSimRoute {
callsign: string;
from: [number, number];
to: [number, number];
/** Metres at the start and end of the leg. */
fromAlt: number;
toAlt: number;
/** Seconds for a full traversal. */
duration: number;
}
/** One aircraft, as `engine/types.ts` `Aircraft` wants it. */
export interface WireAircraft {
id: string;
lat: number;
lng: number;
/** Metres. The wire never carries feet, whatever the upstream feed used. */
altitude: number;
/** Degrees clockwise from true north. */
heading: number;
callsign?: string;
/**
* Metres per second over the ground, when the feed reported a real one.
*
* SI, like every other quantity on this wire — the feed's own `gs` is knots
* and the conversion happens once, in the adapter, for the same reason
* `altitude` is not feet. See the note there.
*
* **This is the field that makes the sky move.** A client that is handed only
* positions can interpolate between the last two of them and nothing more, so
* every aircraft replays a leg it has already flown, arrives at the newest
* known point and then sits perfectly still until the next snapshot lands
* five to fifteen seconds later. Carrying the velocity lets the client
* dead-reckon — advance each track along its own track angle at its own
* speed, every frame — and an aeroplane that is flying stops being a
* screenshot of one.
*
* Absent, never zeroed, and absent covers three different things that must
* all behave the same way downstream: a feed that did not report it, a
* ground vehicle or parked aircraft reporting `gs: 0.0`, and a
* position-only record with a null `track` — dead-reckoning any of those
* along an invented heading would be inventing motion, which is worse than
* showing none. `engine/flights.ts` holds a track still when this is missing.
*/
groundSpeed?: number;
/**
* Metres per second, positive climbing, when the feed reported a rate.
*
* Barometric where the feed has it and geometric otherwise; the two disagree
* by a few percent in real air and by nothing this renders. Absent rather
* than zero for the same reason as `groundSpeed`: level flight and no
* information are different facts, and only one of them may be drawn.
*/
verticalRate?: number;
/**
* How old the position fix already was when this snapshot was taken, in
* seconds — the feed's `seen_pos`.
*
* Carried because the client is the thing that has to place the aircraft and
* it cannot do that without knowing what instant the coordinates describe.
* Between the receiver's last message, this box's cache TTL and the browser's
* own hold, a fix reaches a viewer several seconds stale, and a dead-reckoner
* handed a stale position as if it were current draws the whole sky lagging
* by that much. `observedAt` on the body says when the *snapshot* was taken;
* this says how far behind that the row already was.
*
* Small — a fraction of a second on a healthy feed — and worth carrying
* anyway, because it is the difference between a client that can reason about
* time and one that assumes.
*/
ageSeconds?: number;
/**
* The tail number the feed published for this airframe, e.g. `"N68834"`.
*
* ODbL data off the same record as the position, and publishable on exactly
* the same terms — it is the enrichment a commercial feed was once wanted
* for, obtained legitimately. Absent, never invented: a registration is what
* somebody types into a registry lookup, and a wrong one names another
* aircraft altogether.
*/
registration?: string;
/**
* ICAO aircraft type designator, e.g. `"B739"`, `"A321"`.
*
* A four-character code and not a marketing name: the feed publishes the
* designator, and expanding it to "Boeing 737-900" would mean shipping a
* table this repo would then have to keep true. The card shows the code
* beside the registration, which is how a spotter reads it anyway.
*/
type?: string;
/**
* The transponder's 24-bit ICAO address, lowercase hex, when the feed gave a
* real one.
*
* Carried explicitly even though `id` is usually the same string, because
* "usually" is the problem: `id` falls back to the callsign for a record with
* no hex, and both community feeds emit `~`-prefixed anonymous addresses for
* TIS-B and MLAT targets, which are *not* ICAO addresses. Somebody pastes
* this into a registry lookup, so a wrong one names another aircraft
* altogether — and `Aircraft` in `engine/types.ts` has nowhere to put it,
* which is why the adapter keeps this record beside the position rather than
* inferring the address back out of the id.
*
* Absent, never invented. `aircraftDetail()` in `engine/flights.ts` is where
* it becomes a card.
*/
icao24?: string;
}
/**
* The simulated sky, sent as a *plan* rather than as positions.
*
* The server hands over the routes, a phase origin and a seed, and every browser
* evaluates the same closed-form function of wall-clock time. That means one
* cheap cacheable request instead of a poll every second, and — more usefully —
* two people looking at the map from different machines see the same aircraft in
* the same places, which a per-client simulation cannot promise.
*
* `t0` is a fixed epoch and emphatically **not** the server's start time: if it
* moved on restart, every aircraft would teleport.
*/
export interface FlightsPlanBody {
mode: "plan";
source: "sim";
/** Epoch milliseconds. The instant at which every route's phase is zero. */
t0: number;
/** Seed for the per-route phase offsets, so all viewers agree. */
seed: number;
routes: WireSimRoute[];
/** How long the plan may be cached, in seconds. */
ttlSeconds: number;
}
/** Real traffic, as positions, because there is no closed form for the sky. */
export interface FlightsLiveBody {
mode: "live";
source: "adsb" | "dump1090";
/** Epoch milliseconds at which this snapshot was taken. */
observedAt: number;
aircraft: WireAircraft[];
ttlSeconds: number;
/**
* Attribution the consumer is expected to display, if the feed asks for it.
*
* **Derived from the host that answered**, in `server/src/flights/licence.ts`,
* and never authored next to the request. It said `adsb.lol` unconditionally
* once, whatever `TERA_ADSB_ENDPOINT` pointed at, which is how a credit line
* and a source come to disagree.
*/
attribution?: string[];
/**
* May a shared cache — or the consumer — hand these bytes to a third party?
*
* The licence the positions arrived under, reduced to the one bit that
* changes behaviour. `false` keeps the route on the fail-closed
* `private, no-store` default from CONTRACT.md §5, so a feed this deployment
* may *use* but not *redistribute* stops at the browser that asked. Required
* rather than optional: a body with no answer to this question is a body
* somebody will assume `true` for.
*/
redistributable: boolean;
/**
* The licence id the source publishes under, e.g. `"ODbL-1.0"`, or
* `"first-party"` for an operator's own receiver. For display and for a
* human reading `/api/v1/flights` directly; the machine-readable half of the
* same fact `attribution` states in prose.
*/
licence?: string;
}
export type FlightsBody = FlightsPlanBody | FlightsLiveBody;
// ---- Satellites -----------------------------------------------------------
/**
* One satellite, sent as its **element set** rather than as a position.
*
* The same trick `FlightsPlanBody` plays, for the same reason and with better
* justification: a TLE is already a closed-form description of an orbit, valid
* for days either side of its epoch, and SGP4 is the function that evaluates it.
* Sending positions would mean polling — a satellite crosses the sky in ten
* minutes — and would mean two people looking at the same overhead pass from
* different machines disagreeing about where it is. Sending the elements means
* one cacheable request every few hours and universal agreement, which is
* exactly the property the flight plan exists to buy.
*
* It is also the *honest* shape. CelesTrak publishes element sets; positions are
* something a consumer computes. A server that computed them would be inserting
* itself into a calculation it adds nothing to.
*
* `line1` and `line2` are the two 69-character TLE lines, verbatim. They are
* carried as strings rather than parsed into fields because SGP4 implementations
* take exactly this and every parse in between is a chance to lose a digit.
*/
export interface WireSatellite {
/** NORAD catalogue number, from columns 37 of line 1. Stable for the object's life. */
noradId: number;
name: string;
group: SatelliteGroup;
/** The first TLE line, 69 characters, unmodified. */
line1: string;
/** The second TLE line, 69 characters, unmodified. */
line2: string;
}
/**
* The catalogue this box is serving, and when it last managed to fetch one.
*
* There is no `mode` discriminant here, unlike `FlightsBody`, because there is
* only ever one mode: elements. A box with no satellite source configured serves
* `source: "none"` and an **empty array** rather than a synthetic constellation,
* and that asymmetry with the flight plan is deliberate. An invented aeroplane is
* a plausible aeroplane; an invented Starlink is a lie about a specific object
* with a catalogue number, and somebody standing in a field with a telescope
* would be entitled to be annoyed about it. The sky either has the real thing in
* it or it has nothing.
*/
export interface SatellitesBody {
source: SatellitesSourceId;
/**
* ISO-8601, the last time a fetch **succeeded**. Not the time of this response:
* a body served from a six-hour-old snapshot must say so, because the client
* has no other way to tell a fresh catalogue from a stale one and SGP4 accuracy
* degrades with distance from the element epoch.
*/
fetchedAt: string;
satellites: WireSatellite[];
ttlSeconds: number;
attribution?: string[];
}
// ---- Weather --------------------------------------------------------------
/**
* Sky conditions, reduced to what a light rig can actually use.
*
* Not a met report: no dew point, no pressure, no station id. `Atmosphere`
* consumes cloud cover, precipitation and visibility and nothing else, and a
* field that no renderer reads is a field that gets wrong without anyone
* noticing.
*/
export type WeatherCondition =
| "clear"
| "partly-cloudy"
| "cloudy"
| "overcast"
| "fog"
| "rain"
| "snow"
| "thunderstorm";
export interface WeatherBody {
/** ISO-8601. The observation time, not the fetch time. */
observedAt: string;
source: WeatherSourceId;
/**
* True when nobody was asked and this is the fallback clear day.
*
* A zero-config box serves `synthetic: true` forever and that is a supported
* state, not an error — which is why this is a field and not a 503.
*/
synthetic: boolean;
location: { lat: number; lng: number };
/** `null` where the source did not report it. Never silently zeroed. */
temperatureC: number | null;
windKph: number | null;
/** Degrees clockwise from true north, the direction the wind blows *from*. */
windDirDeg: number | null;
/** 0..1. */
cloudCover: number;
/** 0..1, an intensity rather than a rate — the renderer wants a dial. */
precipitation: number;
visibilityKm: number | null;
condition: WeatherCondition;
/** Attribution the consumer must display for this source, where one is owed. */
attribution?: string[];
}
// ---- Markers --------------------------------------------------------------
/**
* Where a coordinate came from, and the only thing standing between this repo
* and an ODbL share-alike obligation.
*
* Publishing a snapshot of geocoded coordinates is Public Use of a Derivative
* Database. If those coordinates came out of Nominatim, ODbL §4.3 and §4.4
* attach to everything served here — no matter that the rows live in a private
* database rather than in the repo. Containment was never the discharge.
* CONTRACT.md §8.
*
* The type is a plain string on purpose. The entire point of the gate is that a
* value nobody anticipated can arrive and must be *refused at serve time*, which
* a closed union would quietly turn into a compile error somewhere upstream
* instead.
*/
export type CoordinateProvenance = KnownProvenance | (string & {});
/**
* The values the public gate accepts by default.
*
* - `us-census` — geocoding.geo.census.gov, a US Government work in the public
* domain. The sanctioned geocoder.
* - `hand-placed` — typed by a human from a published address. Original.
* - `synthetic` — invented for a demo. Owes nobody anything.
*
* Deliberately absent: `nominatim`, `osm`, `google`, `mapbox`, `here`. The first
* two are share-alike; the rest restrict storing and redistributing what they
* return, which is exactly what a public snapshot does.
*/
export type KnownProvenance = "us-census" | "hand-placed" | "synthetic";
/**
* A marker as it crosses the wire: an engine `Marker` plus where its coordinate
* came from.
*
* It extends `Marker` rather than restating it so that the adapter is a cast and
* not a copy — the engine still knows nothing about provenance, and the day
* `Marker` gains a field, this follows it.
*/
export interface WireMarker extends Marker {
provenance: CoordinateProvenance;
}
export interface MarkersBody {
markers: WireMarker[];
/** ISO-8601 timestamp of the snapshot these rows came from. */
generatedAt: string;
/**
* Rows the public-shape gate refused, by count and reason. Served rather than
* only logged: a silent drop looks identical to an empty database.
*/
refused: { reason: string; count: number }[];
attribution?: string[];
}
// ---- Offices --------------------------------------------------------------
/**
* One office pack, addressed and wrapped for delivery.
*
* `floor` is the authored `Office` exactly as `src/interiors/types.ts` defines
* it — the pack a self-hoster writes by hand and the pack that arrives over HTTP
* are the same bytes, which is the rule that keeps the format from forking.
* Everything outside `floor` is deployment metadata the renderer never reads.
*/
export interface OfficeDoc {
id: string;
name: string;
floor: Office;
visibility: OfficeVisibility;
/** ISO-8601. */
updated?: string;
}
/**
* Who is in one office, right now.
*
* Separate from `OfficeDoc` on purpose, and the separation is load-bearing
* rather than tidy. A `Presence` carries a `seatId` and no coordinate precisely
* so that the geometry can be published while the people are not; putting the
* roster inside the pack would mean an operator who wants a public floorplan has
* to strip the people out of it by hand, and the first time they forget, the
* leak is permanent. Two documents means the safe thing is the default thing.
*
* It is also the honest shape for the data: a floorplan changes when somebody
* moves a wall and a roster changes when somebody sits down, which is why this
* one carries `observedAt` and the pack does not.
*/
export interface PresenceBody {
officeId: string;
people: Presence[];
/** ISO-8601. Absent when the source did not say, which is not an error. */
observedAt?: string;
}
/**
* `private` means the endpoint answers 404 to anyone who may not see it — see
* `ErrorBody`. `unlisted` is served to anybody with the id but never appears in
* an index and is never publicly cached.
*/
export type OfficeVisibility = "public" | "unlisted" | "private";
// ---- Devices --------------------------------------------------------------
/**
* What the hardware in one office is doing, right now.
*
* The runtime half of the split `src/devices/types.ts` opens with, and the
* reason this body exists at all: a `DeviceDeclaration` is authored into the
* office pack and is therefore public by construction, while a `DeviceState`
* never appears in a file anybody can download. It arrives here, from a route
* that can refuse it, and it is the same line `PresenceBody` draws between a
* floorplan and the people standing on it.
*
* **Never publicly cached, in any configuration.** Two reasons and they are
* different: the body took a credential to obtain, so a shared cache holding it
* would hand one viewer's copy to the next; and the state is mutable by a
* command, so a cached copy is a stale claim about a room somebody is standing
* in. `routes/devices.ts` therefore never calls `publicCache`, and says so out
* loud rather than merely omitting the call.
*
* An office with no authored devices is `devices: []` and a 200 — not a 404.
* "This office does not exist" and "nobody has declared any hardware in it" are
* different facts with different fixes, exactly as `presence/store.ts` argues
* for a missing roster.
*/
export interface DevicesBody {
officeId: string;
devices: DeviceState[];
/** Epoch milliseconds at which this snapshot was taken. */
observedAt: number;
source: DevicesSourceId;
/**
* Did anybody observe any of this?
*
* `true` for everything this build ships, because the only implemented source
* is a state machine. It is the body-level statement of the same fact
* `DeviceState.synthetic` makes per device, and it is carried separately so
* that an empty array still says where it came from — an empty `devices` with
* `synthetic: false` is a box with a real bridge and nothing plugged into it,
* which is a different picture from a box that is making it all up.
*/
synthetic: boolean;
ttlSeconds: number;
attribution?: string[];
}
/**
* A command, going up.
*
* The only body in this file that travels from the browser to the server, and
* deliberately the narrowest one: exactly one command, for exactly one device,
* with no batching. A batch would need partial-failure semantics, and the first
* write surface in this product is not the place to invent those.
*
* The command is validated **server-side against the resolved office plan** —
* that the device id names an authored declaration, that the declaration's
* asset really is hardware of the kind it claims, and that the op is one that
* declaration declared. `normalizeDeviceCommand()` is the shared validator and
* both ends run it, which is not redundancy: the browser runs it so a slider
* cannot send nonsense, and the server runs it because a browser is not a
* boundary. The same move `officeHasMediaBinding()` makes for screens.
*/
export interface DeviceCommandBody {
command: DeviceCommand;
}
/**
* What a command did, as the state that resulted from it.
*
* The new state rather than an `ok: true`, so the panel has something to draw
* without a follow-up GET — and so the answer to "did that work" is the reading
* itself rather than an acknowledgement that a request was received. A command
* that was accepted and clamped (a gain of 40 dB on a device whose range stops
* at 36) reports the clamped value here, and the slider snaps to what the
* hardware actually did.
*/
export interface DeviceCommandResultBody {
officeId: string;
/** The device as it stands after the command was applied. */
device: DeviceState;
/** Epoch milliseconds. */
observedAt: number;
}