feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+464
-8
@@ -32,12 +32,16 @@
|
||||
*/
|
||||
|
||||
import type { WeatherObservation } from "../engine/atmosphere.ts";
|
||||
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
|
||||
import { deviceStateSignature } from "../devices/types.ts";
|
||||
import {
|
||||
aircraftDetail,
|
||||
distanceNm,
|
||||
inRegion,
|
||||
sampleRoute,
|
||||
SimulatedFlights,
|
||||
syntheticRoutes,
|
||||
type AircraftDetail,
|
||||
type Place,
|
||||
type SimRoute,
|
||||
type SkyRegion,
|
||||
@@ -46,6 +50,10 @@ import type { SatelliteElements } from "../engine/satellites.ts";
|
||||
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
|
||||
import { seededRandom } from "../engine/world.ts";
|
||||
import type {
|
||||
DeviceCommandBody,
|
||||
DeviceCommandResultBody,
|
||||
DevicesBody,
|
||||
DevicesSourceId,
|
||||
FlightsBody,
|
||||
FlightsPlanBody,
|
||||
HealthBody,
|
||||
@@ -54,6 +62,7 @@ import type {
|
||||
PresenceBody,
|
||||
SatellitesBody,
|
||||
WeatherBody,
|
||||
WireAircraft,
|
||||
} from "../server/wire.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts";
|
||||
|
||||
@@ -197,6 +206,54 @@ export interface PresenceWatch {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One office's hardware, plus whether anybody actually asked a server about it.
|
||||
*
|
||||
* `live` here means *this deployment answered*, and it is not the same claim as
|
||||
* `synthetic`, which means *nobody observed these readings*. All four
|
||||
* combinations are real deployments and the interface has to be able to say
|
||||
* each of them:
|
||||
*
|
||||
* | live | synthetic | what it is |
|
||||
* | --- | --- | --- |
|
||||
* | false | true | no server, or an anonymous viewer: the local simulator |
|
||||
* | true | true | a server running `TERA_DEVICES_SOURCE=sim` |
|
||||
* | true | false | a real bridge to real hardware |
|
||||
* | false | false | impossible, and nothing constructs it |
|
||||
*
|
||||
* The second row is the reference deployment and the first is every clone of
|
||||
* this repo, which is why both of them have to look alive and both have to say
|
||||
* so. `DeviceDeclaration.disclosure` is the sentence a viewer reads; these two
|
||||
* booleans are what the interface branches on.
|
||||
*/
|
||||
export interface DeviceFeed extends Feed<DeviceState[]> {
|
||||
/** Which source the server said it was using, or `"none"` when nobody answered. */
|
||||
source: DevicesSourceId;
|
||||
/** True when nobody observed these readings. True for everything this build ships. */
|
||||
synthetic: boolean;
|
||||
/** Epoch milliseconds of the snapshot, or `null` when there is no snapshot. */
|
||||
observedAt: number | null;
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A running poll of one office's hardware.
|
||||
*
|
||||
* `PresenceWatch` with a `current()` on it. Devices need the accessor and
|
||||
* occupancy does not, because a device panel is opened *after* the room has
|
||||
* been drawn — the panel wants the last reading immediately rather than waiting
|
||||
* up to a TTL for the next publish, and re-fetching to answer that would spend
|
||||
* a request on a body this object is already holding.
|
||||
*/
|
||||
export interface DeviceWatch {
|
||||
/** The latest feed. Empty and not live until an answer lands. */
|
||||
current(): DeviceFeed;
|
||||
/** Ask now rather than at the next tick. Ignored while a request is in flight. */
|
||||
refresh(): void;
|
||||
/** Stop polling, abort anything in flight, and drop any late answer. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface TeraClient {
|
||||
/** What the deployment turned out to be, or `null` if there is no server. */
|
||||
health(): Promise<HealthBody | null>;
|
||||
@@ -262,6 +319,40 @@ export interface TeraClient {
|
||||
* to be able to draw.
|
||||
*/
|
||||
watchPresence(officeId: string, onBody: (body: PresenceBody | null) => void): PresenceWatch;
|
||||
/**
|
||||
* What the hardware in one office is doing.
|
||||
*
|
||||
* Always an authenticated call, exactly like `presence` and for a related
|
||||
* reason: `routes/devices.ts` refuses an anonymous one whatever else the
|
||||
* deployment is set to. A refusal — no API, no session, an office this
|
||||
* viewer may not see — is an empty feed with `live: false`, and the caller's
|
||||
* answer to that is the *locally simulated* studio in `src/devices/sim.ts`,
|
||||
* not an empty panel. See `src/devices/adapter.ts`, which is where that
|
||||
* decision is made once instead of at every call site.
|
||||
*/
|
||||
devices(officeId: string, options?: { signal?: AbortSignal }): Promise<DeviceFeed>;
|
||||
/**
|
||||
* The same question, asked repeatedly, until the caller stops it.
|
||||
*
|
||||
* Modelled on `watchPresence` rather than on `watchWeather`, because it
|
||||
* describes the room somebody is standing in rather than the sky: it stops
|
||||
* dead while the tab is hidden, wakes the moment it comes back, and publishes
|
||||
* only when a reading a viewer could see has changed.
|
||||
*/
|
||||
watchDevices(officeId: string, onFeed: (feed: DeviceFeed) => void): DeviceWatch;
|
||||
/**
|
||||
* Ask one device to do something. Resolves to the state it ended up in, or
|
||||
* `null` for any refusal.
|
||||
*
|
||||
* **A POST, on a route of its own, never folded into the read.** A command
|
||||
* riding in a GET response could be replayed by any shared cache that kept a
|
||||
* copy, and a cache that turned a microphone on by replaying a read is
|
||||
* exactly what the fail-closed `Cache-Control` default in CONTRACT.md §5
|
||||
* exists to prevent. It is also the first write surface in this product that
|
||||
* changes something another viewer can see, which is the other half of why
|
||||
* it is separate: reading is the demo, writing is the account.
|
||||
*/
|
||||
commandDevice(officeId: string, command: DeviceCommand): Promise<DeviceState | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,6 +417,40 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* One POST, and `null` for every way it can go wrong.
|
||||
*
|
||||
* The same deliberate coarseness as `get` — a 401, a 400, a timeout and a
|
||||
* static host answering with its own HTML are one outcome to the caller — with
|
||||
* one difference that matters: **nothing here retries**. A GET that failed can
|
||||
* be repeated because asking twice costs a request; a command that failed may
|
||||
* have been applied before the connection died, and repeating it is the
|
||||
* difference between "turn the microphone on" and "turn the microphone on
|
||||
* twice". Idempotence is not a property this can assume on the caller's
|
||||
* behalf, so a refusal is reported and the panel asks the person.
|
||||
*/
|
||||
const post = async <T,>(path: string, body: unknown): Promise<T | null> => {
|
||||
if (!doFetch) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await doFetch(`${base}${path}`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const type = res.headers.get("content-type") ?? "";
|
||||
if (!type.includes("json")) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
health: () => get<HealthBody>("/health"),
|
||||
|
||||
@@ -410,6 +535,30 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
watchPresence(officeId, onBody) {
|
||||
return watchPresence(get, officeId, onBody);
|
||||
},
|
||||
|
||||
async devices(officeId, opts: { signal?: AbortSignal } = {}): Promise<DeviceFeed> {
|
||||
const body = await get<DevicesBody>(devicesPath(officeId), {
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
});
|
||||
return deviceFeed(body);
|
||||
},
|
||||
|
||||
watchDevices(officeId, onFeed) {
|
||||
return watchDevices(get, officeId, onFeed);
|
||||
},
|
||||
|
||||
async commandDevice(officeId, command): Promise<DeviceState | null> {
|
||||
const request: DeviceCommandBody = { command };
|
||||
const body = await post<DeviceCommandResultBody>(
|
||||
`${devicesPath(officeId)}/command`,
|
||||
request,
|
||||
);
|
||||
// Checked rather than trusted, like every other body this file adopts: a
|
||||
// 200 with the wrong shape in it is what a server one version behind this
|
||||
// one sends, and `null` is already the caller's "it did not happen".
|
||||
if (!body || body.device === null || typeof body.device !== "object") return null;
|
||||
return body.device;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -755,6 +904,16 @@ function queryString(query: Record<string, string | number> | undefined): string
|
||||
* it.
|
||||
*/
|
||||
export interface TrafficSource extends FlightSource {
|
||||
/**
|
||||
* Narrowed from `FlightSource.poll()`, which may return a promise.
|
||||
*
|
||||
* Not a convenience: it is the file's central promise made into a type. This
|
||||
* is called from the render loop, so it answers from whatever is in hand and
|
||||
* refreshes on the body's own TTL in the background — a `poll()` that awaited
|
||||
* a slow fetch would put a frame's aircraft update behind a round trip. An
|
||||
* adapter that cannot promise that is a `FlightSource` and not one of these.
|
||||
*/
|
||||
poll(): Aircraft[];
|
||||
/** True while the aircraft `poll()` returns are observed positions for this region. */
|
||||
live(): boolean;
|
||||
/**
|
||||
@@ -767,6 +926,26 @@ export interface TrafficSource extends FlightSource {
|
||||
* exists. `describeLiveness` says what is live; this says who to thank for it.
|
||||
*/
|
||||
attribution(): string[];
|
||||
/**
|
||||
* Everything known about one aircraft that is currently being drawn, or
|
||||
* `null` for an id that is not.
|
||||
*
|
||||
* The click target of the whole city board, and it is on this interface for
|
||||
* the same reason `live()` and `attribution()` are: the engine draws darts at
|
||||
* coordinates and the *deployment* knows what those coordinates are. It is
|
||||
* synchronous and answers from what `poll()` last handed over, so a click can
|
||||
* open a card in the same frame — going back to the network for a record this
|
||||
* object already holds would put a panel behind a round trip, and would ask a
|
||||
* volunteer-funded feed for a row it just sent.
|
||||
*
|
||||
* **Available to an anonymous visitor**, and that is a decision rather than
|
||||
* an oversight. An ADS-B position is broadcast unencrypted by the aircraft to
|
||||
* anybody with a receiver; there is nothing here an account could grant
|
||||
* access to, and gating it would cost the first-visit moment this map exists
|
||||
* for while protecting nothing. `access.ts` makes the same argument about the
|
||||
* sky at greater length.
|
||||
*/
|
||||
detail(id: string): AircraftDetail | null;
|
||||
/** Stop fetching and abort anything in flight. Idempotent. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -809,30 +988,91 @@ class HttpFlights implements TrafficSource {
|
||||
*/
|
||||
readonly interval = 1;
|
||||
|
||||
private readonly get: Get;
|
||||
private readonly region: SkyRegion;
|
||||
private readonly fallback: SimulatedFlights;
|
||||
private mode: "fallback" | "plan" | "live" = "fallback";
|
||||
private plan: FlightsPlanBody | null = null;
|
||||
private planPhase: number[] = [];
|
||||
private aircraft: Aircraft[] = [];
|
||||
private credits: string[] = [];
|
||||
/**
|
||||
* Everything the live body said about each aircraft, keyed by id.
|
||||
*
|
||||
* Only the live path fills this, because only the live path is told anything
|
||||
* an `Aircraft` cannot carry — `WireAircraft.icao24` in particular, which is
|
||||
* the transponder address and is the field a detail card is actually about.
|
||||
* Cleared and rebuilt with every adopted body, so it can never outlive the
|
||||
* positions it describes.
|
||||
*/
|
||||
private readonly wire = new Map<string, WireAircraft>();
|
||||
private latest: Aircraft[] = [];
|
||||
private nextFetchAt = 0;
|
||||
private inFlight: AbortController | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly get: Get,
|
||||
private readonly region: SkyRegion,
|
||||
fallbackRoutes: SimRoute[],
|
||||
) {
|
||||
/**
|
||||
* Fields assigned in the constructor body rather than declared as parameter
|
||||
* properties.
|
||||
*
|
||||
* **This is not a style preference and it must stay this way.** A parameter
|
||||
* property is the one piece of TypeScript syntax that emits code — it is a
|
||||
* hidden assignment, not a type annotation — so Node's type stripping refuses
|
||||
* the *whole module* with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Vite never
|
||||
* cared, so for the entire life of this file `node --test` could not import
|
||||
* `adapters/http.ts` at all: every degrade path, every rung of the back-off
|
||||
* ladder and the whole region filter below had zero coverage, in the module
|
||||
* whose job is to be correct when everything else has failed.
|
||||
*
|
||||
* `engine/flights.ts` says the same thing over `AdsbFlights` and adds the
|
||||
* consequence: the module with the worst bug this project has shipped was, by
|
||||
* construction, the one module that could not be tested. Two modules had that
|
||||
* property; this was the second.
|
||||
*/
|
||||
constructor(get: Get, region: SkyRegion, fallbackRoutes: SimRoute[]) {
|
||||
this.get = get;
|
||||
this.region = region;
|
||||
this.fallback = new SimulatedFlights(fallbackRoutes);
|
||||
}
|
||||
|
||||
poll(): Aircraft[] {
|
||||
this.refreshIfStale();
|
||||
const { mode, plan, planPhase } = this;
|
||||
if (mode === "plan" && plan) return evaluatePlan(plan, planPhase, Date.now());
|
||||
if (this.mode === "live") return this.aircraft;
|
||||
return this.fallback.poll();
|
||||
// Kept, so that `detail()` can answer a click about the aircraft that were
|
||||
// actually drawn rather than about a fresh evaluation a few milliseconds
|
||||
// later — the plan path is a function of `Date.now()`, so re-evaluating it
|
||||
// for a lookup would return a position slightly ahead of the dart the
|
||||
// viewer aimed at.
|
||||
this.latest =
|
||||
mode === "plan" && plan
|
||||
? evaluatePlan(plan, planPhase, Date.now())
|
||||
: mode === "live"
|
||||
? this.aircraft
|
||||
: this.fallback.poll();
|
||||
return this.latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* One aircraft, as a card.
|
||||
*
|
||||
* Looked up in what was last polled, which is also what is on screen. An id
|
||||
* that has left the feed answers `null` rather than the last known position:
|
||||
* a card showing where something was two minutes ago, with no way to say so,
|
||||
* is the same class of quiet staleness `WeatherFeed.observedAt` exists to
|
||||
* prevent — and the caller's honest response is to close the card.
|
||||
*/
|
||||
detail(id: string): AircraftDetail | null {
|
||||
const found = this.latest.find((a) => a.id === id);
|
||||
if (found === undefined) return null;
|
||||
const wire = this.wire.get(id);
|
||||
return aircraftDetail(found, {
|
||||
// Only a live body carries an address, and only a live body was observed.
|
||||
// The plan's aircraft are this repo's own arithmetic and say so.
|
||||
...(wire?.icao24 === undefined ? {} : { icao24: wire.icao24 }),
|
||||
observed: this.mode === "live",
|
||||
attribution: this.attribution(),
|
||||
from: this.region.center,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -924,6 +1164,12 @@ class HttpFlights implements TrafficSource {
|
||||
private adopt(body: FlightsBody): number {
|
||||
const ttl = Number.isFinite(body.ttlSeconds) ? Math.max(1, body.ttlSeconds) : RETRY_SECONDS;
|
||||
this.credits = [];
|
||||
// Both of these describe the body that is about to be adopted, so both are
|
||||
// dropped before it is looked at rather than on each of the four ways this
|
||||
// method can decide not to adopt it. A transponder address left over from a
|
||||
// previous body would otherwise be attached, by id collision, to whatever
|
||||
// the next one draws.
|
||||
this.wire.clear();
|
||||
|
||||
if (body.mode === "plan") {
|
||||
if (!Array.isArray(body.routes)) {
|
||||
@@ -981,6 +1227,11 @@ class HttpFlights implements TrafficSource {
|
||||
return ELSEWHERE_SECONDS;
|
||||
}
|
||||
this.aircraft = here;
|
||||
// Only the live path has anything to record: a plan carries routes, not
|
||||
// transponders. Cleared at the top of this method, so a record that has
|
||||
// left the feed leaves this map with it rather than surviving to answer a
|
||||
// click about an aircraft nobody is drawing.
|
||||
for (const a of here) this.wire.set(a.id, a);
|
||||
this.plan = null;
|
||||
this.mode = "live";
|
||||
// Only the live body carries credits — `wire.ts` puts `attribution` on
|
||||
@@ -1232,3 +1483,208 @@ function watchPresence(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Devices --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How often to ask what the hardware is doing, when the server does not say.
|
||||
*
|
||||
* Five seconds, and the number comes from the data rather than from the
|
||||
* network. Occupancy moves when somebody stands up and is polled every thirty;
|
||||
* a level meter moves continuously and a mute button moves the instant it is
|
||||
* pressed, and a panel that took half a minute to notice somebody else had
|
||||
* muted the room would read as broken. The server's `ttlSeconds` overrides this
|
||||
* whenever it answers — `TERA_DEVICES_TTL` is the operator's dial and this is
|
||||
* only what to do before they have had their say.
|
||||
*
|
||||
* It is still a poll rather than a stream. The realtime service exists and this
|
||||
* deliberately does not use it: a device panel is open for a minute at a time
|
||||
* on a handful of tabs, and a socket per viewer for a body this size is a
|
||||
* standing cost for an occasional need. If the panels are ever open all day,
|
||||
* that is the moment to revisit it.
|
||||
*/
|
||||
const DEVICES_INTERVAL_MS = 5_000;
|
||||
|
||||
/** Bounds on whatever the server asks for, so one bad TTL cannot become a flood. */
|
||||
const DEVICES_MIN_INTERVAL_MS = 2_000;
|
||||
const DEVICES_MAX_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The ceiling on the back-off ladder, five minutes — `watchPresence`'s number,
|
||||
* not the weather watch's hour, and for the same reason it gives: somebody is
|
||||
* standing in the room this describes.
|
||||
*/
|
||||
const DEVICES_MAX_BACKOFF_MS = 5 * 60_000;
|
||||
|
||||
/** Nobody answered. Empty, not live, and not claiming to have observed anything. */
|
||||
function noDevices(): DeviceFeed {
|
||||
return { value: [], live: false, source: "none", synthetic: true, observedAt: null, attribution: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* One devices body, judged.
|
||||
*
|
||||
* Two outcomes, and the check is on the array rather than on its length. A body
|
||||
* with `devices: []` is a real answer — an office nobody has declared any
|
||||
* hardware in, which is most offices — and it is `live`, because the deployment
|
||||
* answered and said so. A body with no array in it at all is not an answer, and
|
||||
* it is the shape a server one version behind this one sends. The same
|
||||
* distinction `adsb.ts` draws between an empty circle of sky and an unreadable
|
||||
* envelope, for the same reason: coercing the second into the first serves
|
||||
* fiction under a live badge.
|
||||
*/
|
||||
function deviceFeed(body: DevicesBody | null): DeviceFeed {
|
||||
if (!body || !Array.isArray(body.devices)) return noDevices();
|
||||
return {
|
||||
value: body.devices,
|
||||
live: true,
|
||||
source: body.source ?? "none",
|
||||
// Absent means synthetic. A body that did not say whether anybody observed
|
||||
// its readings is not a body that may be presented as observation.
|
||||
synthetic: body.synthetic !== false,
|
||||
observedAt: typeof body.observedAt === "number" ? body.observedAt : null,
|
||||
attribution: Array.isArray(body.attribution) ? body.attribution : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one office's hardware until told to stop.
|
||||
*
|
||||
* `watchPresence` with a TTL from the body and a signature that covers readings
|
||||
* instead of seats. The three properties it inherits are the ones that matter
|
||||
* and they are argued for at length there:
|
||||
*
|
||||
* - **stops dead while the tab is hidden**, and asks immediately on the way
|
||||
* back, because a backgrounded panel polling a meter nobody can see is
|
||||
* waste at both ends;
|
||||
* - **publishes only on change**, by comparing `deviceStateSignature` — every
|
||||
* answer is a fresh array, so identity says nothing, and a still studio
|
||||
* would otherwise rebuild its panel every few seconds forever;
|
||||
* - **reports a refusal** rather than freezing on the last good reading, so
|
||||
* "the room went quiet" and "I have stopped hearing about the room" stay
|
||||
* distinguishable.
|
||||
*
|
||||
* The signature deliberately ignores `observedAt`, which moves on every poll of
|
||||
* an unchanged studio and would defeat the whole comparison.
|
||||
*/
|
||||
function watchDevices(
|
||||
get: Get,
|
||||
officeId: string,
|
||||
onFeed: (feed: DeviceFeed) => void,
|
||||
): DeviceWatch {
|
||||
let feed = noDevices();
|
||||
let signature: string | null = null;
|
||||
let published = false;
|
||||
let failures = 0;
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: AbortController | null = null;
|
||||
|
||||
const path = devicesPath(officeId);
|
||||
|
||||
function schedule(delayMs: number) {
|
||||
if (stopped) return;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = setTimeout(() => void tick(), delayMs);
|
||||
}
|
||||
|
||||
function publish(next: DeviceFeed) {
|
||||
const nextSignature = `${next.live ? "1" : "0"}${deviceStateSignature(next.value)}`;
|
||||
// The first answer always goes through, even when it matches the empty
|
||||
// signature the caller may have assumed. "I asked and there is nothing" and
|
||||
// "I have not asked yet" are different states and only one of them should
|
||||
// leave a panel saying so on purpose.
|
||||
feed = next;
|
||||
if (published && nextSignature === signature) return;
|
||||
signature = nextSignature;
|
||||
published = true;
|
||||
onFeed(next);
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
timer = null;
|
||||
if (stopped) return;
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
// Nothing reaches this with a request already out, but a watch that stalled
|
||||
// would stay stalled until the page reloaded, and that is too quiet a
|
||||
// failure to leave to the reasoning being right.
|
||||
if (inFlight) {
|
||||
schedule(DEVICES_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight = new AbortController();
|
||||
const body = await get<DevicesBody>(path, { signal: inFlight.signal });
|
||||
inFlight = null;
|
||||
// Stopped while this was in the air: the office was left or the panel was
|
||||
// closed. Whatever came back describes a room nobody is looking at, and it
|
||||
// is also why a cancelled request must not count as a failure below.
|
||||
if (stopped) return;
|
||||
|
||||
publish(deviceFeed(body));
|
||||
|
||||
if (body === null) {
|
||||
failures += 1;
|
||||
schedule(Math.min(DEVICES_INTERVAL_MS * 2 ** (failures - 1), DEVICES_MAX_BACKOFF_MS));
|
||||
return;
|
||||
}
|
||||
failures = 0;
|
||||
schedule(intervalFor(body));
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
if (stopped) return;
|
||||
if (document.visibilityState === "visible") {
|
||||
// Immediately rather than at the next tick: somebody has just come back
|
||||
// to this tab and the panel in front of them is the stale thing.
|
||||
failures = 0;
|
||||
schedule(0);
|
||||
} else if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
void tick();
|
||||
|
||||
return {
|
||||
current: () => feed,
|
||||
refresh() {
|
||||
if (stopped || inFlight) return;
|
||||
schedule(0);
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
inFlight?.abort();
|
||||
inFlight = null;
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The cadence the server asked for, clamped.
|
||||
*
|
||||
* Checked rather than trusted, exactly as `HttpFlights.adopt` learned to be: an
|
||||
* absent `ttlSeconds` makes `Math.max(1, undefined)` a `NaN`, `NaN` clears every
|
||||
* comparison, and the poll interval quietly becomes the frame rate. The floor
|
||||
* is the load-bearing half — a `TERA_DEVICES_TTL=0` that reads like "as fresh
|
||||
* as possible" would otherwise be one request per tick per open tab.
|
||||
*/
|
||||
function intervalFor(body: DevicesBody): number {
|
||||
const asked = Number.isFinite(body.ttlSeconds) ? body.ttlSeconds * 1000 : DEVICES_INTERVAL_MS;
|
||||
return Math.min(DEVICES_MAX_INTERVAL_MS, Math.max(DEVICES_MIN_INTERVAL_MS, asked));
|
||||
}
|
||||
|
||||
/** One place the route is spelled, so the read and the command cannot drift apart. */
|
||||
function devicesPath(officeId: string): string {
|
||||
return `/offices/${encodeURIComponent(officeId)}/devices`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user