- {#if nav.selectedCategoryId === "alerts"} + + {#if !schema.value} + + {:else if nav.selectedCategoryId === "alerts"} {:else if nav.selectedCategoryId === "overview"} diff --git a/gui/frontend/src/lib/layout/Shell.svelte b/gui/frontend/src/lib/layout/Shell.svelte index 8bc8f15..abced62 100644 --- a/gui/frontend/src/lib/layout/Shell.svelte +++ b/gui/frontend/src/lib/layout/Shell.svelte @@ -62,18 +62,22 @@ // Multicast badges: the address each host socket is open on, green while // packets arrive there. Until the first network.state lands, the configured - // address stands in. - let configured = $derived(networkFromDoc()); + // address stands in, once the document (and the schema's fallbacks) has + // loaded. + let configured = $derived(config.doc ? networkFromDoc() : undefined); let sockets = $derived([ { label: "Vision", status: network.state?.vision, - fallback: `${configured.vision_ip}:${String(configured.vision_port)}`, + fallback: + configured && + `${configured.vision_ip}:${String(configured.vision_port)}`, }, { label: "Game Controller", status: network.state?.gc, - fallback: `${configured.gc_ip}:${String(configured.gc_port)}`, + fallback: + configured && `${configured.gc_ip}:${String(configured.gc_port)}`, }, ]); @@ -135,7 +139,9 @@ ? "green" : "gray"} > - {socket.label} ({socket.status?.address ?? socket.fallback}) + {@const address = socket.status?.address ?? socket.fallback} + {socket.label}{#if address} + ({address}){/if} {/each} @@ -237,6 +243,12 @@ > + API Reference ↗︎ ; -} - -export interface NetworkState { - vision: SocketStatus; - gc: SocketStatus; - // Every camera_id heard in the last 30 s, by camera_id then address. - cameras: DetectionSource[] | null; - referee: RefereeState; - autoInterfaces: boolean; - interfaces: HostInterface[] | null; - // The loopback interface, and whether multicast is enabled on it. - loopback: string; - loopbackMulticast: boolean; - // Lowest port an unprivileged process may bind on the host. - unprivilegedPortStart: number; - root: boolean; -} +import type { HostInterface, NetworkState } from "./apiTypes"; + +// What the host's sockets hear (network.state), from the host's spec. +export type { + DetectionSource, + HostInterface, + NetworkState, + RefereeState, + RefereeTeam, + SocketStatus, +} from "./apiTypes"; export interface NetworkConfig { vision_ip: string; @@ -91,14 +28,6 @@ export interface NetworkConfig { export type NetworkKey = keyof NetworkConfig; -// vision_processor's fallbacks (Resources.cpp) for keys the block leaves out. -export const NETWORK_DEFAULTS: NetworkConfig = { - vision_ip: "224.5.23.2", - vision_port: 10006, - gc_ip: "224.5.23.1", - gc_port: 10003, -}; - export const network = $state<{ state: NetworkState | null }>({ state: null, }); @@ -121,9 +50,13 @@ function block(): Record | undefined { export function networkFromDoc(): NetworkConfig { const b = block() ?? {}; const str = (key: NetworkKey): string => - typeof b[key] === "string" ? b[key] : String(NETWORK_DEFAULTS[key]); + typeof b[key] === "string" + ? b[key] + : (fallback(`network.${key}`) as string); const num = (key: NetworkKey): number => - typeof b[key] === "number" ? b[key] : Number(NETWORK_DEFAULTS[key]); + typeof b[key] === "number" + ? b[key] + : (fallback(`network.${key}`) as number); return { vision_ip: str("vision_ip"), @@ -293,7 +226,7 @@ export function addressAdvice( } } } else { - const iface = state?.interfaces?.find((i) => i.broadcast === ip); + const iface = state?.interfaces.find((i) => i.broadcast === ip); if (ip === "255.255.255.255") { advice.notes.push(text.address.broadcastAll); diff --git a/gui/frontend/src/lib/schema.svelte.ts b/gui/frontend/src/lib/schema.svelte.ts new file mode 100644 index 0000000..ed9b963 --- /dev/null +++ b/gui/frontend/src/lib/schema.svelte.ts @@ -0,0 +1,50 @@ +// What the host says about vision_processor's settings (GET /api/schema; see +// gui/internal/config's schema.go): each key's type, fallback, bounds, and +// whether a change needs a restart. The one copy of what src/Resources.cpp +// does with config.yml, so nothing here repeats it. loadConfig loads it before +// the document, so anything rendered from the document can rely on it. +import { requestJSON } from "./api"; +import type { Setting, SettingsSchema as Schema } from "./apiTypes"; + +// Each setting's type, fallback, bounds and when a change takes effect: the +// host's spec of GET /api/schema. +export type { + Apply, + DriverCapabilities, + Setting, + SettingsSchema as Schema, +} from "./apiTypes"; + +export const schema = $state<{ value: Schema | null }>({ value: null }); + +let loading: Promise | null = null; + +// Loads the schema once; later calls wait on the same request. A failure is +// thrown to the caller and retried on the next call. +export function loadSchema(): Promise { + loading ??= requestJSON("/api/schema") + .then((response) => response.json()) + .then((value) => { + schema.value = value as Schema; + }) + .catch((err: unknown) => { + loading = null; + throw err; + }); + + return loading; +} + +// The setting at path, e.g. "thresholds.blobs". Throws if the schema isn't +// loaded or has no such key: both are bugs, not runtime conditions. +export function setting(path: string): Setting { + const s = schema.value?.settings[path]; + if (!s) throw new Error(`schema: no setting ${path}`); + + return s; +} + +// vision_processor's fallback for path. +export function fallback(path: string): unknown { + return setting(path).default; +} diff --git a/gui/frontend/src/lib/teams.svelte.ts b/gui/frontend/src/lib/teams.svelte.ts index 4739e53..07da059 100644 --- a/gui/frontend/src/lib/teams.svelte.ts +++ b/gui/frontend/src/lib/teams.svelte.ts @@ -8,6 +8,7 @@ import { without } from "./util"; import { config, type TeamOverride } from "./config.svelte"; import { network } from "./network.svelte"; +import { hostAlerts } from "./alerts/hostAlerts.svelte"; export type Color = "yellow" | "blue"; @@ -112,49 +113,12 @@ export type HeightIssue = | "customMissing" | "unsaved"; +// Decided by the host (gui/internal/alerts' HeightIssue), which raises a +// team_default_height alert per affected color. export function heightIssue(color: Color): HeightIssue | null { - const ref = network.state?.referee; - if (!ref || ref.heightsError) return null; + const alert = hostAlerts.list.find( + (a) => a.code === "team_default_height" && a.params?.["color"] === color, + ); - const team = teamName(color); - const slot = slotFor(color); - - // On a competition field nothing is playing until the game controller - // names a team; the game controller alert covers that. - if (!slot) return null; - - const own = choice(slot); - - if (!own) { - if (!team) return "noHeight"; - - return ref.heights?.[team] === undefined ? "notInTable" : null; - } - - if (own.name === undefined && own.height === undefined) { - return "customMissing"; - } - - return unsaved(slot) ? "unsaved" : null; -} - -// Whether the saved file lacks this slot's choice as it is now: an unsaved -// change to it, its fields, or a block above it. Paths are compared whole, -// since team names can contain dots ("N.R.G"): a prefix match would tie a -// team called "N" to N.R.G's changes. -function unsaved(slot: Slot): boolean { - const key = - "team" in slot - ? `teams.overrides.${slot.team}` - : `teams.by_color.${slot.color}`; - const paths = new Set([ - "teams", - "teams.overrides", - "teams.by_color", - key, - `${key}.name`, - `${key}.height`, - ]); - - return (config.state?.changes ?? []).some((c) => paths.has(c.path)); + return (alert?.params?.["issue"] as HeightIssue | undefined) ?? null; } diff --git a/gui/frontend/src/lib/teams.test.ts b/gui/frontend/src/lib/teams.test.ts index 238f2d4..2516151 100644 --- a/gui/frontend/src/lib/teams.test.ts +++ b/gui/frontend/src/lib/teams.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { blankField } from "./geometry.svelte"; import { config, type ConfigDocument, type Change } from "./config.svelte"; import { network, type NetworkState } from "./network.svelte"; +import { hostAlerts } from "./alerts/hostAlerts.svelte"; import { choice, heightIssue, @@ -14,7 +16,7 @@ import { function emptyDoc(): ConfigDocument { return { version: 1, - field: {}, + field: blankField(), optionalFieldLines: { goal2Goal: true, halfway: true, @@ -46,6 +48,7 @@ function unsavedChanges(...paths: string[]): void { changes: paths.map( (path): Change => ({ path, + jsonPath: path, before: null, after: null, section: "overview", @@ -71,23 +74,30 @@ describe("lab use (no competition field)", () => { expect(config.doc?.teams).toEqual({ byColor: { yellow: { height: 150 } } }); }); - it("warns that the default height applies with nothing set", () => { - expect(heightIssue("yellow")).toBe("noHeight"); - }); - - it("is fine with a saved custom height", () => { + it("starts a custom height as an empty choice", () => { const slot = slotFor("blue"); - if (slot) setCustomHeight(slot, 150); + if (slot) setFromTable(slot, false); - expect(heightIssue("blue")).toBeNull(); + expect(choice(slot)).toEqual({}); }); +}); - it("flags a custom height not entered yet", () => { - const slot = slotFor("blue"); - if (slot) setFromTable(slot, false); +// Which issue applies is the host's call (gui/internal/alerts' HeightIssue, +// tested there); the GUI reads it off the alert. +describe("heightIssue", () => { + it("reads the host's team_default_height alert for the color", () => { + hostAlerts.list = [ + { + id: "team-height-blue", + code: "team_default_height", + severity: "warning", + params: { color: "blue", team: "", issue: "customMissing" }, + message: "", + }, + ]; - expect(choice(slot)).toEqual({}); expect(heightIssue("blue")).toBe("customMissing"); + expect(heightIssue("yellow")).toBeNull(); }); }); @@ -98,7 +108,6 @@ describe("competition field", () => { it("waits for the game controller to name a team", () => { expect(slotFor("yellow")).toBeNull(); - expect(heightIssue("yellow")).toBeNull(); }); it("follows a team through a color switch", () => { @@ -112,13 +121,6 @@ describe("competition field", () => { expect(choice(slotFor("blue"))).toBeUndefined(); }); - it("flags a team not in the height table", () => { - hear({ yellow: "ER-Force", blue: "Custom FC" }); - - expect(heightIssue("yellow")).toBeNull(); - expect(heightIssue("blue")).toBe("notInTable"); - }); - it("borrows another table entry's height", () => { hear({ yellow: "ER Force", blue: "ER-Force" }); const yellow = slotFor("yellow"); @@ -128,18 +130,4 @@ describe("competition field", () => { "ER Force": { name: "ER-Force" }, }); }); - - it("matches unsaved changes by whole path, not prefix", () => { - hear({ yellow: "N", blue: "N.R.G" }); - for (const color of ["yellow", "blue"] as const) { - const slot = slotFor(color); - if (slot) setCustomHeight(slot, 140); - } - - // Only N.R.G's override is unsaved; "N" mustn't be tied to it. - unsavedChanges("teams.overrides.N.R.G.height"); - - expect(heightIssue("blue")).toBe("unsaved"); - expect(heightIssue("yellow")).toBeNull(); - }); }); diff --git a/gui/frontend/src/lib/text/alerts.ts b/gui/frontend/src/lib/text/alerts.ts index 82529b0..5d69fad 100644 --- a/gui/frontend/src/lib/text/alerts.ts +++ b/gui/frontend/src/lib/text/alerts.ts @@ -70,4 +70,5 @@ const CAMERA_WARNING_TITLES: Record = { seed_aspect_mismatch: "line corners don't match the resolution", seed_rescalable: "line corners need rescaling", seed_resolution_unknown: "line corners have no recorded resolution", + unknown_setting: "setting has no effect", }; diff --git a/gui/frontend/src/lib/text/links.ts b/gui/frontend/src/lib/text/links.ts index 2360191..18b9caa 100644 --- a/gui/frontend/src/lib/text/links.ts +++ b/gui/frontend/src/lib/text/links.ts @@ -2,6 +2,8 @@ // place. Reference them as links.; don't paste URLs into components. // export const links = { + // The host's own API reference (OpenAPI, rendered by Redoc), so relative. + apiDocs: "/api/docs", repository: "https://github.com/RoboCup-SSL/ssl-vision-processor", issues: "https://github.com/RoboCup-SSL/ssl-vision-processor/issues", }; diff --git a/gui/frontend/src/lib/wizard/steps/WizardDimensions.svelte b/gui/frontend/src/lib/wizard/steps/WizardDimensions.svelte index bf5e157..9378e2a 100644 --- a/gui/frontend/src/lib/wizard/steps/WizardDimensions.svelte +++ b/gui/frontend/src/lib/wizard/steps/WizardDimensions.svelte @@ -35,9 +35,7 @@ // People measure a half field with a tape measure, not a full one -- there // is no full-field number to enter directly, so ask for the half and double // it, the same way FieldEditor's half/full toggle does. - let halfLength = $derived( - Math.round((wizard.draft.field.fieldLength ?? 0) / 2), - ); + let halfLength = $derived(Math.round(wizard.draft.field.fieldLength / 2)); function setHalfLength(value: number): void { wizard.draft.field.fieldLength = value * 2; @@ -52,7 +50,7 @@ {#if wizard.fieldLayout === "half"}
Half length (full: {wizard.draft.field.fieldLength}mm)
  • - Field: {wizard.draft.field.fieldLength ?? 0}mm x {wizard.draft.field - .fieldWidth ?? 0}mm + Field: {wizard.draft.field.fieldLength}mm x {wizard.draft.field + .fieldWidth}mm
  • Markings: diff --git a/gui/frontend/src/lib/wizard/wizard.svelte.ts b/gui/frontend/src/lib/wizard/wizard.svelte.ts index 617aa96..99d6131 100644 --- a/gui/frontend/src/lib/wizard/wizard.svelte.ts +++ b/gui/frontend/src/lib/wizard/wizard.svelte.ts @@ -7,10 +7,11 @@ // confirming there is what copies `draft` into the host's working document // (see finishWizard below), where it applies live -- persisting it to disk is // the normal Save in the settings menu. -import type { - FieldConfig, - OptionalFieldLines, - FieldPreset, +import { + blankField, + type FieldConfig, + type OptionalFieldLines, + type FieldPreset, } from "../geometry.svelte"; import { config } from "../config.svelte"; import { applyPlan, cameraCount, planCount } from "../cameraLayout"; @@ -43,7 +44,7 @@ export const wizard = $state<{ fieldLayout: "full", cameraCount: 1, draft: { - field: {}, + field: blankField(), optionalFieldLines: { goal2Goal: false, halfway: false, diff --git a/gui/frontend/src/lib/wrapper-bus.ts b/gui/frontend/src/lib/wrapper-bus.ts index d69f047..7cb73a8 100644 --- a/gui/frontend/src/lib/wrapper-bus.ts +++ b/gui/frontend/src/lib/wrapper-bus.ts @@ -13,9 +13,11 @@ interface ClientMessage { topic: string; } +// An error answers a message the host couldn't act on (an unknown topic, for +// one), in the same shape as the HTTP API's errors. type ServerMessage = | { topic: string; data: unknown } - | { error: string; topic?: string }; + | { error: string; message: string; topic?: string }; class WrapperBus { private socket: WebSocket | null = null; diff --git a/gui/frontend/tsconfig.app.json b/gui/frontend/tsconfig.app.json index b5f2f58..65e0909 100644 --- a/gui/frontend/tsconfig.app.json +++ b/gui/frontend/tsconfig.app.json @@ -15,6 +15,7 @@ "allowJs": true, "checkJs": true, "moduleDetection": "force", + "resolveJsonModule": true, "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, diff --git a/gui/frontend/tsconfig.node.json b/gui/frontend/tsconfig.node.json index d3c52ea..c721ff1 100644 --- a/gui/frontend/tsconfig.node.json +++ b/gui/frontend/tsconfig.node.json @@ -20,5 +20,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "playwright.config.ts", "e2e/**/*.ts"] } diff --git a/gui/go.mod b/gui/go.mod index b6da386..c57bf7b 100644 --- a/gui/go.mod +++ b/gui/go.mod @@ -20,10 +20,14 @@ require ( github.com/abema/go-mp4 v1.7.3 // indirect github.com/google/uuid v1.3.0 // indirect github.com/pion/randutil v0.1.0 // indirect + github.com/swaggest/refl v1.4.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) require ( github.com/libp2p/go-reuseport v0.4.0 + github.com/swaggest/jsonschema-go v0.3.79 + github.com/swaggest/openapi-go v0.2.61 golang.org/x/net v0.55.0 golang.org/x/sys v0.45.0 ) diff --git a/gui/go.sum b/gui/go.sum index e5f0bba..2ec7c26 100644 --- a/gui/go.sum +++ b/gui/go.sum @@ -2,6 +2,10 @@ github.com/abema/go-mp4 v1.7.3 h1:Aap+2vZl5KQrSGD9quYhLu4pWGXelFVUeFCa8IJaRXY= github.com/abema/go-mp4 v1.7.3/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= github.com/bluenviron/mediacommon/v2 v2.9.5 h1:6r1vq9qFH5AUUjPOPxRd00BhBNAg1EcmYNvwmpzAibo= github.com/bluenviron/mediacommon/v2 v2.9.5/go.mod h1:1J4QckclKIksdI9+7C+PJXVBkpO/JYWn7/h+wOYq+7E= +github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= +github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -12,6 +16,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -29,11 +35,25 @@ github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TB github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= +github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= +github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= +github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= @@ -54,5 +74,7 @@ gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gui/internal/alerts/alerts.go b/gui/internal/alerts/alerts.go new file mode 100644 index 0000000..c100d84 --- /dev/null +++ b/gui/internal/alerts/alerts.go @@ -0,0 +1,347 @@ +// Package alerts decides every problem the host can see right now: which +// ones, how serious, and the facts behind each. Clients word and link them +// themselves, keyed by Code; Message is a plain English fallback for those +// that don't, such as logs and scripts. +package alerts + +import ( + "cmp" + "fmt" + "slices" + "strconv" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee" +) + +// Severities, most serious first. +const ( + Error = "error" // broken: something isn't working + Warning = "warning" // likely wrong: fix before a match + Caution = "caution" // worth knowing, often fine +) + +// Codes. A camera warning from config's CameraStatus keeps its own code. +const ( + CodeRenderFailed = "render_failed" + CodeCameraIDConflict = "camera_id_conflict" + CodeRegionUncovered = "region_uncovered" + CodeCameraUnplaced = "camera_unplaced" + CodeFileChanged = "file_changed" + CodeSocketProblem = "socket_problem" + CodeSocketSilent = "socket_silent" + CodeHeightsUnreadable = "heights_unreadable" + CodeTeamDefaultHeight = "team_default_height" + CodeLoopbackMulticast = "loopback_multicast_off" +) + +// Alert is one problem. +type Alert struct { + // ID is stable while the problem lasts, for keyed lists. + ID string `json:"id"` + Code string `json:"code"` + Severity string `json:"severity" enum:"error,warning,caution"` + CameraID *int `json:"cameraId,omitempty"` + // Params are the facts the alert is about, named per code. + Params map[string]any `json:"params,omitempty"` + Message string `json:"message"` +} + +// cameraWarnings are config.CameraStatus warning codes by severity. Codes +// not listed are cautions. +var cameraWarnings = map[string]string{ + config.WarningFieldChanged: Warning, + config.WarningCalibrationAspectMismatch: Warning, + config.WarningSeedStale: Warning, + config.WarningSeedAspectMismatch: Warning, + config.WarningUnknownSetting: Warning, + config.WarningSeedRescalable: Caution, + config.WarningSeedResolutionUnknown: Caution, +} + +// Codes is every alert code Collect can raise, sorted: its own and the +// camera warning codes it passes through. +func Codes() []string { + codes := append([]string{ + CodeRenderFailed, CodeCameraIDConflict, CodeRegionUncovered, CodeCameraUnplaced, CodeFileChanged, + CodeSocketProblem, CodeSocketSilent, CodeHeightsUnreadable, CodeTeamDefaultHeight, CodeLoopbackMulticast, + }, config.WarningCodes...) + + slices.Sort(codes) + + return codes +} + +// Network is what the host's sockets report. +type Network struct { + Vision multicast.Status + GC multicast.Status + Sources []detections.Source + Referee referee.State + Loopback string + LoopbackMulticast bool +} + +// Collect lists every alert for the working document doc, the store's state, +// and the network, most serious first. +func Collect(doc config.Document, state config.State, net Network) []Alert { + out := []Alert{} + + add := func(a Alert) { out = append(out, a) } + + for _, id := range sortedKeys(state.RenderErrors) { + add(Alert{ + ID: "render-" + strconv.Itoa(id), Code: CodeRenderFailed, Severity: Error, CameraID: &id, + Params: map[string]any{"error": state.RenderErrors[id]}, + Message: fmt.Sprintf("Camera %d's config.yml couldn't be written: %s", id, state.RenderErrors[id]), + }) + } + + for id := range doc.CameraCount() { + receiving := receivingAddresses(net.Sources, id) + + if len(receiving) > 1 { + add(Alert{ + ID: "conflict-" + strconv.Itoa(id), Code: CodeCameraIDConflict, Severity: Error, CameraID: ptr(id), + Params: map[string]any{"addresses": receiving}, + Message: fmt.Sprintf("Camera %d is being sent from %d places at once: %v", id, len(receiving), receiving), + }) + } + + if !hasCamera(doc, id) { + add(Alert{ + ID: "uncovered-" + strconv.Itoa(id), Code: CodeRegionUncovered, Severity: Warning, + Params: map[string]any{"slot": id}, + Message: fmt.Sprintf("No camera covers region %d of %d.", id, doc.CameraCount()), + }) + } + } + + for _, s := range net.Sources { + if hasCamera(doc, int(s.CameraID)) { + continue + } + + add(Alert{ + ID: fmt.Sprintf("unplaced-%d-%s", s.CameraID, s.Address), Code: CodeCameraUnplaced, Severity: Warning, + Params: map[string]any{"slot": s.CameraID, "address": s.Address}, + Message: fmt.Sprintf("Camera %d is sending from %s but isn't in the layout.", s.CameraID, s.Address), + }) + } + + for _, status := range state.Cameras { + for _, w := range status.Warnings { + add(Alert{ + ID: fmt.Sprintf("camera-%d-%s", status.CameraID, w.Code), Code: w.Code, + Severity: cmp.Or(cameraWarnings[w.Code], Caution), CameraID: ptr(status.CameraID), + Message: w.Message, + }) + } + } + + if ext := state.External; ext != nil { + add(Alert{ + ID: "file-changed", Code: CodeFileChanged, Severity: Warning, + Params: map[string]any{"path": state.Path, "error": ext.Error, "changes": len(ext.Changes)}, + Message: fmt.Sprintf("%s was changed outside the GUI.", state.Path), + }) + } + + for _, socket := range []struct { + kind string + status multicast.Status + }{{"vision", net.Vision}, {"gc", net.GC}} { + params := map[string]any{"socket": socket.kind, "address": socket.status.Address} + + switch { + case socket.status.Problem != "": + params["problem"] = socket.status.Problem + add(Alert{ + ID: "socket-" + socket.kind, Code: CodeSocketProblem, Severity: Error, Params: params, + Message: fmt.Sprintf("The %s socket on %s isn't open: %s", socket.kind, socket.status.Address, socket.status.Problem), + }) + case !socket.status.Receiving: + // Off a competition field a game controller is optional: lab use + // often runs without one. On one, a match can't run without it. + if socket.kind == "gc" && !doc.CompetitionField { + continue + } + + severity := Warning + if socket.kind == "gc" { + severity = Error + } + + params["everHeard"] = socket.status.LastHeard != nil + add(Alert{ + ID: "silent-" + socket.kind, Code: CodeSocketSilent, Severity: severity, Params: params, + Message: fmt.Sprintf("Nothing is arriving on the %s socket (%s).", socket.kind, socket.status.Address), + }) + } + } + + if ref := net.Referee; ref.HeightsError != "" { + add(Alert{ + ID: "heights-unreadable", Code: CodeHeightsUnreadable, Severity: Warning, + Params: map[string]any{"file": ref.HeightsFile, "error": ref.HeightsError}, + Message: fmt.Sprintf("The robot height table %s can't be read: %s", ref.HeightsFile, ref.HeightsError), + }) + } else { + for _, color := range []string{"yellow", "blue"} { + issue := HeightIssue(doc, state, net, color) + if issue == "" { + continue + } + + severity := Warning + if doc.CompetitionField { + severity = Error + } + + team := teamName(net, color) + add(Alert{ + ID: "team-height-" + color, Code: CodeTeamDefaultHeight, Severity: severity, + Params: map[string]any{"color": color, "team": team, "issue": issue}, + Message: fmt.Sprintf("The %s team's robots get vision_processor's default height: %s.", color, issueText[issue]), + }) + } + } + + if !net.LoopbackMulticast { + add(Alert{ + ID: "loopback-multicast", Code: CodeLoopbackMulticast, Severity: Caution, + Params: map[string]any{"interface": net.Loopback}, + Message: fmt.Sprintf("Multicast is off on %s, so vision_processor on this machine can't be heard here.", net.Loopback), + }) + } + + rank := map[string]int{Error: 0, Warning: 1, Caution: 2} + slices.SortStableFunc(out, func(a, b Alert) int { return rank[a.Severity] - rank[b.Severity] }) + + return out +} + +// Height issues: why a color's robots would get vision_processor's default +// height. +const ( + IssueNotInTable = "notInTable" // the game controller's team isn't in the table, no choice + IssueNoHeight = "noHeight" // no game controller team and no choice for the color + IssueCustomMissing = "customMissing" // a custom height chosen but not entered yet + IssueUnsaved = "unsaved" // a choice not written to disk yet +) + +var issueText = map[string]string{ + IssueNotInTable: "the team isn't in the height table", + IssueNoHeight: "no team is named and no height is set", + IssueCustomMissing: "a custom height is chosen but not entered", + IssueUnsaved: "the height choice isn't saved yet", +} + +// HeightIssue is why color's robots would get vision_processor's default +// height, or "" if they have a known one. +func HeightIssue(doc config.Document, state config.State, net Network, color string) string { + team := teamName(net, color) + + // On a competition field choices are kept by team, and nothing is playing + // until the game controller names one; the game controller alert covers + // that. Off one they're kept by color. + var key string + + var own *config.TeamOverride + + switch { + case doc.CompetitionField && team == "": + return "" + case doc.CompetitionField: + key = "teams.overrides." + team + + if doc.Teams != nil { + if o, ok := doc.Teams.Overrides[team]; ok { + own = &o + } + } + default: + key = "teams.by_color." + color + + if doc.Teams != nil && doc.Teams.ByColor != nil { + own = map[string]*config.TeamOverride{"yellow": doc.Teams.ByColor.Yellow, "blue": doc.Teams.ByColor.Blue}[color] + } + } + + switch { + case own == nil && team == "": + return IssueNoHeight + case own == nil: + if _, ok := net.Referee.Heights[team]; !ok { + return IssueNotInTable + } + + return "" + case own.Name == "" && own.Height == nil: + return IssueCustomMissing + case unsaved(state, key): + return IssueUnsaved + } + + return "" +} + +// unsaved is whether the saved file lacks the choice at key as it is now. +// Paths are compared whole, since team names can contain dots ("N.R.G"). +func unsaved(state config.State, key string) bool { + paths := []string{"teams", "teams.overrides", "teams.by_color", key, key + ".name", key + ".height"} + + for _, c := range state.Changes { + if slices.Contains(paths, c.Path) { + return true + } + } + + return false +} + +// teamName is the team the game controller puts in color, or "" if none, or +// if the game controller has gone quiet (its last names may be a past +// match's). +func teamName(net Network, color string) string { + if !net.GC.Receiving || net.Referee.Heard == nil { + return "" + } + + if color == "yellow" { + return net.Referee.Yellow.Name + } + + return net.Referee.Blue.Name +} + +func receivingAddresses(sources []detections.Source, id int) []string { + out := []string{} + + for _, s := range sources { + if int(s.CameraID) == id && s.Receiving { + out = append(out, s.Address) + } + } + + return out +} + +func hasCamera(doc config.Document, id int) bool { + return slices.ContainsFunc(doc.Cameras, func(c config.Camera) bool { return c.CameraID == id }) +} + +func sortedKeys(m map[int]string) []int { + keys := make([]int, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys +} + +func ptr(v int) *int { return &v } diff --git a/gui/internal/alerts/alerts_test.go b/gui/internal/alerts/alerts_test.go new file mode 100644 index 0000000..dc49222 --- /dev/null +++ b/gui/internal/alerts/alerts_test.go @@ -0,0 +1,158 @@ +package alerts + +import ( + "testing" + "time" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee" +) + +// healthy is a two-camera lab setup with nothing wrong. +func healthy() (config.Document, config.State, Network) { + now := time.Now() + doc := config.Document{Cameras: []config.Camera{{CameraID: 0}, {CameraID: 1}}} + + return doc, config.State{Path: "vision.yml"}, Network{ + Vision: multicast.Status{Address: "224.5.23.2:10006", Receiving: true}, + GC: multicast.Status{Address: "224.5.23.1:10003", Receiving: true}, + Sources: []detections.Source{{CameraID: 0, Address: "10.0.0.1", Receiving: true}, {CameraID: 1, Address: "10.0.0.2", Receiving: true}}, + Referee: referee.State{Heard: &now, Yellow: referee.Team{Name: "A"}, Blue: referee.Team{Name: "B"}, Heights: map[string]float64{"A": 150, "B": 140}}, + LoopbackMulticast: true, + } +} + +func codes(alerts []Alert) map[string]string { + out := map[string]string{} + for _, a := range alerts { + out[a.ID] = a.Code + "/" + a.Severity + } + + return out +} + +func TestCollectHealthy(t *testing.T) { + if got := Collect(healthy()); len(got) != 0 { + t.Errorf("Collect = %+v, want nothing", got) + } +} + +func TestCollect(t *testing.T) { + for name, tc := range map[string]struct { + mutate func(*config.Document, *config.State, *Network) + id string + want string + }{ + "conflict": {func(_ *config.Document, _ *config.State, n *Network) { + n.Sources = append(n.Sources, detections.Source{CameraID: 1, Address: "10.0.0.9", Receiving: true}) + }, "conflict-1", "camera_id_conflict/error"}, + "uncovered": {func(d *config.Document, _ *config.State, _ *Network) { + d.Cameras = d.Cameras[:1] + d.Layout = &config.Layout{CameraCount: 2} + }, "uncovered-1", "region_uncovered/warning"}, + "unplaced": {func(_ *config.Document, _ *config.State, n *Network) { + n.Sources = append(n.Sources, detections.Source{CameraID: 3, Address: "10.0.0.3", Receiving: true}) + }, "unplaced-3-10.0.0.3", "camera_unplaced/warning"}, + "camera warning": {func(_ *config.Document, s *config.State, _ *Network) { + s.Cameras = []config.CameraStatus{{CameraID: 0, Warnings: []config.Warning{{Code: "seed_stale"}}}} + }, "camera-0-seed_stale", "seed_stale/warning"}, + "unlisted camera warning": {func(_ *config.Document, s *config.State, _ *Network) { + s.Cameras = []config.CameraStatus{{CameraID: 0, Warnings: []config.Warning{{Code: "something_new"}}}} + }, "camera-0-something_new", "something_new/caution"}, + "render failed": {func(_ *config.Document, s *config.State, _ *Network) { + s.RenderErrors = map[int]string{1: "permission denied"} + }, "render-1", "render_failed/error"}, + "file changed": {func(_ *config.Document, s *config.State, _ *Network) { + s.External = &config.ExternalState{} + }, "file-changed", "file_changed/warning"}, + "socket problem": {func(_ *config.Document, _ *config.State, n *Network) { + n.Vision.Problem = "no usable network interface" + }, "socket-vision", "socket_problem/error"}, + "vision silent": {func(_ *config.Document, _ *config.State, n *Network) { + n.Vision.Receiving = false + }, "silent-vision", "socket_silent/warning"}, + "gc silent on a competition field": {func(d *config.Document, _ *config.State, n *Network) { + d.CompetitionField = true + n.GC.Receiving = false + }, "silent-gc", "socket_silent/error"}, + "team not in table": {func(_ *config.Document, _ *config.State, n *Network) { + n.Referee.Yellow.Name = "C" + }, "team-height-yellow", "team_default_height/warning"}, + "heights unreadable": {func(_ *config.Document, _ *config.State, n *Network) { + n.Referee.HeightsError = "no such file" + }, "heights-unreadable", "heights_unreadable/warning"}, + "loopback": {func(_ *config.Document, _ *config.State, n *Network) { + n.LoopbackMulticast = false + n.Loopback = "lo" + }, "loopback-multicast", "loopback_multicast_off/caution"}, + } { + t.Run(name, func(t *testing.T) { + doc, state, net := healthy() + tc.mutate(&doc, &state, &net) + + if got := codes(Collect(doc, state, net)); got[tc.id] != tc.want { + t.Errorf("alerts = %v, want %s = %s", got, tc.id, tc.want) + } + }) + } +} + +func TestGCSilenceOffACompetitionFieldIsFine(t *testing.T) { + doc, state, net := healthy() + net.GC.Receiving = false + + // No game controller also means no team names: each color has no height. + got := codes(Collect(doc, state, net)) + if _, ok := got["silent-gc"]; ok { + t.Errorf("alerts = %v, want no silent-gc off a competition field", got) + } +} + +func TestHeightIssue(t *testing.T) { + custom := 120.0 + + for name, tc := range map[string]struct { + mutate func(*config.Document, *config.State, *Network) + want string + }{ + "in table": {func(*config.Document, *config.State, *Network) {}, ""}, + "no gc": {func(_ *config.Document, _ *config.State, n *Network) { n.GC.Receiving = false }, IssueNoHeight}, + "by color, saved": {func(d *config.Document, _ *config.State, _ *Network) { + d.Teams = &config.Teams{ByColor: &config.ColorOverrides{Yellow: &config.TeamOverride{Height: &custom}}} + }, ""}, + "by color, unsaved": {func(d *config.Document, s *config.State, _ *Network) { + d.Teams = &config.Teams{ByColor: &config.ColorOverrides{Yellow: &config.TeamOverride{Height: &custom}}} + s.Changes = []config.Change{{Path: "teams.by_color.yellow.height"}} + }, IssueUnsaved}, + "custom missing": {func(d *config.Document, _ *config.State, _ *Network) { + d.Teams = &config.Teams{ByColor: &config.ColorOverrides{Yellow: &config.TeamOverride{}}} + }, IssueCustomMissing}, + "competition, by team": {func(d *config.Document, _ *config.State, n *Network) { + d.CompetitionField = true + n.Referee.Yellow.Name = "N.R.G" + d.Teams = &config.Teams{Overrides: map[string]config.TeamOverride{"N.R.G": {Name: "A"}}} + }, ""}, + // Only N.R.G's override is unsaved; "N" mustn't be tied to it. + "competition, unsaved matched by whole path": {func(d *config.Document, s *config.State, n *Network) { + d.CompetitionField = true + n.Referee.Yellow.Name = "N" + d.Teams = &config.Teams{Overrides: map[string]config.TeamOverride{"N": {Height: &custom}, "N.R.G": {Height: &custom}}} + s.Changes = []config.Change{{Path: "teams.overrides.N.R.G.height"}} + }, ""}, + "competition, no team named": {func(d *config.Document, _ *config.State, n *Network) { + d.CompetitionField = true + n.Referee.Heard = nil + }, ""}, + } { + t.Run(name, func(t *testing.T) { + doc, state, net := healthy() + tc.mutate(&doc, &state, &net) + + if got := HeightIssue(doc, state, net, "yellow"); got != tc.want { + t.Errorf("HeightIssue = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/gui/internal/alerts/codes_test.go b/gui/internal/alerts/codes_test.go new file mode 100644 index 0000000..c4f0de6 --- /dev/null +++ b/gui/internal/alerts/codes_test.go @@ -0,0 +1,59 @@ +package alerts + +import ( + "encoding/json" + "flag" + "os" + "slices" + "testing" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config" +) + +var update = flag.Bool("update", false, "rewrite the frontend's alert-codes.json from Codes()") + +// codesFile is where the frontend's tests read the codes from, to check the +// GUI words every one (alerts.test.ts). +const codesFile = "../../frontend/src/lib/alerts/alert-codes.json" + +func TestCodesFileIsCurrent(t *testing.T) { + want, err := json.MarshalIndent(Codes(), "", " ") + if err != nil { + t.Fatal(err) + } + + want = append(want, '\n') + + if *update { + if err := os.WriteFile(codesFile, want, 0o644); err != nil { + t.Fatal(err) + } + } + + got, err := os.ReadFile(codesFile) + if err != nil { + t.Fatalf("%v (regenerate with go test ./internal/alerts -run Codes -update)", err) + } + + if string(got) != string(want) { + t.Errorf("%s is out of date; regenerate with go test ./internal/alerts -run Codes -update", codesFile) + } +} + +// A camera warning missing from cameraWarnings would still show, but always +// as a caution. +func TestEveryCameraWarningHasASeverity(t *testing.T) { + for _, code := range config.WarningCodes { + if _, ok := cameraWarnings[code]; !ok { + t.Errorf("camera warning %q has no severity in cameraWarnings", code) + } + } + + if len(cameraWarnings) != len(config.WarningCodes) { + t.Errorf("cameraWarnings has %d codes, config.WarningCodes %d", len(cameraWarnings), len(config.WarningCodes)) + } + + if !slices.IsSorted(Codes()) { + t.Error("Codes() isn't sorted") + } +} diff --git a/gui/internal/api/api.go b/gui/internal/api/api.go new file mode 100644 index 0000000..3331484 --- /dev/null +++ b/gui/internal/api/api.go @@ -0,0 +1,509 @@ +// Package api registers the host's HTTP routes with their request and +// response types, and builds the OpenAPI 3.1 spec from those same types. +// +// A handler takes one input struct and returns one output value. Input fields +// tagged path:, query: or header: are parameters; one field tagged +// body:"" is the request body. Output fields tagged header: +// (and json:"-") are response headers; the rest is the JSON body. Because the +// spec is reflected from the handler's own types, it can't describe types the +// handler doesn't use. Field and type descriptions come from Go doc +// comments, via `go generate` (comments_gen.go). +package api + +//go:generate go run ./gen + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "runtime" + "slices" + "strings" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson" + "github.com/swaggest/jsonschema-go" + "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi31" +) + +// API is a ServeMux plus the spec of what's registered on it. +type API struct { + mux *http.ServeMux + r *openapi31.Reflector + onError func(http.ResponseWriter, error) + declared map[string][]string +} + +// Info is the spec's title, version and introduction. +type Info struct { + Title string + Version string + Description string +} + +// New returns an API registering on mux. onError writes a handler's error as +// an httpjson error body, mapping domain errors to codes. +func New(mux *http.ServeMux, info Info, onError func(http.ResponseWriter, error)) *API { + r := openapi31.NewReflector() + r.Spec.Info.WithTitle(info.Title).WithVersion(info.Version).WithDescription(info.Description) + r.JSONSchemaReflector().DefaultOptions = append(r.JSONSchemaReflector().DefaultOptions, + jsonschema.InterceptSchema(describe), + // A slice or map the host sends is [] or {}, never null: Go's nil + // isn't part of the API (the cmd tests check responses for it). + jsonschema.InterceptNullability(func(p jsonschema.InterceptNullabilityParams) { + if p.NullAdded && (p.Type.Kind() == reflect.Slice || p.Type.Kind() == reflect.Map) { + *p.Schema = p.OrigSchema + } + }), + // Types in package main get their bare name, not the module path's. + jsonschema.InterceptDefName(func(t reflect.Type, name string) string { + if canonical(t.PkgPath()) == mainPackage && t.Name() != "" { + return strings.ToUpper(t.Name()[:1]) + t.Name()[1:] + } + + return name + }), + ) + + return &API{mux: mux, r: r, onError: onError, declared: map[string][]string{}} +} + +// Doc is a route's documentation. The summary is its first sentence; the +// rest of Description follows it. +type Doc struct { + Summary string + Description string + Tags []string + // Errors are the httpjson codes the route can return, besides internal. + Errors []string + + id string +} + +// withHandlerComment fills an empty Summary and Description from h's Go doc +// comment, so the prose lives in one place: the first sentence is the +// summary, the rest the description. +func (d Doc) withHandlerComment(h any) Doc { + name := canonical(strings.TrimSuffix(runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name(), "-fm")) + d.id = name[strings.LastIndex(name, ".")+1:] + + summary, rest, _ := strings.Cut(comments[name], ". ") + + if d.Summary == "" { + d.Summary = strings.TrimSuffix(summary, ".") + } + + if d.Description == "" { + d.Description = rest + } + + return d +} + +// SchemaFor documents values of type t with schema instead of reflecting +// them, for types whose shape lives elsewhere (config.Settings, from the +// settings schema). +func (a *API) SchemaFor(t reflect.Type, schema jsonschema.Schema) { + a.r.JSONSchemaReflector().DefaultOptions = append(a.r.JSONSchemaReflector().DefaultOptions, + jsonschema.InterceptSchema(func(p jsonschema.InterceptSchemaParams) (bool, error) { + if !p.Value.IsValid() { + return false, nil + } + + vt := p.Value.Type() + for vt.Kind() == reflect.Pointer { + vt = vt.Elem() + } + + if vt == t { + *p.Schema = schema + + return true, nil + } + + return false, nil + }), + ) +} + +// Route registers h for pattern ("METHOD /path/{param}") and documents it. +// It panics on a pattern or type it can't document, which is a programming +// error found at startup. +func Route[In, Out any](a *API, pattern string, doc Doc, h func(*http.Request, In) (Out, error)) { + method, path, ok := strings.Cut(pattern, " ") + if !ok { + panic("api: pattern needs a method: " + pattern) + } + + doc = doc.withHandlerComment(h) + + in, err := inputOf(reflect.TypeFor[In]()) + if err != nil { + panic(fmt.Sprintf("api: %s: %v", pattern, err)) + } + + if err := a.document(method, path, doc, in, reflect.TypeFor[Out]()); err != nil { + panic(fmt.Sprintf("api: %s: %v", pattern, err)) + } + + a.declared[pattern] = doc.Errors + + a.mux.Handle(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if CheckDeclared != nil { + sniff := &sniffer{ResponseWriter: w} + w = sniff + + defer func() { sniff.check(pattern, doc.Errors) }() + } + + var req In + + if !in.decode(w, r, reflect.ValueOf(&req).Elem()) { + return + } + + resp, err := h(r, req) + if err != nil { + a.onError(w, err) + + return + } + + writeOutput(w, resp) + })) +} + +// CheckDeclared, when set (by tests), is called with each error code a +// Route answers that its Doc doesn't declare. +var CheckDeclared func(pattern, code string) + +// sniffer keeps an error response's body for CheckDeclared. +type sniffer struct { + http.ResponseWriter + status int + body []byte +} + +func (s *sniffer) WriteHeader(status int) { + s.status = status + s.ResponseWriter.WriteHeader(status) +} + +func (s *sniffer) Write(b []byte) (int, error) { + if s.status >= 400 { + s.body = append(s.body, b...) + } + + return s.ResponseWriter.Write(b) +} + +func (s *sniffer) check(pattern string, declared []string) { + var body httpjson.ErrorBody + if s.status < 400 || json.Unmarshal(s.body, &body) != nil { + return + } + + if body.Error != httpjson.CodeInternal && !slices.Contains(declared, body.Error) { + CheckDeclared(pattern, body.Error) + } +} + +// RawResponse is a response a Raw route writes itself. +type RawResponse struct { + Status int + ContentType string // empty for none + Description string +} + +// Raw registers h for a route whose responses aren't one JSON value (an +// image, a WebSocket upgrade) and documents it: In's parameters as for Route, +// which h reads itself, and responses. Errors are JSON as usual. +func Raw[In any](a *API, pattern string, doc Doc, h http.Handler, responses ...RawResponse) { + method, path, ok := strings.Cut(pattern, " ") + if !ok { + panic("api: pattern needs a method: " + pattern) + } + + in, err := inputOf(reflect.TypeFor[In]()) + if err != nil { + panic(fmt.Sprintf("api: %s: %v", pattern, err)) + } + + if err := a.documentRaw(method, path, doc, in, responses); err != nil { + panic(fmt.Sprintf("api: %s: %v", pattern, err)) + } + + a.declared[pattern] = doc.Errors + a.mux.Handle(pattern, h) +} + +func (a *API) documentRaw(method, path string, doc Doc, in input, responses []RawResponse) error { + oc, err := a.r.NewOperationContext(method, path) + if err != nil { + return err + } + + oc.SetSummary(doc.Summary) + oc.SetDescription(doc.Description) + oc.SetTags(doc.Tags...) + + if in.params != nil { + oc.AddReqStructure(reflect.New(in.params).Interface()) + } + + for _, resp := range responses { + oc.AddRespStructure(nil, openapi.WithHTTPStatus(resp.Status), openapi.WithCustomize(func(cor openapi.ContentOrReference) { + r, ok := cor.(*openapi31.ResponseOrReference) + if !ok { + return + } + + if r.Response == nil { + r.Response = &openapi31.Response{} + } + + r.Response.Description = resp.Description + if resp.ContentType != "" { + r.Response.Content = map[string]openapi31.MediaType{ + resp.ContentType: {Schema: map[string]any{"type": "string", "format": "binary"}}, + } + } + })) + } + + for status, codes := range errorsByStatus(doc.Errors) { + oc.AddRespStructure(new(httpjson.ErrorBody), + openapi.WithHTTPStatus(status), + func(cu *openapi.ContentUnit) { cu.Description = "error: " + strings.Join(codes, ", ") }, + ) + } + + return a.r.AddOperation(oc) +} + +// Declared is the error codes each route documents, by pattern, for tests +// that check a route only returns those. +func (a *API) Declared() map[string][]string { return a.declared } + +// Spec is the OpenAPI document as JSON. +func (a *API) Spec() ([]byte, error) { + return json.MarshalIndent(a.r.Spec, "", " ") +} + +func (a *API) document(method, path string, doc Doc, in input, out reflect.Type) error { + oc, err := a.r.NewOperationContext(method, path) + if err != nil { + return err + } + + oc.SetSummary(doc.Summary) + oc.SetDescription(doc.Description) + oc.SetID(doc.id) + oc.SetTags(doc.Tags...) + + if in.params != nil { + oc.AddReqStructure(reflect.New(in.params).Interface()) + } + + if in.body >= 0 || in.whole { + types, bodyDoc := in.bodyTypes, in.bodyDoc + // Reflected as JSON, then filed under the body's own media types. + oc.AddReqStructure(reflect.New(in.bodyType).Interface(), + openapi.WithContentType("application/json"), + openapi.WithCustomize(func(cor openapi.ContentOrReference) { + rb, ok := cor.(*openapi31.RequestBodyOrReference) + if !ok || rb.RequestBody == nil { + return + } + + media := rb.RequestBody.Content["application/json"] + notNull(media.Schema) + + rb.RequestBody.Content = map[string]openapi31.MediaType{} + + for _, t := range types { + rb.RequestBody.Content[t] = media + } + + rb.RequestBody.WithRequired(true) + + if bodyDoc != "" { + rb.RequestBody.WithDescription(bodyDoc) + } + }), + ) + } + + oc.AddRespStructure(reflect.New(out).Interface(), openapi.WithHTTPStatus(http.StatusOK), + openapi.WithCustomize(func(cor openapi.ContentOrReference) { + if r, ok := cor.(*openapi31.ResponseOrReference); ok && r.Response != nil { + for _, media := range r.Response.Content { + notNull(media.Schema) + } + } + }), + ) + + for status, codes := range errorsByStatus(doc.Errors) { + oc.AddRespStructure(new(httpjson.ErrorBody), + openapi.WithHTTPStatus(status), + func(cu *openapi.ContentUnit) { cu.Description = "error: " + strings.Join(codes, ", ") }, + ) + } + + return a.r.AddOperation(oc) +} + +// notNull drops "null" from a top-level body schema's types: the host never +// sends or accepts a null body, though Go reflects a slice, map or pointer as +// nullable. +func notNull(schema map[string]any) { + t, ok := schema["type"].([]any) + if !ok || !slices.Contains(t, any("null")) { + return + } + + t = slices.DeleteFunc(slices.Clone(t), func(v any) bool { return v == "null" }) + if len(t) == 1 { + schema["type"] = t[0] + } else { + schema["type"] = t + } +} + +// errorsByStatus groups codes by the status httpjson answers them with, plus +// internal under 500, which any route can return. +func errorsByStatus(codes []string) map[int][]string { + out := map[int][]string{http.StatusInternalServerError: {httpjson.CodeInternal}} + + for _, c := range codes { + s := httpjson.StatusOf(c) + if !slices.Contains(out[s], c) { + out[s] = append(out[s], c) + } + } + + return out +} + +func writeOutput(w http.ResponseWriter, out any) { + v := reflect.ValueOf(out) + + if v.Kind() == reflect.Struct { + for i := range v.NumField() { + if name := v.Type().Field(i).Tag.Get("header"); name != "" { + w.Header().Set(name, fmt.Sprint(v.Field(i).Interface())) + } + } + } + + httpjson.Write(w, http.StatusOK, out) +} + +// describe sets a struct type's and its fields' descriptions from their Go +// doc comments, once the schema has been reflected. +func describe(p jsonschema.InterceptSchemaParams) (bool, error) { + if !p.Processed || !p.Value.IsValid() { + return false, nil + } + + t := p.Value.Type() + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if t.Kind() != reflect.Struct || t.Name() == "" { + return false, nil + } + + key := canonical(t.PkgPath()) + "." + t.Name() + + if doc := comments[key]; doc != "" && p.Schema.Description == nil { + p.Schema.WithDescription(doc) + } + + for i := range t.NumField() { + f := t.Field(i) + + name, opts, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + + // Without omitempty a field is always sent: required. + if !strings.Contains(opts, "omitempty") && !slices.Contains(p.Schema.Required, name) { + p.Schema.Required = append(p.Schema.Required, name) + } + + prop, ok := p.Schema.Properties[name] + if !ok || prop.TypeObject == nil { + continue + } + + exact(prop.TypeObject, f.Type) + + // An omitempty field is left out rather than sent as null. + if strings.Contains(opts, "omitempty") { + stripNull(prop.TypeObject) + } + + if doc := comments[key+"."+f.Name]; doc != "" && prop.TypeObject.Description == nil { + prop.TypeObject.WithDescription(doc) + } + + p.Schema.Properties[name] = prop + } + + return false, nil +} + +// stripNull removes null from s's types. +func stripNull(s *jsonschema.Schema) { + if s.Type == nil || len(s.Type.SliceOfSimpleTypeValues) == 0 { + return + } + + types := slices.DeleteFunc(slices.Clone(s.Type.SliceOfSimpleTypeValues), func(v jsonschema.SimpleType) bool { return v == jsonschema.Null }) + if len(types) == 1 { + s.Type = &jsonschema.Type{SimpleTypes: &types[0]} + } else { + s.Type.SliceOfSimpleTypeValues = types + } +} + +// exact tightens a value's schema to what the host sends for Go type t: a +// slice, array or map is never null (nil ones are normalized away), and an +// array has its fixed length, so it becomes a tuple in generated types. +func exact(s *jsonschema.Schema, t reflect.Type) { + switch t.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + default: + return + } + + stripNull(s) + + if t.Kind() == reflect.Array { + s.WithMinItems(int64(t.Len())).WithMaxItems(int64(t.Len())) + } + + if t.Kind() != reflect.Map && s.Items != nil && s.Items.SchemaOrBool != nil && s.Items.SchemaOrBool.TypeObject != nil { + exact(s.Items.SchemaOrBool.TypeObject, t.Elem()) + } +} + +// canonical spells a package path, or a name starting with one, the way +// comments keys it: package main by its import path, which only test +// binaries report. +func canonical(name string) string { + if name == "main" || strings.HasPrefix(name, "main.") { + return mainPackage + strings.TrimPrefix(name, "main") + } + + return name +} + +// errNoBody is a body field without content types. +var errNoBody = errors.New(`body field needs content types, e.g. body:"application/json"`) diff --git a/gui/internal/api/api_test.go b/gui/internal/api/api_test.go new file mode 100644 index 0000000..864057d --- /dev/null +++ b/gui/internal/api/api_test.go @@ -0,0 +1,230 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson" + "github.com/swaggest/jsonschema-go" +) + +type version int + +func (v *version) UnmarshalText(text []byte) error { + n, err := strconv.Atoi(strings.Trim(string(text), `"`)) + *v = version(n) + + return err +} + +type thingIn struct { + ID int `path:"id"` + Mode string `query:"mode"` + IfMatch version `header:"If-Match" required:"true"` + Body map[string]any `body:"application/merge-patch+json,application/json"` +} + +type thingOut struct { + ETag string `header:"ETag" json:"-"` + Got string `json:"got"` +} + +var errTaken = errors.New("taken") + +func testAPI(t *testing.T) (*API, *http.ServeMux) { + t.Helper() + + mux := http.NewServeMux() + a := New(mux, Info{Title: "test", Version: "1"}, func(w http.ResponseWriter, err error) { + httpjson.Error(w, http.StatusConflict, httpjson.CodeStale, err.Error()) + }) + + Route(a, "PATCH /things/{id}", Doc{Summary: "Patch a thing", Errors: []string{httpjson.CodeStale, httpjson.CodeInvalid}}, + func(_ *http.Request, in thingIn) (thingOut, error) { + if in.ID == 9 { + return thingOut{}, errTaken + } + + return thingOut{ETag: `"7"`, Got: strconv.Itoa(in.ID) + "/" + in.Mode + "/" + strconv.Itoa(int(in.IfMatch)) + "/" + in.Body["k"].(string)}, nil + }) + + return a, mux +} + +func call(mux *http.ServeMux, method, target, contentType, ifMatch, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + if ifMatch != "" { + req.Header.Set("If-Match", ifMatch) + } + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + return rec +} + +func TestRouteDecodesAndEncodes(t *testing.T) { + _, mux := testAPI(t) + + rec := call(mux, http.MethodPatch, "/things/3?mode=fast", "application/merge-patch+json", `"5"`, `{"k":"v"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body) + } + + if got := rec.Header().Get("ETag"); got != `"7"` { + t.Errorf("ETag = %q, want \"7\" from the output's header field", got) + } + + var out map[string]any + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatal(err) + } + + if out["got"] != "3/fast/5/v" || len(out) != 1 { + t.Errorf("body = %v, want only got = 3/fast/5/v (no ETag in the body)", out) + } +} + +func TestRouteRejectsBadInput(t *testing.T) { + _, mux := testAPI(t) + + for name, tc := range map[string]struct { + target, contentType, ifMatch, body string + status int + code string + }{ + "missing If-Match is 428": {"/things/3", "application/json", "", `{"k":"v"}`, http.StatusPreconditionRequired, httpjson.CodePrecondition}, + "bad If-Match": {"/things/3", "application/json", `"x"`, `{"k":"v"}`, http.StatusBadRequest, httpjson.CodeBadRequest}, + "non-numeric path": {"/things/x", "application/json", `"5"`, `{"k":"v"}`, http.StatusBadRequest, httpjson.CodeBadRequest}, + "wrong content type": {"/things/3", "text/plain", `"5"`, `{"k":"v"}`, http.StatusUnsupportedMediaType, httpjson.CodeUnsupportedMedia}, + "malformed body": {"/things/3", "application/json", `"5"`, `{nope`, http.StatusBadRequest, httpjson.CodeBadRequest}, + "handler error": {"/things/9", "application/json", `"5"`, `{"k":"v"}`, http.StatusConflict, httpjson.CodeStale}, + } { + rec := call(mux, http.MethodPatch, tc.target, tc.contentType, tc.ifMatch, tc.body) + + var body httpjson.ErrorBody + _ = json.NewDecoder(rec.Body).Decode(&body) + + if rec.Code != tc.status || body.Error != tc.code { + t.Errorf("%s: %d %q, want %d %q", name, rec.Code, body.Error, tc.status, tc.code) + } + } +} + +func TestSpecDescribesTheRoute(t *testing.T) { + a, _ := testAPI(t) + + data, err := a.Spec() + if err != nil { + t.Fatal(err) + } + + var spec struct { + Paths map[string]map[string]struct { + Summary string + Parameters []struct{ Name, In string } + RequestBody struct { + Content map[string]any + } + Responses map[string]struct { + Description string + Headers map[string]any + } + } + } + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatal(err) + } + + op, ok := spec.Paths["/things/{id}"]["patch"] + if !ok { + t.Fatalf("paths = %v, want PATCH /things/{id}", spec.Paths) + } + + if op.Summary != "Patch a thing" { + t.Errorf("summary = %q", op.Summary) + } + + params := map[string]string{} + for _, p := range op.Parameters { + params[p.Name] = p.In + } + + if params["id"] != "path" || params["mode"] != "query" || params["If-Match"] != "header" || len(params) != 3 { + t.Errorf("parameters = %v, want id in path, mode in query, If-Match in header", params) + } + + if _, ok := op.RequestBody.Content["application/merge-patch+json"]; !ok { + t.Errorf("request body content = %v, want application/merge-patch+json", op.RequestBody.Content) + } + + if _, ok := op.Responses["200"].Headers["ETag"]; !ok { + t.Errorf("200 headers = %v, want ETag", op.Responses["200"].Headers) + } + + for status, want := range map[string]string{"409": "stale", "400": "invalid", "500": "internal"} { + if !strings.Contains(op.Responses[status].Description, want) { + t.Errorf("response %s = %q, want it to list %s", status, op.Responses[status].Description, want) + } + } +} + +type custom map[string]any + +func TestSchemaForReplacesAType(t *testing.T) { + mux := http.NewServeMux() + a := New(mux, Info{Title: "test", Version: "1"}, nil) + + var s jsonschema.Schema + s.WithType(jsonschema.Object.Type()).WithDescription("from elsewhere"). + WithPropertiesItem("k", jsonschema.String.ToSchemaOrBool()) + a.SchemaFor(reflect.TypeFor[custom](), s) + + Route(a, "GET /custom", Doc{}, func(*http.Request, struct{}) (custom, error) { return custom{}, nil }) + + data, err := a.Spec() + if err != nil { + t.Fatal(err) + } + + var spec struct { + Paths map[string]map[string]struct { + Responses map[string]struct { + Content map[string]struct{ Schema map[string]any } + } + } + } + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatal(err) + } + + // The response itself must keep its body, not just mention the schema. + if _, ok := spec.Paths["/custom"]["get"].Responses["200"].Content["application/json"]; !ok || !strings.Contains(string(data), "from elsewhere") { + t.Errorf("200 doesn't carry the replacement schema:\n%s", data) + } +} + +func TestRoutePanicsOnInputItCantDocument(t *testing.T) { + type twoBodies struct { + A map[string]any `body:"application/json"` + B map[string]any `body:"application/json"` + } + + defer func() { + if recover() == nil { + t.Error("Route accepted an input with two bodies") + } + }() + + Route(New(http.NewServeMux(), Info{}, nil), "POST /x", Doc{}, func(*http.Request, twoBodies) (struct{}, error) { return struct{}{}, nil }) +} diff --git a/gui/internal/api/comments_gen.go b/gui/internal/api/comments_gen.go new file mode 100644 index 0000000..17fac2f --- /dev/null +++ b/gui/internal/api/comments_gen.go @@ -0,0 +1,362 @@ +// Code generated by go run ./gen; DO NOT EDIT. + +package api + +// mainPackage is package main's import path, which a built binary reports +// as just "main". +const mainPackage = "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui" + +// comments are Go doc comments by import path, type and field. +var comments = map[string]string{ + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).addRoutes": "Registers every route. Each is in the OpenAPI spec at /api/openapi.json, documented from its handler's types and doc comment.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).cameraDevices": "Lists this host's capture devices: candidates for a camera's camera.path, stable by-id and by-path links first. Empty on a machine without Video4Linux.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).configFiles": "Lists the YAML files beside the current vision.yml, for load and save-as.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).effectiveConfig": "Lists every setting of one camera as its vision_processor runs it. Each value says where it comes from: the camera, the shared defaults, the host, or vision_processor's fallback.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).fieldPresets": "Lists the rulebook field dimensions, Division A and B. A preset file the host can't read (from the wrong working directory, for one) is left out rather than failing the request.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).getAlerts": "Lists every problem the host sees right now, most serious first: alerts.state on demand.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).getConfig": "Serves the working document and the store's state. Its revision is also the ETag, for PATCH's If-Match.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).getGeometry": "Serves the wrapper packet the host publishes: the field and every camera's calibration, as canonical protojson, the encoding the rest of the league already reads.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).getSchema": "Describes every vision_processor setting: type, fallback, bounds, and when a change takes effect.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).health": "Reports that the host is up and serving.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).load": "Switches to editing another vision.yml. It replaces the working document, unsaved changes included, and applies it live.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).lockCalibration": "Locks the camera's latest live calibration. It's stored in the working document and published in place of whatever the camera sends; once saved, it survives restarts. A 409 no_live_calibration means none has been received yet.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).network": "Reports each multicast socket's address and what it hears: network.state on demand.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).patchConfig": "Merge-patches the working document and applies it live. It follows RFC 7396, for clients that change a few keys rather than resending the whole document. A stale If-Match is a 409, like PUT's stale revision.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).putConfig": "Replaces the working document and applies it live. Nothing is written to vision.yml until a save.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).reload": "Accepts vision.yml as it is on disk. It discards unsaved changes: the \"load from disk\" answer to the external-change prompt, and \"revert to disk\".", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).save": "Writes the working document to vision.yml. A 409 disk_changed means the file was edited elsewhere since it was loaded (config.state's external describes how); force overwrites it anyway.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).saveAs": "Writes the working document to another path. The host loads from, saves to, and watches that file from then on.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).snapshots": "Lists the debug images on this host, by camera and view.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).topics": "Lists the WebSocket topics /ws accepts.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*VisionServer).unlockCalibration": "Removes the camera's locked calibration. The host stops publishing any calibration for it, so its vision_processor calibrates again the next time it starts.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*networkSockets).apply": "Moves both endpoints to the store's current addresses and interface selection. Each endpoint reopens only if something it uses changed.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.(*networkSockets).reopen": "Forces every socket to reopen, after a suspend.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.NetworkState": "Each socket's address and what it has heard, plus the host facts the Network page's presets and notes need. Served as network.state and GET /api/network.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.StatusSource": "The live host state the API serves beside the store's: what the sockets hear, and the alerts derived from it all.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.bootstrapConfig": "Creates path if it doesn't exist yet: imported from the legacy geometry/config files where present, otherwise from preset. Never touches an existing path.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.botHeightsFile": "The robot height table the cameras read.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.cameraRequest": "Names a camera by camera_id.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.cameraRequest.ID": "The camera's camera_id.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.cameraStreams": "Every camera's live video stream settings.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.competitionField": "Whether the working document is a match setup, which limits browser pages to the -allowOrigin list.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.configResponse": "The working document plus the store's state.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.configResponse.ETag": "The document's revision, for PATCH's If-Match.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.currentNetwork": "The store's working document, its network block, and its interface selection applied to this machine's interfaces.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.fieldPresetResponse": "One rulebook field.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.fieldPresetResponse.Name": "The division, e.g. \"Division B\".", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.formattedAddress": "Turns -address into a URL a person can actually open. A bind-all host (empty, \"0.0.0.0\", \"::\") isn't itself reachable -- printing the LAN-facing IP instead of \"localhost\" is what makes this useful to someone else on the venue network, not just the machine running it. localhost is only the fallback if that IP can't be determined.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.healthResponse": "Says the host is up.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.healthResponse.Status": "Always \"ok\".", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.host": "Everything the process runs, wired together but not yet running: nothing opens a socket or starts a loop until run() starts its background tasks, which is what lets tests build one.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.hostNetwork": "Doc's network block. The store only holds validated documents, so the fallback is for a bug, not a user error.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.hostStatus": "The running host's StatusSource.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.loadRequest": "Names a vision.yml to load.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.loadRequest.Path": "The file to load, on the host.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.mustSchema": "A JSON Schema written as plain values.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.networkSockets": "The host's two multicast groups.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.networkSockets.detections": "Which camera_ids the vision socket hears, from where.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.networkSockets.used": "The previous apply's used interfaces, nil before the first, so a network going away or coming back is logged once.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.networkSockets.video": "Opens each camera's stream only while someone watches it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.newAPI": "The host's typed route registry and spec.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.newHost": "Opens the config at path and wires the store, geometry, sockets, hub and HTTP handler together. A store change re-points the sockets, switches the origin rules for a competition field, and publishes the config, network and alert topics.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.nonNil": "Keeps an empty list a JSON [] rather than null.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.none": "A route's input when it takes nothing.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.openConfig": "Builds the live Geometry from path's field template and opens the store that keeps it in sync with the file.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.openapiSpec": "The host's OpenAPI spec as JSON. It depends only on the code, so it's built from routes registered on a server with nothing behind it; no handler runs.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.outboundIP": "Reports this host's IP as seen by external routing. Dialing UDP doesn't send a packet, just consults the routing table for which local interface/address would be used -- so this works without real connectivity, only a route to the internet.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.ownHostNames": "This machine's hostname and the names its addresses reverse-resolve to, for the API's host check. Lookups that take too long are skipped.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.patchConfigRequest": "A merge patch and the revision it's based on.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.patchConfigRequest.IfMatch": "The revision the patch is based on, as GET /api/config's ETag gives it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.patchConfigRequest.Patch": "Merged into the working document's JSON: objects merge, null removes a key, arrays (cameras) are replaced whole.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.publishGeometryToHub": "Re-encodes the current geometry as protojson and publishes it to the browser-facing hub. Called once per publish tick alongside the raw-bytes multicast send, so both destinations stay in sync.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.publishStatus": "Publishes the network state and the alerts, which depend on it and on the store.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.putConfigRequest": "A whole working document and the revision it's based on.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.putConfigRequest.Revision": "The revision the document is based on; a stale one is a 409.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.respondConfigErr": "Maps a handler's error to an httpjson error: one it returned as a Problem as is, caller mistakes 400 with each problem listed, conflicts 409 with a code, anything else a logged 500.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.revision": "A document revision as an ETag or If-Match value: \"12\".", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.revisionResponse": "Answers a config write with the revision it left the working document at.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.runBackground": "Runs task in its own goroutine tracked by wg, logging its error unless it is the expected result of ctx being cancelled.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.saveAsRequest": "Saves the working document to another file.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.saveAsRequest.Path": "The file to write, on the host.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.saveAsRequest.Revision": "Must be the current one; a stale one is a 409.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.saveRequest": "Saves the working document at a revision.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.saveRequest.Force": "Overwrites vision.yml even if it changed on disk since it was loaded: the \"overwrite disk\" answer to the external-change prompt.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.saveRequest.Revision": "Must be the current one; a stale one is a 409.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.settingsSchema": "Config.CurrentSchema() as JSON Schema: one object per config.yml block, one property per key, with vision_processor's fallback, bounds, and when a change takes effect.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.snapshotRequest": "Names a debug image.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.snapshotRequest.CamID": "The camera's camera_id.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.snapshotRequest.View": "The image's view, as GET /api/snapshots lists it, e.g. raw or geomcalib_input.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.splitList": "Splits a comma-separated flag, dropping empty entries.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.videoRequest": "Picks a camera's stream and how much of it to send.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.videoRequest.ID": "The camera's camera_id.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.videoRequest.Mode": "Full (every frame, the default) or keyframes (about one a second, for grids).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.warnLoopbackMulticast": "Logs once at startup if the loopback interface has multicast off, as Ubuntu ships it. The fix needs root, so it's only suggested.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.wrapperPacket": "An SSL_WrapperPacket as canonical protojson.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/cmd/ssl-vision-processor-gui.writeOpenAPI": "Writes the spec to path, for make.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.Alert": "One problem.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.Alert.ID": "Stable while the problem lasts, for keyed lists.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.Alert.Params": "The facts the alert is about, named per code.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.Codes": "Every alert code Collect can raise, sorted: its own and the camera warning codes it passes through.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.Collect": "Lists every alert for the working document doc, the store's state, and the network, most serious first.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.HeightIssue": "Why color's robots would get vision_processor's default height, or \"\" if they have a known one.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.Network": "What the host's sockets report.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.teamName": "The team the game controller puts in color, or \"\" if none, or if the game controller has gone quiet (its last names may be a past match's).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/alerts.unsaved": "Whether the saved file lacks the choice at key as it is now. Paths are compared whole, since team names can contain dots (\"N.R.G\").", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Document).camera": "Returns a pointer to the camera with id, or nil.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Document).normalize": "Canonicalizes every free-form map in d, so a value is equal to itself whether it arrived as YAML (ints) or JSON from the browser (all float64).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Files": "Lists the .yml and .yaml files in the current file's directory: candidates for Load and SaveAs. Generated config.yml files are listed too; they don't parse as a vision.yml, so Load refuses them.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Load": "Replaces both the working and disk documents with path's contents, applies it live, and switches to editing path.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).LockCalibration": "Stores the latest calibration received for camID in the working document, so it's published in place of anything the camera sends and survives restarts once saved.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).OnChange": "Registers fn to run (without the store's lock held) after any change to the store's state.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Reload": "Load of the current path: it accepts whatever is on disk now, discarding unsaved working changes.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Revision": "The working document's current revision.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Save": "Writes the working document to disk. rev must be the current revision. Unless force is set, a file changed on disk since it was loaded is not overwritten (ErrDiskChanged).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).SaveAs": "Writes the working document to path and makes path the file this store loads from, saves to, and watches.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).State": "Reports the store's current status, including live per-camera state.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).UnlockCalibration": "Removes camID's locked calibration and stops publishing any calibration for it, so its vision_processor calibrates the next time it starts.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Update": "Replaces the working document and applies it live. rev must be the revision the caller's edit was based on.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Watch": "Polls the file every interval until ctx is cancelled, recording a change made by anything other than this store.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).Working": "Returns a copy of the working document and its revision.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).apply": "Pushes doc into the live system and regenerates config.yml files. doc must already be validated. Callers must hold mu.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).checkDisk": "Reports whether the external-change state changed.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).recordExternal": "Notes data as the on-disk version, reporting whether it's new. Callers must hold mu.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.(*Store).writeLocked": "Writes working to path and records it as the disk version. Callers must hold mu.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Applier": "The live system a Store pushes the working document into. *geometry.Geometry implements it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Calibration": "A locked SSL_GeometryCameraCalibration, published in place of whatever the camera's vision_processor last sent.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Calibration.Camera": "SSL_GeometryCameraCalibration in protojson (proto field names).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Calibration.FieldHash": "Identifies the field dimensions the calibration was solved against; a mismatch means the field changed since locking.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Calibration.Proto": "Decodes the locked calibration and checks it's complete.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Camera": "One vision_processor instance, keyed by camera_id (its position on the field), not by which machine runs it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Camera.Config": "Overrides Defaults, same config.yml layout, deep-merged.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Camera.ConfigPath": "A host-local config.yml to regenerate from this document for a vision_processor on the same machine. Empty for remote instances.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Camera.Display": "How the GUI shows this camera's video. Never rendered.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.CameraStatus": "Everything about a camera derived from the document plus the live network state -- never stored.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.CameraStatus.Live": "The latest calibration received from the network (proto field names), whether or not one is locked -- what \"Lock\" would store.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.CameraStatus.LiveResolution": "The image size the live calibration was solved at, [0, 0] if unknown.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Change": "One leaf-level difference between two documents. Before or After is nil when the value was added or removed. Lists (e.g. line_corners) are compared as a whole, not element by element.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Change.Path": "In vision.yml's spelling (field.field_width), JSONPath in the JSON document's (field.fieldWidth). In both, cameras[N] is the camera with camera_id N, not a list index.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.ColorConfig": "A camera's effective color: block, with vision_processor's own fallbacks filled in for anything unset. MinReferenceForce has no protocol equivalent: it's a GUI-only floor on ReferenceForce, stored as min_reference_force in the color block (yaml-cpp ignores unknown keys).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.ColorConfig.Validate": "Reports every out-of-range value at once.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.ColorOverrides": "A height choice per color.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.CurrentSchema": "The schema GET /api/schema serves.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Diff": "Lists every difference from before to after, ordered by path. Cameras are matched by camera_id, so reordering the list is not a change, except that a physical camera (same host and device) whose camera_id changed is matched to itself: moving it to another slot is one camera_id change, not every one of its settings changing in two places.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Display": "Orients a camera's video in the GUI to match the field, since a camera can be mounted at any angle.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Display.Rotate": "degrees clockwise: 0, 90, 180, 270", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document": "One vision.yml.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.BotHeightsFile": "The robot height table the cameras read, from Defaults' bot_heights_file. Like vision_processor, a relative path is relative to the working directory.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.CameraCount": "The number of regions the field is split into: Layout's value, or else the number of cameras rounded up to a power of 2.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.CompetitionField": "A match setup, with a game controller, rather than casual lab use. The GUI keys height choices by team and raises missing setup to errors; vision_processor doesn't read it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Defaults": "Uses vision_processor's own config.yml layout (camera:, geometry:, color:, thresholds:, ...) and applies to every camera.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Effective": "A camera's config.yml-layout settings: Defaults with the camera's own Config deep-merged over it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.EffectiveConfig": "Resolves camera id's settings the way its vision_processor will: its generated config.yml, then vision_processor's fallbacks for anything that leaves out. ErrUnknownCamera if there's no such camera.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Host": "Settings for this GUI host alone; no vision_processor reads it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.InterfaceSelection": "Whether the host picks interfaces itself, and which ones to skip when it doesn't.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Models": "SSL_GeometryModels in protojson (proto field names).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.ModelsProto": "Decodes Models, or returns nil if there are none.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Network": "The shared network block (defaults.network) with fallbacks applied: what the host listens on, and what every camera gets unless it overrides it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Stream": "The camera with id's stream, from its effective config. ok is false if there's no such camera.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Teams": "Overrides robot heights by team. Shown in the GUI only for now: vision_processor still looks heights up in bot_heights_file by game controller team name.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.Validate": "Reports every problem at once, wrapped in a *ValidationError.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.clone": "Deep-copies d through its YAML encoding.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.generic": "D as plain YAML values -- what Diff compares.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Document.rendered": "Camera c's config.yml content: the document's defaults with c's own config merged over, plus cam_id, camera_amount and the seed's line_corners ([][2]int), which the document as a whole decides.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Driver": "What a camera driver does with the camera: block (src/driver/*.cpp).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Driver.SelectsBy": "How the block picks a device: \"id\", an index in the SDK's camera list, or \"path\", a device or file path.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Driver.WhiteBalanceProfiles": "Separate OUTDOOR and INDOOR auto profiles.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.EffectiveConfig": "Every setting of one camera, resolved.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.EffectiveConfig.Settings": "By dotted config.yml path, as in Schema.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.EffectiveSetting": "One setting as camera's vision_processor runs it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.EffectiveSetting.Known": "False for a key the schema doesn't describe: it's written to config.yml, but nothing the host knows of reads it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.ExternalState": "Describes a change made to the file on disk by something else. Changes are what loading it would do to the working document.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.File": "A YAML file beside the one the store edits.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.File.Current": "The file the store loads from and saves to.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.File.ReadOnly": "Files (rulebook presets, no write permission) can't be saved over.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Host": "Settings that only mean something on the machine running the GUI host, such as its network interface names.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Import": "Builds a document from the pre-vision.yml layout: a geometry file (geometry-*.yml) and, optionally, one vision_processor config.yml. With no config file the document gets a single camera 0. The imported camera's ConfigPath is the config file itself, so the next render regenerates it in place.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Interfaces": "Chooses which network interfaces the host's sockets use.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Interfaces.Auto": "(the default when unset) lets the host pick. Skip is then kept only so switching back to manual restores it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Interfaces.Skip": "Names interfaces not to use when choosing manually.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.IsValidation": "Reports whether err is a content problem in either this package's or internal/geometry's sense.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Issues": "Err split into its separate problems, one per line, as Validate reports them.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Layout": "How the field is split between cameras.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Layout.CameraCount": "The number of regions the field is split into (vision_processor's geometry.camera_amount). It can exceed the number of cameras, leaving some regions uncovered.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Marshal": "Encodes doc as vision.yml, header comment included.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.MergePatch": "Applies a JSON Merge Patch (RFC 7396) to doc's JSON form: keys in patch replace doc's, nested objects merge, null removes a key. Arrays are replaced whole, so changing one camera means sending the full cameras list. A patch naming a key Document doesn't have is rejected. The result is not validated; Store.Update does that.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Network": "The vision and game controller multicast addresses, from the config.yml-layout network: block. vision_processor reads the same block at startup, and the host uses it for its own sockets, so both always agree.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Network.GCAddress": "The game controller group as host:port.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Network.Validate": "Reports every problem at once. Any IPv4 address is accepted: multicast is the norm, broadcast works around switches whose IGMP snooping drops multicast, and the GUI warns about anything else rather than refusing it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Network.VisionAddress": "The vision group as host:port.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Open": "Loads path, applies it to live, and renders every local camera's config.yml.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Parse": "Decodes and validates a vision.yml. Unknown keys are rejected so a typo in a hand edit is reported rather than silently ignored.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.RGB": "One reference color, 0-255 per channel.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Range": "A control's suggested span and step.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Render": "Produces the config.yml vision_processor reads for camera c: the document's defaults with c's own config merged over, plus the values derived from the document as a whole (cam_id, camera_amount, and the seed's line_corners). sourcePath is named in the header comment. This is the stand-in transport until vision_processor accepts config over the network; vision_processor hot-reloads color/thresholds/tracking/debug from it and needs a restart for everything else.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Schema": "Describes everything a client needs to edit vision_processor settings without reading the C++: served as GET /api/schema.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Schema.CameraCounts": "The supported layout.cameraCount values.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Schema.Settings": "By dotted config.yml path, e.g. \"thresholds.blobs\".", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Seed": "The human-picked calibration starting point: the four field line corners in image pixels, and the image resolution they were picked at. Resolution [0, 0] means unknown (e.g. imported from a legacy config.yml).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Seed.GoalSideMarker": "Records which corner-picker marker (1-4) was chosen as the goal-side corner. vision_processor never reads it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Seed.Slot": "The slot the corners were picked for. Moving the camera to another slot makes them stale. Nil for corners saved before slots were recorded.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Setting": "One key of vision_processor's config.yml layout, as the defaults block and each camera's config hold it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Setting.Default": "Vision_processor's own fallback for an unset key, nil when there is none (derived) or it depends on other keys (camera.path).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Setting.Minimum": "Bounds the host enforces, inclusive unless Exclusive.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Setting.Range": "A sensible span for a control, narrower than the bounds. The value itself may be anything within the bounds.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Setting.check": "Reports why value isn't a valid s, or nil. Values have been through normalize, so numbers are int or float64.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Settings": "Vision_processor's config.yml layout: blocks (camera, thresholds, color, ...) of keys, as GET /api/schema describes them.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Slot": "A camera's place in the layout: its camera_id out of the camera count, which together decide the region it covers.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.State": "The store's status as the GUI sees it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.State.Changes": "What saving would write: disk -> working.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.State.External": "Set while the file on disk differs from what was last loaded or saved.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Store": "Holds the working document -- live, already applied to the running system -- alongside the document as last loaded from or saved to disk. Edits change only working; Save writes working to disk. A watcher notices when the file changes underneath and records it for the user to resolve.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Stream": "Where a camera's vision_processor sends its live H.264 video, from its config.yml-layout stream: block. vision_processor builds the address as ip_base_prefix + (ip_base_end + cam_id) : port (src/Resources.cpp).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.TeamOverride": "A team's robot height when it isn't (rightly) in the height table: another table entry's (Name), or a custom Height. Neither set means a custom height not entered yet.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Teams": "Overrides robot heights by team. Keyed by the game controller's team name, as vision_processor's height table is, so an override follows its team through a color switch (the game controller swaps the whole team record, name included, between colors).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Teams.ByColor": "Each color's height off a competition field, where there's usually no game controller to name the teams.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.ValidationError": "Marks a document rejected for its content -- the caller's mistake, not ours. Callers use errors.As to answer 400 instead of 500.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.Warning": "One problem worth surfacing on a camera, with a stable code the frontend can key UI (e.g. a \"rescale corners\" button) off.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.atomicWrite": "Replaces path via a temp file and rename, so a reader (the watcher, vision_processor's own reload) never sees a half-written file. A rename replaces a file whatever its permissions, so a read-only file is refused here instead.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.cameraBlock": "The YAML shape of a camera: block, as src/driver/cameradriver.cpp reads it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.cameraMoves": "Maps before camera_ids to after ones for physical cameras that changed slot.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.classify": "Maps a change's path to the GUI tab it belongs to and, for per-camera settings, which camera.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.colorBlock": "The YAML shape of a color: block. Scalars are pointers so a missing key falls back to vision_processor's default rather than Go's zero value, which would silently disagree with what the instance runs.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.colorFromBlock": "Decodes a config.yml-layout color block (generic YAML values, possibly nil) into a ColorConfig with fallbacks applied.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.deepMerge": "Returns base with over merged on top: nested maps merge, anything else in over replaces. Neither input is modified.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.deviceKey": "Identifies the physical camera a camera block opens on host, or is empty when the block doesn't say which device. Two cameras with the same key would fight over one device.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.diffValue": "Recurses only while both sides are maps. A map present on just one side (a camera added, a calibration locked) is one change holding the whole block, not one change per leaf.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.external": "A version of the file on disk that differs from what the store last loaded or wrote.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.external.doc": "nil if it doesn't parse or validate", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.fieldHash": "Fingerprints the field dimensions a calibration was solved against. Short, since it only needs to notice a change, not resist attack.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.flowPair": "Encodes as [x, y] rather than a two-line block sequence.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.importConfig": "Also returns the file's geometry.camera_amount, 0 if unset.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.jsonPath": "Respells a vision.yml path in the JSON document's keys, following the struct tags down Document. Free-form maps (defaults, a camera's config) use the same keys in both, so segments below one pass through unchanged.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.keyCameras": "Replaces the cameras list with a map keyed \"cameras[]\" segments, so cameras diff by identity rather than list position. rekey files a camera under another id (where it moved to); camera_id is kept as a value only for rekeyed cameras and the ids in keep, so the move itself shows up as a change.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.lookup": "Finds a top-level (\"cam_id\") or block (\"thresholds.blobs\") path in a config.yml-layout map.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.mergePatch": "RFC 7396's MergePatch(Target, Patch).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.networkBlock": "The YAML shape of a network: block. Pointers tell a missing key (use the fallback) from a zero one (invalid).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.normalize": "Turns integral floats into ints and every number type into int or float64, recursively. Nested blocks become plain maps: yaml.v3 decodes a mapping inside a Settings as another Settings.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.prefixed": "Puts prefix on every line of err, not just the first as fmt.Errorf(\"%s: %w\") would on a joined error, so each line names its own key. Nil stays nil.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.protoMap": "Msg as protojson with proto field names, decoded to plain values -- the form Models and Calibration.Camera are stored in.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.renderAll": "Regenerates every camera's config.yml that has a ConfigPath, returning an error message per camera_id that failed.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.streamBlock": "The YAML shape of a stream: block. Pointers tell a missing key (use vision_processor's fallback) from a zero one.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.validateCamera": "Checks a camera block for what vision_processor would misread rather than reject: an unknown driver is fatal at startup, and any white_balance string other than OUTDOOR silently means INDOOR.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.validateSettings": "Checks the type and bounds of every scalar setting in genericBlocks of an effective config.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/config.writeIfChanged": "Writes data to path only if it differs from what's there, so an unchanged render doesn't bump the mtime vision_processor polls.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections.(*Tracker).Record": "Notes a detection frame for cameraID from from, arriving at now.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections.(*Tracker).Sources": "Lists every source heard within Forget, by camera_id then address.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections.Source": "One camera_id heard from one address.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections.Tracker": "Safe for concurrent use.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections.Tracker.sources": "arrival times within Window, newest last", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/detections.trim": "Drops arrivals older than Window before now.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).Absorb": "Records the calibrations in one incoming SSL_GeometryData. A calibration missing required fields is rejected before it can touch stored state, so a bad message never desyncs wrapper from encoded. Absorbed calibrations for a locked camera are kept (for LiveCalibration) but not published -- the locked one wins.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).ApplyConfig": "Replaces the field template (dimensions, optional markings, ball models) and regenerates the derived field lines/arcs. Calibrations are untouched. On failure nothing changes.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).Encoded": "Returns the last marshalled SSL_WrapperPacket.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).LiveCalibration": "Returns a copy of the latest calibration absorbed from the network for camID, or nil if none has arrived since startup (or since the last Unlock).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).Run": "Publishes immediately, then republishes via publish once per PublishInterval until ctx is cancelled. Publishing before the first wait (not after) matters here: real vision_processor instances on the network wait to receive the field-geometry template before they can calibrate, so a restart of this host should hand it to them right away, not after an extra idle PublishInterval.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).SetLocked": "Replaces the full set of locked calibrations, keyed by camera_id. Each must already be complete (proto.CheckInitialized).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).Snapshot": "Returns a deep copy of the current wrapper packet.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).Unlock": "Stops publishing any calibration for camID -- locked or absorbed -- so the next time that camera's vision processor starts, it finds none for itself and calibrates. A processor that's already running keeps the model it has; the protocol has no way to tell it to recalibrate live.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).applyFieldConfig": "Builds a field message from cfg, regenerates its derived markings, and installs it onto g. Callers must hold mu and must have already validated cfg -- this never fails.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).reencode": "Refreshes encoded. Callers must hold mu.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.(*Geometry).republishCalib": "Rebuilds the published calibration list from locked and absorbed, ordered by camera_id, and re-encodes. Callers must hold mu.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.FieldConfig": "The editable subset of SSL_GeometryFieldSize -- everything except FieldLines/FieldArcs (generated, never hand-edited) and the fields that live elsewhere (Calib is runtime state, not static config).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.FieldConfig.Validate": "Reports every dimension that can't be right, so a caller can surface all of them at once rather than one failed PUT per typo.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.Geometry": "Owns the live field template and the per-camera calibrations published with it. It holds no file state: internal/config owns vision.yml and pushes changes in through ApplyConfig/SetLocked. Each camera's published calibration is its locked one if set, otherwise the latest one absorbed from the network. A vision processor only skips calibrating at startup if the packet it receives holds a calibration for its camera_id, so publishing locked calibrations is what lets a restart of this host (or of a processor) avoid a needless recalibration. proto.Marshal writes to the message's size cache, so this is not a read-without-locking type. mu guards every field. encoded is the last marshalled snapshot, kept so Run never has to marshal on its own.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.Load": "Reads path and returns the wrapper packet, the optional-line toggles (not a proto field, so callers wanting to inspect or re-save them need it separately), and whatever top-level YAML keys aren't \"optional_field_lines\" or \"field\" (e.g. \"models\") so a later save can put them back unchanged.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.LoadFieldFile": "Reads a legacy geometry YAML file (geometry-*.yml) fresh -- no Geometry instance, no mutation of anything -- and returns its field dimensions, optional-line toggles, and ball models. Used for the rulebook presets and for importing a pre-vision.yml setup.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.LoadPreset": "LoadFieldFile without the models, for serving the rulebook presets (config/legacy/geometry-div{A,B}.yml) straight from the same files a human would open, rather than a copy that could drift from them.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.New": "Builds a Geometry publishing the given field template and no calibrations.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.OptionalLinesConfig": "OptionalLines with concrete bools instead of pointers -- an API request/response always supplies all four, so there's no \"missing\" case to represent here the way there is when parsing a hand-edited YAML file.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.ValidationError": "Marks a FieldConfig rejected by Validate -- the caller's mistake, not ours. Callers such as the HTTP handler use errors.As to tell this apart from an internal failure (e.g. a disk write error) and respond 400 instead of 500.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.fieldConfigFrom": "Reads a FieldConfig/OptionalLinesConfig pair out of a decoded field size and its optional-line toggles. Shared by FieldConfig (the live, held Geometry) and LoadPreset (an arbitrary file read fresh, with no Geometry involved).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.generateFieldMarkings": "Appends the standard SSL lines and arcs derived from the field dimensions. Markings the physical field lacks are skipped: publishing a line the camera cannot see disturbs refinement calibration. Coordinates are in mm, with the origin at the field centre, +x towards the right goal and +y towards the top touch line.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry.optionalLines.enabled": "Reads one of the optionalLines pointers. A nil pointer cannot occur after validate, but treating it as off keeps a programming error from publishing a marking the field does not have.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.Decode": "Reads r's JSON body into v, answering the request itself and returning false on failure. The Content-Type must be one of types (JSONTypes if none), and unknown fields are rejected, so a misspelled key fails loudly rather than being dropped and wiping the value it named.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.Error": "Sends an ErrorBody with status.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.ErrorBody": "The body of every non-2xx API response.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.ErrorBody.Issues": "Lists each problem separately for CodeInvalid.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.Errorf": "A Problem with code and a formatted message.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.NotFound": "An http.Handler answering CodeNotFound, for unrouted API paths.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.Problem": "An error a handler returns to answer with a specific code, e.g. Errorf(CodeInvalid, \"path: required\"). Its status is StatusOf(Code).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.StatusOf": "The HTTP status a code is answered with; 500 for one it doesn't know.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson.Write": "Sends v as JSON with status.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).Address": "The address the endpoint is (or is about to be) open on.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).Reopen": "Closes and reopens the sockets even though nothing changed: after a suspend, the old ones may be bound to state the network no longer has.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).Run": "Keeps the endpoint open on its current address until ctx is cancelled, reopening whenever SetAddress, SetInterfaces, or Reopen asks, and every RetryInterval while the sockets aren't fully set up.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).Send": "Publishes data onto the group. Dropped while the endpoint is between sockets, or if it wasn't opened with Options.Send.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).SetAddress": "Moves the endpoint to address: Run closes the current sockets and opens new ones. Counters restart, since they describe the old group. A no-op when address is unchanged.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).SetInterfaces": "Moves the endpoint onto a new interface selection (see Select). A no-op unless the used interfaces or their addresses changed, since the host re-evaluates automatic selection every second.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).Status": "Reports the endpoint as of now.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).receive": "Handles a datagram that arrived on address. A closing socket can still deliver one after a move; it's dropped rather than counted against the new address.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).report": "Logs a change in what's wrong, so a socket retrying all night doesn't fill the log.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*Endpoint).wait": "Returns when the sockets should be reopened: retrying is true when it's because they're unhealthy rather than because something changed.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*SuspendDetector).Check": "Reports how long the machine was suspended since the last check, or 0 if it wasn't.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*sender).health": "\"\" while every send socket that should be open is.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.(*sender).send": "Writes data on every connection, dropping any that fail until the endpoint reopens (it notices through health).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Consumer": "Handles one datagram from from (nil if unknown) and reports whether it counts as heard: a valid packet from someone else, not noise or our own looped-back sends.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Endpoint": "One multicast group the host listens on, and optionally sends to.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Endpoint.poke": "Wakes Run after SetAddress, SetInterfaces, or Reopen. Size 1: several before Run gets to it collapse into one reopen.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Endpoint.problem": "last reported, so it's logged when it changes", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Interface": "One local network interface as the Network page shows it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Interface.Address": "Its first IPv4 address, if any; Broadcast that subnet's broadcast address.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Interface.AutoUse": "What automatic selection decides; AutoReason why it doesn't use the interface.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Interface.Usable": "Means up, multicast capable, and with an IPv4 address: the least a socket needs to use it at all.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Interface.Used": "Whether the host's sockets use it under the current selection.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.ListInterfaces": "Describes every local interface, with Used left false. Select fills it in.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.LoopbackMulticast": "Reports the loopback interface's name and whether it has multicast enabled. Ubuntu leaves it off, so programs on this machine that talk multicast over loopback (typical when no network is connected) don't hear each other. ok is false if there's no loopback interface.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.NewEndpoint": "Prepares an endpoint on address (e.g. \"224.5.23.2:10006\") over ifaces (see Select). Nothing is opened until Run.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.NewSuspendDetector": "Starts watching from now.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Options": "Configures an Endpoint's sockets.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Options.ReadBuffer": "The kernel receive buffer; 0 means defaultReadBuffer. Video sets more, for a keyframe's burst of packets.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Options.Send": "Also opens a sender on the group, for Endpoint.Send.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.RefereeConsumer": "Counts valid referee messages and hands each one's yellow and blue team names to track. Hearing them is what shows the game controller address is right.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Select": "Marks which interfaces the host uses, automatically or by skipping the named ones, and returns the names of all the others: the skip list for the sockets.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Status": "An endpoint's address and what it has heard there.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Status.Mode": "How the receiver was opened: \"multicast\" joined the group, \"port\" listens on the port for anything sent there (broadcast, or unicast to this host).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.Status.Problem": "Why the sockets aren't fully open, if they aren't.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.SuspendDetector": "Notices that the machine was suspended between two checks. It compares a clock that keeps counting through suspend with one that doesn't (on Linux, CLOCK_BOOTTIME and CLOCK_MONOTONIC): neither jumps for NTP or manual clock changes, so their difference grows by exactly the time spent asleep and never otherwise.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.SuspendDetector.offset": "Reads the suspend-counting clock minus the one that isn't.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.UnprivilegedPortStart": "The lowest port an unprivileged process may bind on this host (Linux's net.ipv4.ip_unprivileged_port_start), or 1024, the traditional limit, where that can't be read.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.VisionConsumer": "Hands the geometry from every wrapper packet to absorb, and each detection frame's camera_id and sender to track. Only packets carrying a detection frame count as heard: those come from vision processors, while geometry-only packets include the host's own, looped back (multicast loopback is on by default; absorb is idempotent, so that's harmless rather than special-cased).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.ifaceFacts": "What ListInterfaces reads from the OS, split out so the decisions made from it can be tested.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.join": "Joins addr's group on every used interface. It fails only if it joined none: some interfaces refusing (one without multicast, say) is fine as long as another works.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.modeOf": "Says how address is received: by joining it as a multicast group, or by listening on its port for anything sent there (broadcast, or unicast to this host).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.onlyJoinedGroups": "Linux only (IP_MULTICAST_ALL). Elsewhere a socket may hear other groups on its port.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.openSender": "Dials address from each used interface it makes sense to send from: all of them for a multicast group or the limited broadcast address, only the matching one for a subnet broadcast, and the kernel's choice for anything else (unicast). A multicast group with no used interface gets no socket at all rather than the kernel's choice, which is what \"skip every interface\" means, and which would fail anyway while the network is down.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.openSocket": "Receives on one plain UDP socket: - it hears only the groups it joined itself (onlyJoinedGroups), so groups sharing a port (every camera's video uses 10100) each get only their own traffic. Binding the group address doesn't do this in Go, which binds a multicast address as 0.0.0.0; - it joins the group on every used interface at once; - its kernel receive buffer is opts.ReadBuffer, room for a video keyframe's burst of packets. The kernel caps it at net.core.rmem_max. A non-multicast address binds 0.0.0.0 on the port instead, hearing broadcast (or unicast to this host) on every interface. The returned problem is \"\" if the socket is fully set up. Even with a problem, stop is safe to call.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.openSockets": "The real opener: a plain receiving socket (openSocket) and, for Options.Send, a sender on every used interface (openSender).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.opener": "Opens sockets on address over the interfaces marked Used, delivering datagrams to receive. Swapped out in tests, which can't rely on joining a real multicast group.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.sender": "Writes datagrams to one address from every interface the host uses.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.sender.problem": "Why some send socket is missing: one couldn't be opened, or one failed and was dropped (an interface going away). The endpoint sees it through health and reopens.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.sockets": "What an opener hands back.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.sockets.health": "\"\" while everything that should be open is, else what's wrong (\"no usable network interface\", a join error, a send socket lost to an interface going away). Checked every RetryInterval.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.sockets.send": "Nil when not sending.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.suspendOffset": "Falls back to the wall clock (counts suspend) minus Go's monotonic clock. Unlike CLOCK_BOOTTIME, the wall clock can jump for a manual clock change or an NTP step, which then reads as a suspend; the only effect is a harmless reopen.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast.usedKey": "The part of a selection the sockets depend on.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.(*Heights).load": "Returns the table at path, reading it again only if the path, size, or modification time changed.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.(*Match).SetHeightsFile": "Chooses the height table, vision_processor's bot_heights_file.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.(*Match).State": "Describes the match now.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.Describe": "Combines the latest team names with the height table at heightsFile.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.Heights": "A robot height table file (team name: height in mm), re-read when the file changes. Safe for concurrent use.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.Match": "The current match as the GUI sees it: the teams the referee names, and the robot height table they're looked up in. Safe for concurrent use.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.State": "What the GUI shows about the current match.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.State.Heard": "When the last referee message arrived, nil if none has.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.State.Heights": "The whole table, team name to height in mm.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.State.HeightsFile": "The table vision_processor reads (bot_heights_file).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.State.MeanHeight": "Vision_processor's height for a team not in the table, until a known team has played; MaxHeight is the tallest in the table, which it uses for its field extent checks.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.Team": "One side of the current match.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.Team.Height": "The team's robot height from the table, nil when the team isn't in it.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/referee.Teams": "Records the team names from the latest referee message. Safe for concurrent use.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/snapshot.Entry": "Identifies one available snapshot.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/snapshot.HandleGet": "Serves a camera's latest debug image for one view, e.g. /api/snapshot/0/raw.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/snapshot.List": "The debug images vision_processor has written to dir (-imgDir), one entry per camera and view, sorted. A missing dir has none.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/snapshot.latest": "Returns the most recently modified file matching dir/camID.view.*, or \"\" if none exists.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Device": "One way to name a capture device.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Device.Kind": "\"by-id\" (follows the camera), \"by-path\" (follows the USB port), or \"node\" (/dev/videoN, which can change when devices re-enumerate).", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Device.Name": "The driver's name for the device, e.g. \"UC70: UC70\".", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Device.Node": "The /dev/videoN it currently points at.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Device.Path": "What goes in camera.path.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.List": "Returns the capture nodes (V4L2 index 0; cameras often add a metadata-only node at index 1) under every stable name udev gives them, stable names first.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Roots": "Where List looks; tests point them at a fake tree.", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Roots.Dev": "usually /dev", + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/v4l.Roots.Sysfs": "usually /sys/class/video4linux", +} diff --git a/gui/internal/api/gen/main.go b/gui/internal/api/gen/main.go new file mode 100644 index 0000000..c861320 --- /dev/null +++ b/gui/internal/api/gen/main.go @@ -0,0 +1,186 @@ +// Command gen writes comments_gen.go: the Go doc comment of every exported +// type and struct field in the packages whose types the API serves, keyed by +// import path, type and field, for api.describe to put in the spec. Run by +// `go generate ./internal/api`. +package main + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "log" + "os" + "path/filepath" + "sort" + "strings" +) + +const module = "github.com/RoboCup-SSL/ssl-vision-processor/gui" + +// dirs are relative to internal/api, where go generate runs. +var dirs = []string{ + "../alerts", "../config", "../detections", "../geometry", "../httpjson", + "../multicast", "../referee", "../snapshot", "../v4l", + "../../cmd/ssl-vision-processor-gui", +} + +// mainPackage is the import path of the dir holding package main, which a +// built binary reports as just "main". +var mainPackage string + +func main() { + comments := map[string]string{} + fset := token.NewFileSet() + + for _, dir := range dirs { + files, err := filepath.Glob(filepath.Join(dir, "*.go")) + if err != nil { + log.Fatal(err) + } + + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + log.Fatal(err) + } + + pkg := module + "/" + filepath.ToSlash(filepath.Clean(filepath.Join("internal/api", dir))) + if f.Name.Name == "main" { + mainPackage = pkg + } + + collect(comments, pkg, f) + } + } + + keys := make([]string, 0, len(comments)) + for k := range comments { + keys = append(keys, k) + } + + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteString("// Code generated by go run ./gen; DO NOT EDIT.\n\npackage api\n\n") + fmt.Fprintf(&buf, "// mainPackage is package main's import path, which a built binary reports\n// as just \"main\".\nconst mainPackage = %q\n\n", mainPackage) + buf.WriteString("// comments are Go doc comments by import path, type and field.\nvar comments = map[string]string{\n") + + for _, k := range keys { + fmt.Fprintf(&buf, "\t%q: %q,\n", k, comments[k]) + } + + buf.WriteString("}\n") + + out, err := format.Source(buf.Bytes()) + if err != nil { + log.Fatal(err) + } + + if err := os.WriteFile("comments_gen.go", out, 0o644); err != nil { + log.Fatal(err) + } +} + +func collect(comments map[string]string, pkg string, f *ast.File) { + for _, decl := range f.Decls { + // Functions and methods, keyed as the runtime names them: + // main.(*VisionServer).getConfig. + if fn, ok := decl.(*ast.FuncDecl); ok { + if doc := lead(fn.Name.Name, text(fn.Doc)); doc != "" { + comments[pkg+"."+funcName(fn)] = doc + } + + continue + } + + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + + for _, spec := range gen.Specs { + ts := spec.(*ast.TypeSpec) + + doc := text(ts.Doc) + if doc == "" && len(gen.Specs) == 1 { + doc = text(gen.Doc) + } + + doc = lead(ts.Name.Name, doc) + + key := pkg + "." + ts.Name.Name + if doc != "" { + comments[key] = doc + } + + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + + for _, field := range st.Fields.List { + doc := strings.TrimSpace(text(field.Doc) + " " + text(field.Comment)) + if doc == "" { + continue + } + + for _, name := range field.Names { + comments[key+"."+name.Name] = lead(name.Name, doc) + } + } + } + } +} + +func funcName(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return fn.Name.Name + } + + switch t := fn.Recv.List[0].Type.(type) { + case *ast.StarExpr: + if id, ok := t.X.(*ast.Ident); ok { + return "(*" + id.Name + ")." + fn.Name.Name + } + case *ast.Ident: + return t.Name + "." + fn.Name.Name + } + + return fn.Name.Name +} + +// lead rewrites a Go doc comment's opening for an API reader: "Document is +// one vision.yml." becomes "One vision.yml.", "Params are the facts" becomes +// "The facts". +func lead(name, doc string) string { + rest, ok := strings.CutPrefix(doc, name+" ") + if !ok || rest == "" { + return doc + } + + for _, verb := range []string{"is ", "are "} { + if r, ok := strings.CutPrefix(rest, verb); ok { + rest = r + + break + } + } + + return strings.ToUpper(rest[:1]) + rest[1:] +} + +// text is a comment group as one paragraph: what a spec description wants. +func text(g *ast.CommentGroup) string { + if g == nil { + return "" + } + + return strings.Join(strings.Fields(g.Text()), " ") +} diff --git a/gui/internal/api/input.go b/gui/internal/api/input.go new file mode 100644 index 0000000..e999998 --- /dev/null +++ b/gui/internal/api/input.go @@ -0,0 +1,179 @@ +package api + +import ( + "encoding" + "fmt" + "net/http" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson" +) + +// input is what Route learned about a handler's input struct. +type input struct { + fields []param + // params is the input struct without its body field, for the spec. + params reflect.Type + // body is the body field's index, or -1. + body int + bodyType reflect.Type + bodyTypes []string + // bodyDoc is the body field's doc comment. + bodyDoc string + // whole is an input with only JSON fields: the struct itself is the + // body. + whole bool +} + +type param struct { + index int + in string // path, query or header + name string + required bool +} + +var textUnmarshaler = reflect.TypeFor[encoding.TextUnmarshaler]() + +// inputOf reads the tags on t, which must be a struct (struct{} for none). +func inputOf(t reflect.Type) (input, error) { + if t.Kind() != reflect.Struct { + return input{}, fmt.Errorf("input %s: want a struct", t) + } + + in := input{body: -1} + + var paramFields []reflect.StructField + + for i := range t.NumField() { + f := t.Field(i) + + if types, ok := f.Tag.Lookup("body"); ok { + if in.body >= 0 { + return input{}, fmt.Errorf("input %s: more than one body field", t) + } + + if types == "" { + return input{}, errNoBody + } + + in.body, in.bodyType, in.bodyTypes = i, f.Type, strings.Split(types, ",") + in.bodyDoc = comments[canonical(t.PkgPath())+"."+t.Name()+"."+f.Name] + + continue + } + + for _, where := range []string{"path", "query", "header"} { + name, ok := f.Tag.Lookup(where) + if !ok { + continue + } + + if f.Type.Kind() != reflect.String && f.Type.Kind() != reflect.Int && f.Type.Kind() != reflect.Int64 && + !reflect.PointerTo(f.Type).Implements(textUnmarshaler) { + return input{}, fmt.Errorf("input %s.%s: want a string, an int, or an encoding.TextUnmarshaler", t, f.Name) + } + + // The field's doc comment becomes the parameter's description. + if doc := comments[canonical(t.PkgPath())+"."+t.Name()+"."+f.Name]; doc != "" && f.Tag.Get("description") == "" { + f.Tag = reflect.StructTag(fmt.Sprintf("%s description:%q", f.Tag, doc)) + } + + in.fields = append(in.fields, param{ + index: i, + in: where, + name: name, + required: where == "path" || f.Tag.Get("required") == "true", + }) + paramFields = append(paramFields, f) + } + } + + if len(paramFields) > 0 { + in.params = reflect.StructOf(paramFields) + } + + if in.body < 0 && len(paramFields) == 0 { + for i := range t.NumField() { + if name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ","); name != "" && name != "-" { + in.whole, in.bodyType, in.bodyTypes = true, t, []string{"application/json"} + in.bodyDoc = comments[canonical(t.PkgPath())+"."+t.Name()] + + break + } + } + } + + return in, nil +} + +// conditionalHeaders are answered 428 Precondition Required when missing +// (RFC 6585), rather than 400. +var conditionalHeaders = []string{"If-Match", "If-None-Match", "If-Unmodified-Since"} + +// decode fills v from r, or answers r itself and returns false. +func (in input) decode(w http.ResponseWriter, r *http.Request, v reflect.Value) bool { + for _, p := range in.fields { + var raw string + + switch p.in { + case "path": + raw = r.PathValue(p.name) + case "query": + raw = r.URL.Query().Get(p.name) + case "header": + raw = r.Header.Get(p.name) + } + + if raw == "" { + if !p.required { + continue + } + + if p.in == "header" && slices.Contains(conditionalHeaders, p.name) { + httpjson.Error(w, http.StatusPreconditionRequired, httpjson.CodePrecondition, p.name+": required") + } else { + httpjson.Error(w, http.StatusBadRequest, httpjson.CodeBadRequest, p.name+": required") + } + + return false + } + + if err := set(v.Field(p.index), raw); err != nil { + httpjson.Error(w, http.StatusBadRequest, httpjson.CodeBadRequest, fmt.Sprintf("%s: %v", p.name, err)) + + return false + } + } + + switch { + case in.whole: + return httpjson.Decode(w, r, v.Addr().Interface(), in.bodyTypes...) + case in.body >= 0: + return httpjson.Decode(w, r, v.Field(in.body).Addr().Interface(), in.bodyTypes...) + } + + return true +} + +func set(f reflect.Value, raw string) error { + if u, ok := f.Addr().Interface().(encoding.TextUnmarshaler); ok { + return u.UnmarshalText([]byte(raw)) + } + + switch f.Kind() { + case reflect.String: + f.SetString(raw) + case reflect.Int, reflect.Int64: + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("want a whole number, got %q", raw) + } + + f.SetInt(n) + } + + return nil +} diff --git a/gui/internal/api/viewer.go b/gui/internal/api/viewer.go new file mode 100644 index 0000000..04277ac --- /dev/null +++ b/gui/internal/api/viewer.go @@ -0,0 +1,69 @@ +package api + +import ( + _ "embed" + "net/http" +) + +// redoc is Redoc's standalone bundle (MIT, viewer/REDOC-LICENSE), embedded so +// the docs work on a venue network with no internet. Refresh it with +// `make redoc`. +// +//go:embed viewer/redoc.standalone.js +var redoc []byte + +// viewerPage loads the spec into Redoc with system fonts, so it needs nothing +// from the internet. +const viewerPage = ` + + + + +vision_processor GUI host API + + + +
    + + + + +` + +// docsCSP allows the viewer page only this host's resources, plus the inline +// init script and the blob: worker and images Redoc creates. +const docsCSP = "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src blob:; " + + "style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'" + +// ServeDocs registers GET /api/openapi.json (the spec) and GET /api/docs +// (a viewer for it). Call it after every Route, since the spec is built +// once, here. +func (a *API) ServeDocs() { + spec, err := a.Spec() + if err != nil { + panic("api: marshalling the spec: " + err.Error()) + } + + a.mux.HandleFunc("GET /api/openapi.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(spec) + }) + + a.mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Everything from this host: no request leaves the venue network, + // not even Redoc's badge logo. + w.Header().Set("Content-Security-Policy", docsCSP) + _, _ = w.Write([]byte(viewerPage)) + }) + + a.mux.HandleFunc("GET /api/docs/redoc.standalone.js", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript") + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = w.Write(redoc) + }) +} diff --git a/gui/internal/config/advanced.go b/gui/internal/config/advanced.go deleted file mode 100644 index 68d6d20..0000000 --- a/gui/internal/config/advanced.go +++ /dev/null @@ -1,82 +0,0 @@ -package config - -import ( - "errors" - "fmt" - - "gopkg.in/yaml.v3" -) - -// advancedBlocks is the YAML shape of the thresholds: and tracking: blocks, -// as src/Resources.cpp reads them. -type advancedBlocks struct { - Thresholds struct { - Circularity *float64 `yaml:"circularity"` - Score *float64 `yaml:"score"` - Blobs *int `yaml:"blobs"` - MinConfidence *float64 `yaml:"min_confidence"` - MinCamEdgeDistance *float64 `yaml:"min_cam_edge_distance"` - ResamplingFactor *float64 `yaml:"resampling_factor"` - ClippingTolerance *float64 `yaml:"clipping_tolerance"` - GeometryTolerance *float64 `yaml:"geometry_tolerance"` - } `yaml:"thresholds"` - Tracking struct { - MinTrackingRadius *float64 `yaml:"min_tracking_radius"` - MaxBotAcceleration *float64 `yaml:"max_bot_acceleration"` - } `yaml:"tracking"` -} - -// validateAdvanced checks the thresholds and tracking values in an effective -// config for what vision_processor would accept but misbehave on: a zero or -// negative blob budget or resampling factor, a confidence outside 0-1, or a -// negative distance. -func validateAdvanced(effective map[string]any) error { - data, err := yaml.Marshal(map[string]any{ - "thresholds": effective["thresholds"], - "tracking": effective["tracking"], - }) - if err != nil { - return err - } - - var b advancedBlocks - if err := yaml.Unmarshal(data, &b); err != nil { - return fmt.Errorf("thresholds/tracking: %w", err) - } - - var errs error - - add := func(format string, args ...any) { errs = errors.Join(errs, fmt.Errorf(format, args...)) } - - t := b.Thresholds - if t.Blobs != nil && *t.Blobs < 1 { - add("thresholds.blobs: must be at least 1, got %d", *t.Blobs) - } - - if t.MinConfidence != nil && (*t.MinConfidence < 0 || *t.MinConfidence > 1) { - add("thresholds.min_confidence: want 0-1, got %g", *t.MinConfidence) - } - - if t.ResamplingFactor != nil && *t.ResamplingFactor <= 0 { - add("thresholds.resampling_factor: must be positive, got %g", *t.ResamplingFactor) - } - - for _, f := range []struct { - name string - value *float64 - }{ - {"thresholds.circularity", t.Circularity}, - {"thresholds.score", t.Score}, - {"thresholds.min_cam_edge_distance", t.MinCamEdgeDistance}, - {"thresholds.clipping_tolerance", t.ClippingTolerance}, - {"thresholds.geometry_tolerance", t.GeometryTolerance}, - {"tracking.min_tracking_radius", b.Tracking.MinTrackingRadius}, - {"tracking.max_bot_acceleration", b.Tracking.MaxBotAcceleration}, - } { - if f.value != nil && *f.value < 0 { - add("%s: must not be negative, got %g", f.name, *f.value) - } - } - - return errs -} diff --git a/gui/internal/config/diff.go b/gui/internal/config/diff.go index de58f32..d66bd18 100644 --- a/gui/internal/config/diff.go +++ b/gui/internal/config/diff.go @@ -24,10 +24,14 @@ const ( // After is nil when the value was added or removed. Lists (e.g. // line_corners) are compared as a whole, not element by element. type Change struct { + // Path is in vision.yml's spelling (field.field_width), JSONPath in the + // JSON document's (field.fieldWidth). In both, cameras[N] is the camera + // with camera_id N, not a list index. Path string `json:"path"` + JSONPath string `json:"jsonPath"` Before any `json:"before"` After any `json:"after"` - Section string `json:"section"` + Section string `json:"section" enum:"field,geometry,color,network,camera,layout,advanced,overview,other"` CameraID *int `json:"cameraId,omitempty"` } @@ -161,6 +165,7 @@ func newChange(path []string, before, after any) Change { return Change{ Path: strings.Join(path, "."), + JSONPath: strings.Join(jsonPath(path), "."), Before: before, After: after, Section: section, @@ -236,3 +241,57 @@ func classify(path []string) (string, *int) { return SectionOther, nil } } + +// jsonPath respells a vision.yml path in the JSON document's keys, following +// the struct tags down Document. Free-form maps (defaults, a camera's config) +// use the same keys in both, so segments below one pass through unchanged. +func jsonPath(path []string) []string { + out := make([]string, len(path)) + t := reflect.TypeFor[Document]() + + for i, segment := range path { + out[i] = segment + + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + + switch { + case t == nil: + case t == reflect.TypeFor[Document]() && strings.HasPrefix(segment, "cameras["): + t = reflect.TypeFor[Camera]() + case t.Kind() == reflect.Struct: + field, ok := fieldByYAMLName(t, segment) + if !ok { + t = nil + + continue + } + + out[i] = tagName(field.Tag.Get("json")) + t = field.Type + case t.Kind() == reflect.Map: + t = t.Elem() + default: + t = nil + } + } + + return out +} + +func fieldByYAMLName(t reflect.Type, name string) (reflect.StructField, bool) { + for i := range t.NumField() { + if f := t.Field(i); tagName(f.Tag.Get("yaml")) == name { + return f, true + } + } + + return reflect.StructField{}, false +} + +func tagName(tag string) string { + name, _, _ := strings.Cut(tag, ",") + + return name +} diff --git a/gui/internal/config/document.go b/gui/internal/config/document.go index 26dc167..f69ebd4 100644 --- a/gui/internal/config/document.go +++ b/gui/internal/config/document.go @@ -13,6 +13,7 @@ import ( "fmt" "math" "slices" + "strings" "time" "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/geometry" @@ -39,8 +40,8 @@ type Document struct { Models map[string]any `yaml:"models,omitempty" json:"models,omitempty"` // Defaults uses vision_processor's own config.yml layout (camera:, // geometry:, color:, thresholds:, ...) and applies to every camera. - Defaults map[string]any `yaml:"defaults,omitempty" json:"defaults,omitempty"` - Layout *Layout `yaml:"layout,omitempty" json:"layout,omitempty"` + Defaults Settings `yaml:"defaults,omitempty" json:"defaults,omitempty"` + Layout *Layout `yaml:"layout,omitempty" json:"layout,omitempty"` // Teams overrides robot heights by team. Shown in the GUI only for now: // vision_processor still looks heights up in bot_heights_file by game // controller team name. @@ -54,6 +55,10 @@ type Document struct { Host *Host `yaml:"host,omitempty" json:"host,omitempty"` } +// Settings is vision_processor's config.yml layout: blocks (camera, +// thresholds, color, ...) of keys, as GET /api/schema describes them. +type Settings map[string]any + // Layout is how the field is split between cameras. type Layout struct { // CameraCount is the number of regions the field is split into @@ -160,9 +165,9 @@ type Camera struct { // for a vision_processor on the same machine. Empty for remote instances. ConfigPath string `yaml:"config_path,omitempty" json:"configPath,omitempty"` // Config overrides Defaults, same config.yml layout, deep-merged. - Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` - Seed *Seed `yaml:"seed,omitempty" json:"seed,omitempty"` - Calibration *Calibration `yaml:"calibration,omitempty" json:"calibration,omitempty"` + Config Settings `yaml:"config,omitempty" json:"config,omitempty"` + Seed *Seed `yaml:"seed,omitempty" json:"seed,omitempty"` + Calibration *Calibration `yaml:"calibration,omitempty" json:"calibration,omitempty"` // Display is how the GUI shows this camera's video. Never rendered. Display *Display `yaml:"display,omitempty" json:"display,omitempty"` } @@ -170,7 +175,7 @@ type Camera struct { // Display orients a camera's video in the GUI to match the field, since a // camera can be mounted at any angle. type Display struct { - Rotate int `yaml:"rotate,omitempty" json:"rotate,omitempty"` // degrees clockwise: 0, 90, 180, 270 + Rotate int `yaml:"rotate,omitempty" json:"rotate,omitempty" enum:"0,90,180,270"` // degrees clockwise: 0, 90, 180, 270 Mirror bool `yaml:"mirror,omitempty" json:"mirror,omitempty"` } @@ -272,13 +277,14 @@ func (d Document) Validate() error { var errs error add := func(err error) { errs = errors.Join(errs, err) } + addIn := func(prefix string, err error) { add(prefixed(prefix, err)) } if d.Version != Version { add(fmt.Errorf("version: want %d, got %d", Version, d.Version)) } if err := d.Field.Validate(); err != nil { - add(fmt.Errorf("field: %w", err)) + addIn("field", err) } if _, err := d.ModelsProto(); err != nil { @@ -286,9 +292,9 @@ func (d Document) Validate() error { } if n, err := d.Network(); err != nil { - add(fmt.Errorf("defaults: %w", err)) + addIn("defaults", err) } else if err := n.Validate(); err != nil { - add(fmt.Errorf("defaults: %w", err)) + addIn("defaults", err) } if d.Teams != nil { @@ -356,7 +362,7 @@ func (d Document) Validate() error { p, err := c.Calibration.Proto() switch { case err != nil: - add(fmt.Errorf("%s.calibration: %w", prefix, err)) + addIn(prefix+".calibration", err) case int(p.GetCameraId()) != c.CameraID: add(fmt.Errorf("%s.calibration.camera.camera_id: want %d, got %d", prefix, c.CameraID, p.GetCameraId())) } @@ -366,25 +372,25 @@ func (d Document) Validate() error { // add a new problem here. if c.Config["network"] != nil { if n, err := networkFromBlock(d.Effective(c)["network"]); err != nil { - add(fmt.Errorf("%s: %w", prefix, err)) + addIn(prefix, err) } else if err := n.Validate(); err != nil { - add(fmt.Errorf("%s: %w", prefix, err)) + addIn(prefix, err) } } if err := validateCamera(d.Effective(c)["camera"]); err != nil { - add(fmt.Errorf("%s: %w", prefix, err)) + addIn(prefix, err) } - if err := validateAdvanced(d.Effective(c)); err != nil { - add(fmt.Errorf("%s: %w", prefix, err)) + if err := validateSettings(d.Effective(c)); err != nil { + addIn(prefix, err) } color, err := colorFromBlock(d.Effective(c)["color"]) if err != nil { - add(fmt.Errorf("%s: %w", prefix, err)) + addIn(prefix, err) } else if err := color.Validate(); err != nil { - add(fmt.Errorf("%s: %w", prefix, err)) + addIn(prefix, err) } } @@ -395,6 +401,30 @@ func (d Document) Validate() error { return nil } +// prefixed puts prefix on every line of err, not just the first as +// fmt.Errorf("%s: %w") would on a joined error, so each line names its own +// key. Nil stays nil. +func prefixed(prefix string, err error) error { + if err == nil { + return nil + } + + lines := strings.Split(err.Error(), "\n") + errs := make([]error, len(lines)) + + for i, line := range lines { + errs[i] = errors.New(prefix + ": " + line) + } + + return errors.Join(errs...) +} + +// Issues is err split into its separate problems, one per line, as Validate +// reports them. +func Issues(err error) []string { + return strings.Split(err.Error(), "\n") +} + func (s Seed) validate(prefix string) error { var errs error @@ -544,10 +574,19 @@ func (d *Document) normalize() { d.Models = normalizeMap(d.Models) d.Defaults = normalizeMap(d.Defaults) + // Never null on the wire, whether the file had cameras: or not. + if d.Cameras == nil { + d.Cameras = []Camera{} + } + for i := range d.Cameras { c := &d.Cameras[i] c.Config = normalizeMap(c.Config) + if c.Seed != nil && c.Seed.LineCorners == nil { + c.Seed.LineCorners = [][2]int{} + } + if c.Calibration != nil { c.Calibration.Camera = normalizeMap(c.Calibration.Camera) } @@ -565,9 +604,12 @@ func normalizeMap(m map[string]any) map[string]any { } // normalize turns integral floats into ints and every number type into int -// or float64, recursively. +// or float64, recursively. Nested blocks become plain maps: yaml.v3 decodes +// a mapping inside a Settings as another Settings. func normalize(v any) any { switch t := v.(type) { + case Settings: + return normalize(map[string]any(t)) case map[string]any: out := make(map[string]any, len(t)) for k, val := range t { @@ -624,6 +666,8 @@ func deepMerge(base, over map[string]any) map[string]any { func deepCopy(v any) any { switch t := v.(type) { + case Settings: + return deepMerge(t, nil) case map[string]any: return deepMerge(t, nil) case []any: diff --git a/gui/internal/config/document_test.go b/gui/internal/config/document_test.go index 9ef9c3c..3b05c02 100644 --- a/gui/internal/config/document_test.go +++ b/gui/internal/config/document_test.go @@ -340,3 +340,45 @@ func TestRenderAllOnlyWritesChangedFiles(t *testing.T) { func (c Camera) clone() Camera { return Document{Version: Version, Cameras: []Camera{c}}.clone().Cameras[0] } + +func TestValidatePrefixesEveryIssue(t *testing.T) { + doc := loadFixture(t) + doc.Field.FieldLength = 0 + doc.Field.GoalWidth = 0 + + err := doc.Validate() + if err == nil { + t.Fatal("Validate: want an error") + } + + issues := Issues(err) + if len(issues) != 2 { + t.Fatalf("Issues = %q, want 2", issues) + } + + for _, issue := range issues { + if !strings.HasPrefix(issue, "field: ") { + t.Errorf("issue %q: want the field: prefix", issue) + } + } +} + +func TestJSONPath(t *testing.T) { + for yamlPath, want := range map[string]string{ + "field.field_width": "field.fieldWidth", + "optional_field_lines.goal2goal": "optionalFieldLines.goal2Goal", + "competition_field": "competitionField", + "teams.by_color.yellow.height": "teams.byColor.yellow.height", + "teams.overrides.N.R.G": "teams.overrides.N.R.G", + "cameras[2].config_path": "cameras[2].configPath", + "cameras[2].seed.line_corners": "cameras[2].seed.lineCorners", + "cameras[2].config.color.orange": "cameras[2].config.color.orange", + "defaults.thresholds.blobs": "defaults.thresholds.blobs", + "host.interfaces.auto": "host.interfaces.auto", + "cameras[0].calibration.locked_at": "cameras[0].calibration.lockedAt", + } { + if got := strings.Join(jsonPath(strings.Split(yamlPath, ".")), "."); got != want { + t.Errorf("jsonPath(%s) = %s, want %s", yamlPath, got, want) + } + } +} diff --git a/gui/internal/config/effective.go b/gui/internal/config/effective.go new file mode 100644 index 0000000..c054048 --- /dev/null +++ b/gui/internal/config/effective.go @@ -0,0 +1,108 @@ +package config + +import ( + "fmt" + "strings" +) + +// Where an effective setting's value comes from. +const ( + SourceCamera = "camera" // the camera's own config + SourceDefaults = "defaults" // the document's shared defaults + SourceDerived = "derived" // written by the host from the document + SourceFallback = "fallback" // unset: vision_processor's own fallback +) + +// EffectiveSetting is one setting as camera's vision_processor runs it. +type EffectiveSetting struct { + Value any `json:"value"` + Source string `json:"source" enum:"camera,defaults,derived,fallback"` + // Known is false for a key the schema doesn't describe: it's written to + // config.yml, but nothing the host knows of reads it. + Known bool `json:"known"` +} + +// EffectiveConfig is every setting of one camera, resolved. +type EffectiveConfig struct { + CameraID int `json:"cameraId"` + // Settings by dotted config.yml path, as in Schema. + Settings map[string]EffectiveSetting `json:"settings"` +} + +// EffectiveConfig resolves camera id's settings the way its vision_processor +// will: its generated config.yml, then vision_processor's fallbacks for +// anything that leaves out. ErrUnknownCamera if there's no such camera. +func (d Document) EffectiveConfig(id int) (EffectiveConfig, error) { + c := d.camera(id) + if c == nil { + return EffectiveConfig{}, ErrUnknownCamera + } + + merged := d.rendered(*c) + out := EffectiveConfig{CameraID: id, Settings: map[string]EffectiveSetting{}} + + sourceOf := func(path string) string { + if s, ok := settings[path]; ok && s.Apply == ApplyDerived { + return SourceDerived + } + + if _, ok := lookup(c.Config, path); ok { + return SourceCamera + } + + return SourceDefaults + } + + for path, s := range settings { + value, ok := lookup(merged, path) + + switch { + case ok || s.Apply == ApplyDerived: + out.Settings[path] = EffectiveSetting{Value: value, Source: sourceOf(path), Known: true} + default: + out.Settings[path] = EffectiveSetting{Value: s.Default, Source: SourceFallback, Known: true} + } + } + + // cameradriver.cpp's path fallback depends on the id in effect. + if p := out.Settings["camera.path"]; p.Source == SourceFallback { + p.Value = fmt.Sprintf("/dev/video%v", out.Settings["camera.id"].Value) + out.Settings["camera.path"] = p + } + + for key, value := range merged { + block, isBlock := value.(map[string]any) + if !isBlock { + if _, known := settings[key]; !known { + out.Settings[key] = EffectiveSetting{Value: value, Source: sourceOf(key)} + } + + continue + } + + for k, v := range block { + path := key + "." + k + if _, known := settings[path]; !known { + out.Settings[path] = EffectiveSetting{Value: v, Source: sourceOf(path)} + } + } + } + + return out, nil +} + +// lookup finds a top-level ("cam_id") or block ("thresholds.blobs") path in +// a config.yml-layout map. +func lookup(m map[string]any, path string) (any, bool) { + block, key, nested := strings.Cut(path, ".") + if !nested { + v, ok := m[path] + + return v, ok + } + + values, _ := m[block].(map[string]any) + v, ok := values[key] + + return v, ok +} diff --git a/gui/internal/config/effective_test.go b/gui/internal/config/effective_test.go new file mode 100644 index 0000000..e487f7a --- /dev/null +++ b/gui/internal/config/effective_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "errors" + "reflect" + "slices" + "testing" +) + +func TestEffectiveConfigResolvesEverySource(t *testing.T) { + doc := loadFixture(t) + doc.Cameras[0].Config["thresholds"] = map[string]any{"made_up": 3} + + eff, err := doc.EffectiveConfig(0) + if err != nil { + t.Fatalf("EffectiveConfig: %v", err) + } + + for path, want := range map[string]EffectiveSetting{ + "camera.path": {Value: "/dev/video0", Source: SourceCamera, Known: true}, + "camera.driver": {Value: "OPENCV", Source: SourceDefaults, Known: true}, + "color.orange": {Value: []any{200, 120, 60}, Source: SourceCamera, Known: true}, + "color.reference_force": {Value: 0.1, Source: SourceDefaults, Known: true}, + "thresholds.blobs": {Value: 2000, Source: SourceFallback, Known: true}, + "cam_id": {Value: 0, Source: SourceDerived, Known: true}, + "geometry.camera_amount": {Value: 2, Source: SourceDerived, Known: true}, + "thresholds.made_up": {Value: 3, Source: SourceCamera}, + "debug.wait_for_geometry": {Value: false, Source: SourceFallback, Known: true}, + "network.vision_port": {Value: 10006, Source: SourceFallback, Known: true}, + "tracking.min_tracking_radius": {Value: 20, Source: SourceFallback, Known: true}, + } { + if got := eff.Settings[path]; !reflect.DeepEqual(got, want) { + t.Errorf("%s = %#v, want %#v", path, got, want) + } + } + + if corners, _ := eff.Settings["geometry.line_corners"].Value.([][2]int); len(corners) != 4 { + t.Errorf("geometry.line_corners = %v, want the seed's 4 corners", eff.Settings["geometry.line_corners"].Value) + } +} + +func TestEffectiveConfigPathFallbackFollowsTheID(t *testing.T) { + doc := loadFixture(t) + doc.Cameras[1].Config = map[string]any{"camera": map[string]any{"id": 3}} + + eff, err := doc.EffectiveConfig(1) + if err != nil { + t.Fatal(err) + } + + if got := eff.Settings["camera.path"]; got.Value != "/dev/video3" || got.Source != SourceFallback { + t.Errorf("camera.path = %+v, want the /dev/video3 fallback", got) + } +} + +func TestEffectiveConfigUnknownCamera(t *testing.T) { + if _, err := loadFixture(t).EffectiveConfig(9); !errors.Is(err, ErrUnknownCamera) { + t.Errorf("err = %v, want ErrUnknownCamera", err) + } +} + +func TestCameraStatusWarnsAboutUnknownSettings(t *testing.T) { + doc := loadFixture(t) + doc.Defaults["thresholds"] = map[string]any{"circularty": 20} + + status := cameraStatus(doc, doc.Cameras[1], nil) + + var codes []string + for _, w := range status.Warnings { + codes = append(codes, w.Code) + } + + if !slices.Contains(codes, "unknown_setting") { + t.Errorf("warnings = %v, want unknown_setting for thresholds.circularty", status.Warnings) + } +} diff --git a/gui/internal/config/patch.go b/gui/internal/config/patch.go new file mode 100644 index 0000000..e9a4ee6 --- /dev/null +++ b/gui/internal/config/patch.go @@ -0,0 +1,69 @@ +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// MergePatch applies a JSON Merge Patch (RFC 7396) to doc's JSON form: keys +// in patch replace doc's, nested objects merge, null removes a key. Arrays +// are replaced whole, so changing one camera means sending the full cameras +// list. A patch naming a key Document doesn't have is rejected. The result is +// not validated; Store.Update does that. +func MergePatch(doc Document, patch any) (Document, error) { + if _, ok := patch.(map[string]any); !ok { + return Document{}, &ValidationError{errors.New("patch: want a JSON object")} + } + + data, err := json.Marshal(doc) + if err != nil { + return Document{}, err + } + + var target any + if err := json.Unmarshal(data, &target); err != nil { + return Document{}, err + } + + merged, err := json.Marshal(mergePatch(target, patch)) + if err != nil { + return Document{}, err + } + + decoder := json.NewDecoder(bytes.NewReader(merged)) + decoder.DisallowUnknownFields() + + var out Document + if err := decoder.Decode(&out); err != nil { + return Document{}, &ValidationError{fmt.Errorf("patch: %w", err)} + } + + return out, nil +} + +// mergePatch is RFC 7396's MergePatch(Target, Patch). +func mergePatch(target, patch any) any { + patchMap, ok := patch.(map[string]any) + if !ok { + return patch + } + + targetMap, _ := target.(map[string]any) + out := make(map[string]any, len(targetMap)+len(patchMap)) + + for k, v := range targetMap { + out[k] = v + } + + for k, v := range patchMap { + if v == nil { + delete(out, k) + } else { + out[k] = mergePatch(out[k], v) + } + } + + return out +} diff --git a/gui/internal/config/patch_test.go b/gui/internal/config/patch_test.go new file mode 100644 index 0000000..94ebf49 --- /dev/null +++ b/gui/internal/config/patch_test.go @@ -0,0 +1,78 @@ +package config + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestMergePatchRFC7396(t *testing.T) { + // RFC 7396 appendix A, a selection. + tests := []struct{ target, patch, want string }{ + {`{"a":"b"}`, `{"a":"c"}`, `{"a":"c"}`}, + {`{"a":"b"}`, `{"b":"c"}`, `{"a":"b","b":"c"}`}, + {`{"a":"b"}`, `{"a":null}`, `{}`}, + {`{"a":{"b":"c"}}`, `{"a":{"b":"d","c":null}}`, `{"a":{"b":"d"}}`}, + {`{"a":[{"b":"c"}]}`, `{"a":[1]}`, `{"a":[1]}`}, + {`{"a":"foo"}`, `{"a":{"b":"c"}}`, `{"a":{"b":"c"}}`}, + } + + for _, tt := range tests { + var target, patch, want any + for _, p := range []struct { + s string + v *any + }{{tt.target, &target}, {tt.patch, &patch}, {tt.want, &want}} { + if err := json.Unmarshal([]byte(p.s), p.v); err != nil { + t.Fatalf("Unmarshal %s: %v", p.s, err) + } + } + + if got := mergePatch(target, patch); !reflect.DeepEqual(got, want) { + t.Errorf("mergePatch(%s, %s) = %v, want %s", tt.target, tt.patch, got, tt.want) + } + } +} + +func TestMergePatchDocument(t *testing.T) { + doc := loadFixture(t) + + var patch any + if err := json.Unmarshal([]byte(`{"field":{"goalWidth":1234},"defaults":{"color":{"reference_force":0.5}}}`), &patch); err != nil { + t.Fatal(err) + } + + got, err := MergePatch(doc, patch) + if err != nil { + t.Fatalf("MergePatch: %v", err) + } + + if got.Field.GoalWidth != 1234 { + t.Errorf("goalWidth = %d, want 1234", got.Field.GoalWidth) + } + + if got.Field.FieldLength != doc.Field.FieldLength { + t.Errorf("fieldLength changed: %d -> %d", doc.Field.FieldLength, got.Field.FieldLength) + } + + if len(got.Cameras) != len(doc.Cameras) { + t.Errorf("cameras: %d, want %d untouched", len(got.Cameras), len(doc.Cameras)) + } +} + +func TestMergePatchRejectsUnknownKey(t *testing.T) { + var patch any + if err := json.Unmarshal([]byte(`{"feild":{"goalWidth":1}}`), &patch); err != nil { + t.Fatal(err) + } + + if _, err := MergePatch(loadFixture(t), patch); !IsValidation(err) { + t.Errorf("MergePatch with unknown key: err = %v, want a validation error", err) + } +} + +func TestMergePatchRejectsNonObject(t *testing.T) { + if _, err := MergePatch(loadFixture(t), []any{}); !IsValidation(err) { + t.Errorf("MergePatch with array: err = %v, want a validation error", err) + } +} diff --git a/gui/internal/config/render.go b/gui/internal/config/render.go index 745a6fd..9d2fd44 100644 --- a/gui/internal/config/render.go +++ b/gui/internal/config/render.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "gopkg.in/yaml.v3" ) @@ -19,25 +20,17 @@ import ( // the network; vision_processor hot-reloads color/thresholds/tracking/debug // from it and needs a restart for everything else. func Render(doc Document, c Camera, sourcePath string) ([]byte, error) { - merged := doc.Effective(c) - merged["cam_id"] = c.CameraID - - geom, _ := merged["geometry"].(map[string]any) - if geom == nil { - geom = map[string]any{} - merged["geometry"] = geom - } + merged := doc.rendered(c) - geom["camera_amount"] = doc.CameraCount() - delete(geom, "line_corners") + if geom, _ := merged["geometry"].(map[string]any); geom["line_corners"] != nil { + corners := geom["line_corners"].([][2]int) + pairs := make([]flowPair, len(corners)) - if c.Seed != nil && len(c.Seed.LineCorners) == 4 { - corners := make([]flowPair, len(c.Seed.LineCorners)) - for i, p := range c.Seed.LineCorners { - corners[i] = flowPair(p) + for i, p := range corners { + pairs[i] = flowPair(p) } - geom["line_corners"] = corners + geom["line_corners"] = pairs } var buf bytes.Buffer @@ -57,6 +50,29 @@ func Render(doc Document, c Camera, sourcePath string) ([]byte, error) { return buf.Bytes(), nil } +// rendered is camera c's config.yml content: the document's defaults with c's +// own config merged over, plus cam_id, camera_amount and the seed's +// line_corners ([][2]int), which the document as a whole decides. +func (d Document) rendered(c Camera) map[string]any { + merged := d.Effective(c) + merged["cam_id"] = c.CameraID + + geom, _ := merged["geometry"].(map[string]any) + if geom == nil { + geom = map[string]any{} + merged["geometry"] = geom + } + + geom["camera_amount"] = d.CameraCount() + delete(geom, "line_corners") + + if c.Seed != nil && len(c.Seed.LineCorners) == 4 { + geom["line_corners"] = slices.Clone(c.Seed.LineCorners) + } + + return merged +} + // flowPair encodes as [x, y] rather than a two-line block sequence. type flowPair [2]int diff --git a/gui/internal/config/schema.go b/gui/internal/config/schema.go new file mode 100644 index 0000000..90ec9ff --- /dev/null +++ b/gui/internal/config/schema.go @@ -0,0 +1,237 @@ +package config + +import ( + "errors" + "fmt" + "math" + "slices" + "strings" +) + +// Setting types. +const ( + TypeInteger = "integer" + TypeNumber = "number" + TypeBoolean = "boolean" + TypeString = "string" + TypeRGB = "rgb" // [r, g, b], 0-255 each + TypeWhiteBalance = "whiteBalance" // "OUTDOOR", "INDOOR", or {red, blue} + TypePoints = "points" // [[x, y], ...] +) + +// When a change to a setting takes effect. +const ( + ApplyLive = "live" // vision_processor reloads it from config.yml within a second + ApplyRestart = "restart" // vision_processor reads it only at startup + ApplyDerived = "derived" // the host writes it from the document; don't set it + ApplyHost = "host" // only the GUI host reads it +) + +// Setting is one key of vision_processor's config.yml layout, as the +// defaults block and each camera's config hold it. +type Setting struct { + Type string `json:"type" enum:"integer,number,boolean,string,rgb,whiteBalance,points"` + // Default is vision_processor's own fallback for an unset key, nil when + // there is none (derived) or it depends on other keys (camera.path). + Default any `json:"default"` + Apply string `json:"apply" enum:"live,restart,derived,host"` + Unit string `json:"unit,omitempty"` + // Bounds the host enforces, inclusive unless Exclusive. + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty"` + Enum []string `json:"enum,omitempty"` + // Range is a sensible span for a control, narrower than the bounds. The + // value itself may be anything within the bounds. + Range *Range `json:"range,omitempty"` +} + +// Range is a control's suggested span and step. +type Range struct { + Min float64 `json:"min"` + Max float64 `json:"max"` + Step float64 `json:"step"` +} + +// Driver is what a camera driver does with the camera: block +// (src/driver/*.cpp). +type Driver struct { + ID string `json:"id"` + // SelectsBy is how the block picks a device: "id", an index in the SDK's + // camera list, or "path", a device or file path. + SelectsBy string `json:"selectsBy" enum:"id,path"` + Gamma bool `json:"gamma"` + // WhiteBalanceProfiles is separate OUTDOOR and INDOOR auto profiles. + WhiteBalanceProfiles bool `json:"whiteBalanceProfiles"` +} + +// Schema describes everything a client needs to edit vision_processor +// settings without reading the C++: served as GET /api/schema. +type Schema struct { + // Settings by dotted config.yml path, e.g. "thresholds.blobs". + Settings map[string]Setting `json:"settings"` + Drivers []Driver `json:"drivers"` + // CameraCounts are the supported layout.cameraCount values. + CameraCounts []int `json:"cameraCounts"` +} + +func bound(v float64) *float64 { return &v } + +func rangeOf(lo, hi, step float64) *Range { return &Range{Min: lo, Max: hi, Step: step} } + +// settings mirrors src/Resources.cpp and src/driver/cameradriver.cpp: every +// key vision_processor reads, its fallback, and whether applyTunables +// reloads it. Keep it in step with them. +var settings = map[string]Setting{ + "cam_id": {Type: TypeInteger, Apply: ApplyDerived}, + "bot_heights_file": {Type: TypeString, Default: DefaultBotHeightsFile, Apply: ApplyRestart}, + + "camera.driver": {Type: TypeString, Default: "SPINNAKER", Apply: ApplyRestart, Enum: CameraDrivers}, + "camera.id": {Type: TypeInteger, Default: 0, Apply: ApplyRestart, Minimum: bound(0)}, + "camera.path": {Type: TypeString, Apply: ApplyRestart}, // /dev/video{id} + "camera.width": {Type: TypeInteger, Default: 0, Apply: ApplyRestart, Unit: "px", Minimum: bound(0)}, + "camera.height": {Type: TypeInteger, Default: 0, Apply: ApplyRestart, Unit: "px", Minimum: bound(0)}, + "camera.exposure": {Type: TypeNumber, Default: 0, Apply: ApplyRestart, Minimum: bound(0)}, + "camera.gain": {Type: TypeNumber, Default: 0, Apply: ApplyRestart, Minimum: bound(0)}, + "camera.gamma": {Type: TypeNumber, Default: 1, Apply: ApplyRestart, Minimum: bound(0), ExclusiveMinimum: true}, + "camera.white_balance": {Type: TypeWhiteBalance, Default: "OUTDOOR", Apply: ApplyRestart, Enum: []string{"OUTDOOR", "INDOOR"}}, + + "network.vision_ip": {Type: TypeString, Default: defaultNetwork.VisionIP, Apply: ApplyRestart}, + "network.vision_port": {Type: TypeInteger, Default: defaultNetwork.VisionPort, Apply: ApplyRestart, Minimum: bound(1), Maximum: bound(65535)}, + "network.gc_ip": {Type: TypeString, Default: defaultNetwork.GCIP, Apply: ApplyRestart}, + "network.gc_port": {Type: TypeInteger, Default: defaultNetwork.GCPort, Apply: ApplyRestart, Minimum: bound(1), Maximum: bound(65535)}, + + "stream.active": {Type: TypeBoolean, Default: true, Apply: ApplyRestart}, + "stream.ip_base_prefix": {Type: TypeString, Default: defaultStream.prefix, Apply: ApplyRestart}, + "stream.ip_base_end": {Type: TypeInteger, Default: defaultStream.end, Apply: ApplyRestart, Minimum: bound(0), Maximum: bound(255)}, + "stream.port": {Type: TypeInteger, Default: defaultStream.port, Apply: ApplyRestart, Minimum: bound(1), Maximum: bound(65535)}, + "stream.raw_feed": {Type: TypeBoolean, Default: false, Apply: ApplyRestart}, + + "geometry.camera_amount": {Type: TypeInteger, Apply: ApplyDerived}, + "geometry.line_corners": {Type: TypePoints, Apply: ApplyDerived}, + "geometry.camera_height": {Type: TypeNumber, Default: 0, Apply: ApplyRestart, Minimum: bound(0)}, + "geometry.refinement": {Type: TypeBoolean, Default: true, Apply: ApplyRestart}, + "geometry.field_line_threshold": {Type: TypeInteger, Default: 5, Apply: ApplyRestart, Minimum: bound(0)}, + "geometry.min_line_segment_length": {Type: TypeNumber, Default: 10, Apply: ApplyRestart, Minimum: bound(0)}, + "geometry.max_line_segment_offset": {Type: TypeNumber, Default: 10, Apply: ApplyRestart, Minimum: bound(0)}, + "geometry.max_line_segment_angle": {Type: TypeNumber, Default: 3, Apply: ApplyRestart, Unit: "°", Minimum: bound(0), Maximum: bound(90)}, + + "thresholds.circularity": {Type: TypeNumber, Default: 15, Apply: ApplyLive, Minimum: bound(0), Range: rangeOf(0, 60, 0.5)}, + "thresholds.score": {Type: TypeNumber, Default: 5, Apply: ApplyLive, Minimum: bound(0), Range: rangeOf(0, 20, 0.5)}, + "thresholds.min_confidence": {Type: TypeNumber, Default: 0.2, Apply: ApplyLive, Minimum: bound(0), Maximum: bound(1), Range: rangeOf(0, 1, 0.01)}, + "thresholds.resampling_factor": {Type: TypeNumber, Default: 1, Apply: ApplyLive, Unit: "×", Minimum: bound(0), ExclusiveMinimum: true, Range: rangeOf(0.25, 2, 0.05)}, + "thresholds.blobs": {Type: TypeInteger, Default: 2000, Apply: ApplyRestart, Minimum: bound(1), Range: rangeOf(100, 10000, 100)}, + "thresholds.min_cam_edge_distance": {Type: TypeNumber, Default: 170, Apply: ApplyLive, Unit: "mm", Minimum: bound(0), Range: rangeOf(0, 500, 5)}, + "thresholds.clipping_tolerance": {Type: TypeNumber, Default: 10, Apply: ApplyLive, Unit: "mm", Minimum: bound(0), Range: rangeOf(0, 50, 1)}, + "thresholds.geometry_tolerance": {Type: TypeNumber, Default: 10, Apply: ApplyRestart, Unit: "mm", Minimum: bound(0), Range: rangeOf(0, 100, 1)}, + + "tracking.min_tracking_radius": {Type: TypeNumber, Default: 20, Apply: ApplyLive, Unit: "mm", Minimum: bound(0), Range: rangeOf(0, 200, 5)}, + "tracking.max_bot_acceleration": {Type: TypeNumber, Default: 6.5, Apply: ApplyLive, Unit: "m/s²", Minimum: bound(0), Range: rangeOf(0, 20, 0.5)}, + + "color.reference_force": {Type: TypeNumber, Default: 0.1, Apply: ApplyLive, Minimum: bound(0), Maximum: bound(1)}, + "color.history_force": {Type: TypeNumber, Default: 0.7, Apply: ApplyLive, Minimum: bound(0), Maximum: bound(1)}, + "color.min_reference_force": {Type: TypeNumber, Default: defaultMinReferenceForce, Apply: ApplyHost, Minimum: bound(0), Maximum: bound(1)}, + "color.orange": {Type: TypeRGB, Default: colorReferenceDefaults["orange"].list(), Apply: ApplyLive}, + "color.field": {Type: TypeRGB, Default: colorReferenceDefaults["field"].list(), Apply: ApplyLive}, + "color.yellow": {Type: TypeRGB, Default: colorReferenceDefaults["yellow"].list(), Apply: ApplyLive}, + "color.blue": {Type: TypeRGB, Default: colorReferenceDefaults["blue"].list(), Apply: ApplyLive}, + "color.green": {Type: TypeRGB, Default: colorReferenceDefaults["green"].list(), Apply: ApplyLive}, + "color.pink": {Type: TypeRGB, Default: colorReferenceDefaults["pink"].list(), Apply: ApplyLive}, + + "debug.ground_truth": {Type: TypeString, Default: "gt.yml", Apply: ApplyRestart}, + "debug.wait_for_geometry": {Type: TypeBoolean, Default: false, Apply: ApplyRestart}, + "debug.debug_images": {Type: TypeBoolean, Default: false, Apply: ApplyLive}, + "debug.debug_stream_interval_ms": {Type: TypeInteger, Default: 0, Apply: ApplyLive, Unit: "ms", Minimum: bound(0)}, +} + +var drivers = []Driver{ + {ID: "SPINNAKER", SelectsBy: "id", Gamma: true, WhiteBalanceProfiles: true}, + {ID: "MVIMPACT", SelectsBy: "id"}, + {ID: "OPENCV", SelectsBy: "path", Gamma: true}, +} + +// CurrentSchema is the schema GET /api/schema serves. +func CurrentSchema() Schema { + return Schema{Settings: settings, Drivers: drivers, CameraCounts: CameraCounts} +} + +func (c RGB) list() []int { return []int{c.R, c.G, c.B} } + +// genericBlocks are validated straight from the schema. camera, network and +// color have their own validators, which also check how keys relate (both +// width and height, reference_force at least min_reference_force). +var genericBlocks = []string{"thresholds", "tracking", "geometry", "stream", "debug"} + +// validateSettings checks the type and bounds of every scalar setting in +// genericBlocks of an effective config. +func validateSettings(effective map[string]any) error { + var errs error + + for path, s := range settings { + block, key, _ := strings.Cut(path, ".") + if !slices.Contains(genericBlocks, block) || s.Apply == ApplyDerived { + continue + } + + values, _ := effective[block].(map[string]any) + + value, ok := values[key] + if !ok || value == nil { + continue + } + + if err := s.check(value); err != nil { + errs = errors.Join(errs, fmt.Errorf("%s: %w", path, err)) + } + } + + return errs +} + +// check reports why value isn't a valid s, or nil. Values have been through +// normalize, so numbers are int or float64. +func (s Setting) check(value any) error { + switch s.Type { + case TypeBoolean: + if _, ok := value.(bool); !ok { + return fmt.Errorf("want true or false, got %v", value) + } + case TypeString: + if _, ok := value.(string); !ok { + return fmt.Errorf("want a string, got %v", value) + } + case TypeInteger, TypeNumber: + var n float64 + + switch v := value.(type) { + case int: + n = float64(v) + case float64: + if s.Type == TypeInteger { + return fmt.Errorf("want a whole number, got %g", v) + } + + n = v + default: + return fmt.Errorf("want a number, got %v", value) + } + + if s.Minimum != nil && (n < *s.Minimum || s.ExclusiveMinimum && n == *s.Minimum) { + if s.ExclusiveMinimum { + return fmt.Errorf("must be above %g, got %g", *s.Minimum, n) + } + + return fmt.Errorf("must be at least %g, got %g", *s.Minimum, n) + } + + if s.Maximum != nil && n > *s.Maximum { + return fmt.Errorf("must be at most %g, got %g", *s.Maximum, n) + } + + if math.IsNaN(n) || math.IsInf(n, 0) { + return fmt.Errorf("want a finite number, got %g", n) + } + } + + return nil +} diff --git a/gui/internal/config/schema_test.go b/gui/internal/config/schema_test.go new file mode 100644 index 0000000..1b5d795 --- /dev/null +++ b/gui/internal/config/schema_test.go @@ -0,0 +1,112 @@ +package config + +import ( + "strings" + "testing" +) + +// withSetting is the fixture with path set in camera 0's own config. +func withSetting(t *testing.T, path string, value any) Document { + t.Helper() + + doc := loadFixture(t) + block, key, _ := strings.Cut(path, ".") + + cam := &doc.Cameras[0] + if cam.Config == nil { + cam.Config = map[string]any{} + } + + values, _ := cam.Config[block].(map[string]any) + if values == nil { + values = map[string]any{} + } + + values[key] = value + cam.Config[block] = values + + return doc +} + +// Every bound the schema advertises must be one Validate enforces, whichever +// validator owns the block. +func TestSchemaBoundsAreEnforced(t *testing.T) { + for path, s := range CurrentSchema().Settings { + if !strings.Contains(path, ".") || s.Apply == ApplyDerived { + continue + } + + _, key, _ := strings.Cut(path, ".") + + var outside []float64 + + if s.Minimum != nil { + if s.ExclusiveMinimum { + outside = append(outside, *s.Minimum) + } else { + outside = append(outside, *s.Minimum-1) + } + } + + if s.Maximum != nil { + outside = append(outside, *s.Maximum+1) + } + + for _, v := range outside { + var value any = v + if s.Type == TypeInteger { + value = int(v) + } + + err := withSetting(t, path, value).Validate() + if err == nil || !strings.Contains(err.Error(), key) { + t.Errorf("%s = %v: Validate = %v, want an error naming %s", path, value, err, key) + } + } + } +} + +func TestSchemaDefaultsAreValid(t *testing.T) { + for path, s := range CurrentSchema().Settings { + if s.Default == nil || !strings.Contains(path, ".") { + continue + } + + doc := withSetting(t, path, s.Default) + + // Width and height are set both or neither. + if pair, ok := map[string]string{"width": "height", "height": "width"}[strings.TrimPrefix(path, "camera.")]; ok { + doc.Cameras[0].Config["camera"].(map[string]any)[pair] = s.Default + } + + if err := doc.Validate(); err != nil { + t.Errorf("%s = %v (its default): Validate = %v", path, s.Default, err) + } + } +} + +func TestSchemaChecksTypes(t *testing.T) { + for path, value := range map[string]any{ + "thresholds.blobs": 1.5, + "tracking.min_tracking_radius": "far", + "stream.active": "yes", + "debug.ground_truth": 3, + } { + if err := withSetting(t, path, value).Validate(); err == nil || !strings.Contains(err.Error(), path) { + t.Errorf("%s = %v: Validate = %v, want an error naming it", path, value, err) + } + } +} + +func TestSchemaColorDefaultsMatchTheFallbacks(t *testing.T) { + color, err := colorFromBlock(nil) + if err != nil { + t.Fatal(err) + } + + s := CurrentSchema().Settings + if s["color.reference_force"].Default != color.ReferenceForce || s["color.history_force"].Default != color.HistoryForce { + t.Errorf("schema color forces = %v/%v, colorFromBlock = %v/%v", + s["color.reference_force"].Default, s["color.history_force"].Default, color.ReferenceForce, color.HistoryForce) + } +} diff --git a/gui/internal/config/status.go b/gui/internal/config/status.go index 2c7b959..0c771ba 100644 --- a/gui/internal/config/status.go +++ b/gui/internal/config/status.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "slices" "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/vision" ) @@ -17,7 +18,7 @@ const ( // the live network state -- never stored. type CameraStatus struct { CameraID int `json:"cameraId"` - Calibration string `json:"calibration"` + Calibration string `json:"calibration" enum:"none,live,locked"` // Live is the latest calibration received from the network (proto field // names), whether or not one is locked -- what "Lock" would store. Live map[string]any `json:"live,omitempty"` @@ -27,6 +28,23 @@ type CameraStatus struct { Warnings []Warning `json:"warnings"` } +// Camera warning codes, stable for clients to key UI off. +const ( + WarningFieldChanged = "field_changed" + WarningCalibrationAspectMismatch = "calibration_aspect_mismatch" + WarningUnknownSetting = "unknown_setting" + WarningSeedStale = "seed_stale" + WarningSeedResolutionUnknown = "seed_resolution_unknown" + WarningSeedRescalable = "seed_rescalable" + WarningSeedAspectMismatch = "seed_aspect_mismatch" +) + +// WarningCodes is every code cameraStatus can raise. +var WarningCodes = []string{ + WarningFieldChanged, WarningCalibrationAspectMismatch, WarningUnknownSetting, WarningSeedStale, + WarningSeedResolutionUnknown, WarningSeedRescalable, WarningSeedAspectMismatch, +} + // Warning is one problem worth surfacing on a camera, with a stable code the // frontend can key UI (e.g. a "rescale corners" button) off. type Warning struct { @@ -54,31 +72,47 @@ func cameraStatus(doc Document, c Camera, live *vision.SSL_GeometryCameraCalibra status.Calibration = CalibrationLocked if c.Calibration.FieldHash != fieldHash(doc.Field) { - warn("field_changed", "The field dimensions changed after this calibration was locked; it was solved against the old field lines.") + warn(WarningFieldChanged, "The field dimensions changed after this calibration was locked; it was solved against the old field lines.") } if locked, err := c.Calibration.Proto(); err == nil && liveKnown { lockedRes := [2]int{int(locked.GetPixelImageWidth()), int(locked.GetPixelImageHeight())} if lockedRes[0] > 0 && lockedRes[1] > 0 && !sameAspect(lockedRes, liveRes) { - warn("calibration_aspect_mismatch", "Locked calibration was solved at %dx%d but the camera now reports %dx%d, a different aspect ratio. vision_processor can't rescale across aspect ratios; unlock and recalibrate.", lockedRes[0], lockedRes[1], liveRes[0], liveRes[1]) + warn(WarningCalibrationAspectMismatch, "Locked calibration was solved at %dx%d but the camera now reports %dx%d, a different aspect ratio. vision_processor can't rescale across aspect ratios; unlock and recalibrate.", lockedRes[0], lockedRes[1], liveRes[0], liveRes[1]) } } } + if eff, err := doc.EffectiveConfig(c.CameraID); err == nil { + var unknown []string + + for path, setting := range eff.Settings { + if !setting.Known { + unknown = append(unknown, path) + } + } + + slices.Sort(unknown) + + for _, path := range unknown { + warn(WarningUnknownSetting, "%s isn't a setting vision_processor reads, so it has no effect. Check the spelling.", path) + } + } + if c.Seed != nil && len(c.Seed.LineCorners) == 4 { seedRes := c.Seed.Resolution if slot := (Slot{CameraID: c.CameraID, CameraCount: doc.CameraCount()}); c.Seed.Slot != nil && *c.Seed.Slot != slot { - warn("seed_stale", "Line corners were picked as camera %d of %d, but this camera is now %d of %d and covers a different region. Re-pick them.", c.Seed.Slot.CameraID, c.Seed.Slot.CameraCount, slot.CameraID, slot.CameraCount) + warn(WarningSeedStale, "Line corners were picked as camera %d of %d, but this camera is now %d of %d and covers a different region. Re-pick them.", c.Seed.Slot.CameraID, c.Seed.Slot.CameraCount, slot.CameraID, slot.CameraCount) } switch { case seedRes[0] == 0 || seedRes[1] == 0: - warn("seed_resolution_unknown", "Line corners were saved without the image resolution they were picked at, so a resolution change can't be detected. Re-save them from the corner picker.") + warn(WarningSeedResolutionUnknown, "Line corners were saved without the image resolution they were picked at, so a resolution change can't be detected. Re-save them from the corner picker.") case liveKnown && seedRes != liveRes && sameAspect(seedRes, liveRes): - warn("seed_rescalable", "Line corners were picked at %dx%d but the camera now reports %dx%d. Same aspect ratio, so they can be rescaled.", seedRes[0], seedRes[1], liveRes[0], liveRes[1]) + warn(WarningSeedRescalable, "Line corners were picked at %dx%d but the camera now reports %dx%d. Same aspect ratio, so they can be rescaled.", seedRes[0], seedRes[1], liveRes[0], liveRes[1]) case liveKnown && seedRes != liveRes: - warn("seed_aspect_mismatch", "Line corners were picked at %dx%d but the camera now reports %dx%d, a different aspect ratio. Re-pick them.", seedRes[0], seedRes[1], liveRes[0], liveRes[1]) + warn(WarningSeedAspectMismatch, "Line corners were picked at %dx%d but the camera now reports %dx%d, a different aspect ratio. Re-pick them.", seedRes[0], seedRes[1], liveRes[0], liveRes[1]) } } diff --git a/gui/internal/config/store.go b/gui/internal/config/store.go index 53448ab..93bd290 100644 --- a/gui/internal/config/store.go +++ b/gui/internal/config/store.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sync" "time" @@ -135,6 +136,59 @@ func (s *Store) Working() (Document, int64) { return s.working.clone(), s.revision } +// File is a YAML file beside the one the store edits. +type File struct { + Path string `json:"path"` + // Current is the file the store loads from and saves to. + Current bool `json:"current"` + // ReadOnly files (rulebook presets, no write permission) can't be saved + // over. + ReadOnly bool `json:"readOnly"` +} + +// Files lists the .yml and .yaml files in the current file's directory: +// candidates for Load and SaveAs. Generated config.yml files are listed +// too; they don't parse as a vision.yml, so Load refuses them. +func (s *Store) Files() ([]File, error) { + s.mu.Lock() + current := s.path + s.mu.Unlock() + + dir := filepath.Dir(current) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + files := []File{} + + for _, e := range entries { + if e.IsDir() || !slices.Contains([]string{".yml", ".yaml"}, filepath.Ext(e.Name())) { + continue + } + + path := filepath.Join(dir, e.Name()) + readOnly := readOnlyFiles[e.Name()] + + if info, err := e.Info(); err == nil && info.Mode().Perm()&0o222 == 0 { + readOnly = true + } + + files = append(files, File{Path: path, Current: path == current, ReadOnly: readOnly}) + } + + return files, nil +} + +// Revision is the working document's current revision. +func (s *Store) Revision() int64 { + s.mu.Lock() + defer s.mu.Unlock() + + return s.revision +} + // Update replaces the working document and applies it live. rev must be the // revision the caller's edit was based on. func (s *Store) Update(rev int64, doc Document) (int64, error) { @@ -331,9 +385,10 @@ func (s *Store) loadLocked(path string) error { return &ValidationError{fmt.Errorf("read %s: %w", path, err)} } + // Parse only fails on content, so this stays a ValidationError. doc, err := Parse(data) if err != nil { - return fmt.Errorf("%s: %w", path, err) + return &ValidationError{prefixed(path, err)} } previousPath := s.path diff --git a/gui/internal/config/stream.go b/gui/internal/config/stream.go index 1cf22b0..c961f6a 100644 --- a/gui/internal/config/stream.go +++ b/gui/internal/config/stream.go @@ -16,6 +16,13 @@ type Stream struct { Address string } +// defaultStream is vision_processor's fallbacks (Resources.cpp) for keys the +// stream block leaves out. +var defaultStream = struct { + prefix string + end, port int +}{prefix: "224.5.23.", end: 100, port: 10100} + // streamBlock is the YAML shape of a stream: block. Pointers tell a missing // key (use vision_processor's fallback) from a zero one. type streamBlock struct { @@ -46,7 +53,7 @@ func (d Document) Stream(id int) (s Stream, ok bool, err error) { } } - prefix, end, port := "224.5.23.", 100, 10100 + prefix, end, port := defaultStream.prefix, defaultStream.end, defaultStream.port if parsed.IPBasePrefix != nil { prefix = *parsed.IPBasePrefix diff --git a/gui/internal/httpjson/httpjson.go b/gui/internal/httpjson/httpjson.go new file mode 100644 index 0000000..d2c285c --- /dev/null +++ b/gui/internal/httpjson/httpjson.go @@ -0,0 +1,135 @@ +// Package httpjson is the host's JSON response conventions: every API reply, +// error or not, is JSON, and every error has the same envelope, so a client +// can branch on a stable code instead of parsing text. +package httpjson + +import ( + "encoding/json" + "fmt" + "log/slog" + "mime" + "net/http" + "slices" +) + +// Error codes. Clients branch on these; Message is for people. +const ( + CodeBadRequest = "bad_request" // malformed request: body, path value, query + CodeInvalid = "invalid" // well-formed, but the content was rejected; see Issues + CodeNotFound = "not_found" + CodeStale = "stale" // edited an older revision; refetch and retry + CodeDiskChanged = "disk_changed" // the file changed on disk since it was loaded + CodeNoLiveCalibration = "no_live_calibration" // nothing received to lock yet + CodePrecondition = "precondition" // a required header (If-Match) is missing + CodeCrossOrigin = "cross_origin" // a browser request on behalf of another site + CodeUnknownHost = "unknown_host" // the request names this host by a name it doesn't answer to + CodeUnsupportedMedia = "unsupported_media" // wrong Content-Type + CodeInternal = "internal" // the host's fault, logged +) + +// statuses is each code's HTTP status. +var statuses = map[string]int{ + CodeBadRequest: http.StatusBadRequest, + CodeInvalid: http.StatusBadRequest, + CodeNotFound: http.StatusNotFound, + CodeStale: http.StatusConflict, + CodeDiskChanged: http.StatusConflict, + CodeNoLiveCalibration: http.StatusConflict, + CodePrecondition: http.StatusPreconditionRequired, + CodeCrossOrigin: http.StatusForbidden, + CodeUnknownHost: http.StatusMisdirectedRequest, + CodeUnsupportedMedia: http.StatusUnsupportedMediaType, + CodeInternal: http.StatusInternalServerError, +} + +// StatusOf is the HTTP status a code is answered with; 500 for one it doesn't +// know. +func StatusOf(code string) int { + if s, ok := statuses[code]; ok { + return s + } + + return http.StatusInternalServerError +} + +// Problem is an error a handler returns to answer with a specific code, e.g. +// Errorf(CodeInvalid, "path: required"). Its status is StatusOf(Code). +type Problem struct { + Code string + Message string + Issues []string +} + +func (p *Problem) Error() string { return p.Message } + +// Errorf is a Problem with code and a formatted message. +func Errorf(code, format string, args ...any) *Problem { + return &Problem{Code: code, Message: fmt.Sprintf(format, args...)} +} + +// ErrorBody is the body of every non-2xx API response. +type ErrorBody struct { + Error string `json:"error" enum:"bad_request,invalid,not_found,stale,disk_changed,no_live_calibration,precondition,cross_origin,unknown_host,unsupported_media,internal"` + Message string `json:"message"` + // Issues lists each problem separately for CodeInvalid. + Issues []string `json:"issues,omitempty"` +} + +// Write sends v as JSON with status. +func Write(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("writing response", "err", err) + } +} + +// Error sends an ErrorBody with status. +func Error(w http.ResponseWriter, status int, code, message string, issues ...string) { + Write(w, status, ErrorBody{Error: code, Message: message, Issues: issues}) +} + +// NotFound is an http.Handler answering CodeNotFound, for unrouted API paths. +func NotFound() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Error(w, http.StatusNotFound, CodeNotFound, "no such endpoint: "+r.Method+" "+r.URL.Path) + }) +} + +// JSONTypes are the media types Decode accepts by default. +var JSONTypes = []string{"application/json"} + +// Decode reads r's JSON body into v, answering the request itself and +// returning false on failure. The Content-Type must be one of types +// (JSONTypes if none), and unknown fields are rejected, so a misspelled key +// fails loudly rather than being dropped and wiping the value it named. +func Decode(w http.ResponseWriter, r *http.Request, v any, types ...string) bool { + if len(types) == 0 { + types = JSONTypes + } + + mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if !slices.Contains(types, mediaType) { + Error(w, http.StatusUnsupportedMediaType, CodeUnsupportedMedia, fmt.Sprintf("Content-Type: want one of %v", types)) + + return false + } + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(v); err != nil { + Error(w, http.StatusBadRequest, CodeBadRequest, "malformed request body: "+err.Error()) + + return false + } + + if decoder.More() { + Error(w, http.StatusBadRequest, CodeBadRequest, "malformed request body: more than one JSON value") + + return false + } + + return true +} diff --git a/gui/internal/hub/hub.go b/gui/internal/hub/hub.go index 8fe58e0..4747b87 100644 --- a/gui/internal/hub/hub.go +++ b/gui/internal/hub/hub.go @@ -3,21 +3,43 @@ // back a size-1 channel of it. A slow subscriber sees only the latest value. package hub -import "sync" +import ( + "slices" + "sync" +) // Hub fans published data out to subscribers by topic. type Hub struct { - mu sync.Mutex - subs map[string]map[chan []byte]struct{} + mu sync.Mutex + topics []string + subs map[string]map[chan []byte]struct{} + latest map[string][]byte } -// New returns an empty Hub. -func New() *Hub { - return &Hub{subs: make(map[string]map[chan []byte]struct{})} +// New returns a Hub for topics, the ones clients may subscribe to. +func New(topics ...string) *Hub { + return &Hub{ + topics: slices.Sorted(slices.Values(topics)), + subs: make(map[string]map[chan []byte]struct{}), + latest: make(map[string][]byte), + } +} + +// Topics lists the topics clients may subscribe to. +func (h *Hub) Topics() []string { + return append([]string{}, h.topics...) } -// Subscribe returns a channel of future values published to topic, and an -// unsubscribe function the caller must call exactly once when done. +// Known reports whether topic is one of the hub's. +func (h *Hub) Known(topic string) bool { + _, ok := slices.BinarySearch(h.topics, topic) + + return ok +} + +// Subscribe returns a channel of values published to topic, starting with +// the latest one if there is any, and an unsubscribe function the caller must +// call exactly once when done. func (h *Hub) Subscribe(topic string) (<-chan []byte, func()) { ch := make(chan []byte, 1) @@ -26,6 +48,10 @@ func (h *Hub) Subscribe(topic string) (<-chan []byte, func()) { h.subs[topic] = make(map[chan []byte]struct{}) } h.subs[topic][ch] = struct{}{} + + if data, ok := h.latest[topic]; ok { + ch <- data + } h.mu.Unlock() unsubscribe := func() { @@ -45,7 +71,8 @@ func (h *Hub) Subscribe(topic string) (<-chan []byte, func()) { return ch, unsubscribe } -// Publish sends data to every current subscriber of topic. A subscriber whose +// Publish sends data to every current subscriber of topic, and keeps it for +// later ones. A subscriber whose // channel is still holding an unread value has that value replaced, not // queued -- matching the size-1, drop-stale semantics the rest of the host // uses for anything published faster than a slow reader drains it. @@ -53,6 +80,8 @@ func (h *Hub) Publish(topic string, data []byte) { h.mu.Lock() defer h.mu.Unlock() + h.latest[topic] = data + for ch := range h.subs[topic] { sendLatest(ch, data) } diff --git a/gui/internal/hub/websocket.go b/gui/internal/hub/websocket.go index 6dc7b29..c1b77f7 100644 --- a/gui/internal/hub/websocket.go +++ b/gui/internal/hub/websocket.go @@ -2,6 +2,7 @@ package hub import ( "encoding/json" + "fmt" "log/slog" "net/http" "sync" @@ -16,14 +17,20 @@ const ( writeWait = 10 * time.Second ) -var upgrader = websocket.Upgrader{} - // clientMessage is what a browser sends: {"action": "subscribe"|"unsubscribe", "topic": "..."}. type clientMessage struct { Action string `json:"action"` Topic string `json:"topic"` } +// errorMessage answers a client message the server can't act on, in the same +// shape as the HTTP API's errors. +type errorMessage struct { + Error string `json:"error"` + Message string `json:"message"` + Topic string `json:"topic,omitempty"` +} + // serverMessage is what a browser receives: {"topic": "...", "data": }. // Data is a RawMessage so an already-JSON-encoded publish is embedded as-is, // not re-encoded as a string. @@ -33,8 +40,11 @@ type serverMessage struct { } // HandleWebSocket upgrades the request and lets the client subscribe to and -// unsubscribe from hub topics for the life of the connection. -func HandleWebSocket(h *Hub) http.HandlerFunc { +// unsubscribe from hub topics for the life of the connection. checkOrigin +// decides which pages may connect; nil allows only the host's own. +func HandleWebSocket(h *Hub, checkOrigin func(*http.Request) bool) http.HandlerFunc { + upgrader := websocket.Upgrader{CheckOrigin: checkOrigin} + return func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { @@ -80,14 +90,34 @@ func serveConnection(h *Hub, conn *websocket.Conn) { return conn.SetReadDeadline(time.Now().Add(pongWait)) }) + reject := func(code, message, topic string) { + data, err := json.Marshal(errorMessage{Error: code, Message: message, Topic: topic}) + if err == nil { + sendLatest(outbound, data) + } + } + for { - var msg clientMessage - if err := conn.ReadJSON(&msg); err != nil { + _, raw, err := conn.ReadMessage() + if err != nil { return } + var msg clientMessage + if err := json.Unmarshal(raw, &msg); err != nil { + reject("bad_request", "malformed message: "+err.Error(), "") + + continue + } + switch msg.Action { case "subscribe": + if !h.Known(msg.Topic) { + reject("unknown_topic", fmt.Sprintf("no such topic; want one of %v", h.Topics()), msg.Topic) + + continue + } + if _, already := unsubscribers[msg.Topic]; already { continue } @@ -107,7 +137,7 @@ func serveConnection(h *Hub, conn *websocket.Conn) { delete(unsubscribers, msg.Topic) } default: - slog.Warn("unknown websocket action", "action", msg.Action) + reject("unknown_action", fmt.Sprintf("action: want subscribe or unsubscribe, got %q", msg.Action), msg.Topic) } } } diff --git a/gui/internal/hub/websocket_test.go b/gui/internal/hub/websocket_test.go index 96d5e4f..ad90a75 100644 --- a/gui/internal/hub/websocket_test.go +++ b/gui/internal/hub/websocket_test.go @@ -15,7 +15,7 @@ import ( func dialTestServer(t *testing.T, h *Hub) *websocket.Conn { t.Helper() - srv := httptest.NewServer(HandleWebSocket(h)) + srv := httptest.NewServer(HandleWebSocket(h, nil)) t.Cleanup(srv.Close) wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") @@ -44,7 +44,7 @@ func readEnvelope(t *testing.T, conn *websocket.Conn) serverMessage { } func TestWebSocketDeliversPublishedData(t *testing.T) { - h := New() + h := New("geometry") conn := dialTestServer(t, h) if err := conn.WriteJSON(clientMessage{Action: "subscribe", Topic: "geometry"}); err != nil { @@ -69,7 +69,7 @@ func TestWebSocketDeliversPublishedData(t *testing.T) { } func TestWebSocketUnsubscribeStopsDelivery(t *testing.T) { - h := New() + h := New("geometry") conn := dialTestServer(t, h) if err := conn.WriteJSON(clientMessage{Action: "subscribe", Topic: "geometry"}); err != nil { @@ -95,7 +95,7 @@ func TestWebSocketUnsubscribeStopsDelivery(t *testing.T) { } func TestWebSocketTwoConnectionsOnSameTopic(t *testing.T) { - h := New() + h := New("geometry") a := dialTestServer(t, h) b := dialTestServer(t, h) @@ -116,3 +116,43 @@ func TestWebSocketTwoConnectionsOnSameTopic(t *testing.T) { } } } + +func TestWebSocketSendsTheLatestValueOnSubscribe(t *testing.T) { + h := New("geometry") + h.Publish("geometry", []byte(`{"x":1}`)) + + conn := dialTestServer(t, h) + if err := conn.WriteJSON(clientMessage{Action: "subscribe", Topic: "geometry"}); err != nil { + t.Fatalf("WriteJSON: %v", err) + } + + if got := readEnvelope(t, conn); string(got.Data) != `{"x":1}` { + t.Errorf("data = %s, want the value published before subscribing", got.Data) + } +} + +func TestWebSocketRejectsWhatItCantAct(t *testing.T) { + h := New("geometry") + conn := dialTestServer(t, h) + + for raw, want := range map[string]string{ + `{"action":"subscribe","topic":"nope"}`: "unknown_topic", + `{"action":"shout","topic":"geometry"}`: "unknown_action", + `not json`: "bad_request", + } { + if err := conn.WriteMessage(websocket.TextMessage, []byte(raw)); err != nil { + t.Fatalf("WriteMessage: %v", err) + } + + conn.SetReadDeadline(time.Now().Add(time.Second)) + + var got errorMessage + if err := conn.ReadJSON(&got); err != nil { + t.Fatalf("%s: ReadJSON: %v", raw, err) + } + + if got.Error != want { + t.Errorf("%s: error = %+v, want %s", raw, got, want) + } + } +} diff --git a/gui/internal/multicast/endpoint.go b/gui/internal/multicast/endpoint.go index 83b6264..0348355 100644 --- a/gui/internal/multicast/endpoint.go +++ b/gui/internal/multicast/endpoint.go @@ -92,7 +92,7 @@ type Status struct { // Mode is how the receiver was opened: "multicast" joined the group, // "port" listens on the port for anything sent there (broadcast, or // unicast to this host). - Mode string `json:"mode"` + Mode string `json:"mode" enum:"multicast,port"` Heard uint64 `json:"heard"` LastHeard *time.Time `json:"lastHeard,omitempty"` Source string `json:"source,omitempty"` diff --git a/gui/internal/multicast/loopback_test.go b/gui/internal/multicast/loopback_test.go new file mode 100644 index 0000000..46b02d8 --- /dev/null +++ b/gui/internal/multicast/loopback_test.go @@ -0,0 +1,153 @@ +package multicast_test + +// Real sockets over loopback: what endpoint_test.go's fake opener stands in +// for. Skipped unless loopback multicast is on (scripts/lo-multicast.sh on). + +import ( + "bytes" + "context" + "net" + "sync" + "testing" + "time" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast/mctest" +) + +// received records what an endpoint's consumer was handed. +type received struct { + mu sync.Mutex + data [][]byte + from []string +} + +func (r *received) consume(data []byte, from *net.UDPAddr) bool { + r.mu.Lock() + defer r.mu.Unlock() + + r.data = append(r.data, append([]byte(nil), data...)) + r.from = append(r.from, from.IP.String()) + + return true +} + +func (r *received) has(want []byte) bool { + r.mu.Lock() + defer r.mu.Unlock() + + for _, d := range r.data { + if bytes.Equal(d, want) { + return true + } + } + + return false +} + +func run(t *testing.T, e *multicast.Endpoint) { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + + go func() { + defer close(done) + _ = e.Run(ctx) + }() + + t.Cleanup(func() { + cancel() + <-done + }) +} + +func TestEndpointReceivesItsGroupOverLoopback(t *testing.T) { + lo := mctest.RequireLoopback(t) + group := mctest.Group(t) + + var got received + + e := multicast.NewEndpoint("test", group, lo, multicast.Options{}, got.consume) + run(t, e) + + mctest.SendUntil(t, group, []byte("hello"), 3*time.Second, func() bool { return got.has([]byte("hello")) }) + + status := e.Status(time.Now()) + if status.Heard == 0 || !status.Receiving || status.Problem != "" || status.Mode != "multicast" { + t.Errorf("status = %+v, want receiving with no problem", status) + } + + got.mu.Lock() + from := got.from[0] + got.mu.Unlock() + + if from != "127.0.0.1" { + t.Errorf("from = %s, want 127.0.0.1", from) + } +} + +// Go binds a multicast address as 0.0.0.0, which on Linux hears every group +// joined on that port by anything on the host. The endpoint turns +// IP_MULTICAST_ALL off so it hears only its own group: cameras sharing a +// port must not mix. +func TestEndpointIgnoresOtherGroupsOnItsPort(t *testing.T) { + lo := mctest.RequireLoopback(t) + group := mctest.Group(t) + + host, port, _ := net.SplitHostPort(group) + other := net.JoinHostPort(otherGroup(host), port) + + // Something else on this machine joined the other group on that port. + mctest.Listen(t, other) + + var got received + + run(t, multicast.NewEndpoint("test", group, lo, multicast.Options{}, got.consume)) + + mctest.SendUntil(t, group, []byte("mine"), 3*time.Second, func() bool { return got.has([]byte("mine")) }) + + for range 5 { + mctest.Send(t, other, []byte("theirs")) + } + + time.Sleep(200 * time.Millisecond) + + if got.has([]byte("theirs")) { + t.Error("endpoint heard a packet sent to another group on its port") + } +} + +func TestEndpointSendsToItsGroupOverLoopback(t *testing.T) { + lo := mctest.RequireLoopback(t) + group := mctest.Group(t) + listener := mctest.Listen(t, group) + + e := multicast.NewEndpoint("test", group, lo, multicast.Options{Send: true}, func([]byte, *net.UDPAddr) bool { return false }) + run(t, e) + + deadline := time.After(3 * time.Second) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + + for { + select { + case data := <-listener: + if bytes.Equal(data, []byte("geometry")) { + return + } + case <-tick.C: + e.Send([]byte("geometry")) + case <-deadline: + t.Fatalf("nothing the endpoint sent arrived on %s", group) + } + } +} + +// otherGroup is a group next to host's, in the same scope. +func otherGroup(host string) string { + ip := net.ParseIP(host).To4() + ip[3] ^= 1 + + return ip.String() +} diff --git a/gui/internal/multicast/mctest/mctest.go b/gui/internal/multicast/mctest/mctest.go new file mode 100644 index 0000000..c6f3be3 --- /dev/null +++ b/gui/internal/multicast/mctest/mctest.go @@ -0,0 +1,193 @@ +// Package mctest sends and receives real multicast over the loopback +// interface, for tests of the host's sockets. Loopback multicast is off by +// default on Ubuntu: RequireLoopback skips a test until it's on +// (scripts/lo-multicast.sh on), and fails it under CI, where the workflow +// turns it on. +package mctest + +import ( + "fmt" + "math/rand/v2" + "net" + "os" + "strconv" + "testing" + "time" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast" + "golang.org/x/net/ipv4" +) + +// RequireLoopback returns the loopback interface, as the only one a socket +// should use, or skips t if it can't carry multicast. +func RequireLoopback(t testing.TB) []multicast.Interface { + t.Helper() + + name, enabled, ok := multicast.LoopbackMulticast() + + switch { + case !ok: + t.Skip("no loopback interface") + case !enabled && os.Getenv("CI") != "": + t.Fatalf("multicast is off on %s; the CI workflow should run scripts/lo-multicast.sh on", name) + case !enabled: + t.Skipf("multicast is off on %s; enable it with scripts/lo-multicast.sh on", name) + } + + for _, i := range multicast.ListInterfaces() { + if i.Name == name { + i.Used = true + + return []multicast.Interface{i} + } + } + + t.Fatalf("loopback %s not in ListInterfaces", name) + + return nil +} + +// Group is a fresh administratively scoped group and a free port, so tests +// running at once don't hear each other. +func Group(t testing.TB) string { + t.Helper() + + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + port := conn.LocalAddr().(*net.UDPAddr).Port + conn.Close() + + return net.JoinHostPort(fmt.Sprintf("239.255.%d.%d", rand.IntN(254)+1, rand.IntN(254)+1), strconv.Itoa(port)) +} + +// Sender is a loopback multicast sender, safe to use from any goroutine. +type Sender struct { + pc *ipv4.PacketConn +} + +// NewSender opens a sender, closed when t ends. +func NewSender(t testing.TB) *Sender { + t.Helper() + + s, closeFn := openSender(t) + t.Cleanup(closeFn) + + return s +} + +func openSender(t testing.TB) (*Sender, func()) { + t.Helper() + + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + pc := ipv4.NewPacketConn(conn) + if err := pc.SetMulticastInterface(loopback(t)); err != nil { + conn.Close() + t.Fatal(err) + } + + if err := pc.SetMulticastLoopback(true); err != nil { + conn.Close() + t.Fatal(err) + } + + return &Sender{pc: pc}, func() { conn.Close() } +} + +// Send sends data to group. +func (s *Sender) Send(group string, data []byte) error { + dst, err := net.ResolveUDPAddr("udp4", group) + if err != nil { + return err + } + + _, err = s.pc.WriteTo(data, nil, dst) + + return err +} + +// Send sends data to group over loopback, once. +func Send(t testing.TB, group string, data []byte) { + t.Helper() + + s, closeFn := openSender(t) + defer closeFn() + + if err := s.Send(group, data); err != nil { + t.Fatal(err) + } +} + +// SendUntil sends data to group every 50 ms until done reports true, failing +// t after timeout. A socket being tested opens asynchronously, so a single +// send could arrive before it's listening. +func SendUntil(t testing.TB, group string, data []byte, timeout time.Duration, done func() bool) { + t.Helper() + + deadline := time.Now().Add(timeout) + + for !done() { + if time.Now().After(deadline) { + t.Fatalf("nothing received on %s after %v", group, timeout) + } + + Send(t, group, data) + time.Sleep(50 * time.Millisecond) + } +} + +// Listen joins group on loopback and returns what arrives, until t ends. +func Listen(t testing.TB, group string) <-chan []byte { + t.Helper() + + addr, err := net.ResolveUDPAddr("udp4", group) + if err != nil { + t.Fatal(err) + } + + conn, err := net.ListenMulticastUDP("udp4", loopback(t), addr) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { conn.Close() }) + + out := make(chan []byte, 16) + + go func() { + buf := make([]byte, 65536) + + for { + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + + select { + case out <- append([]byte(nil), buf[:n]...): + default: + } + } + }() + + return out +} + +func loopback(t testing.TB) *net.Interface { + t.Helper() + + name, _, _ := multicast.LoopbackMulticast() + + ifi, err := net.InterfaceByName(name) + if err != nil { + t.Fatal(err) + } + + return ifi +} diff --git a/gui/internal/origins/origins.go b/gui/internal/origins/origins.go new file mode 100644 index 0000000..dc1e6f5 --- /dev/null +++ b/gui/internal/origins/origins.go @@ -0,0 +1,228 @@ +// Package origins decides which web pages may use the host's API from a +// browser, and which names the host answers to. +// +// Origins: the host's own pages are always allowed. In a lab, so is any page +// served from this network (localhost, a private or link-local IP, a .local +// or single-label name): a public website can't have such an origin, since +// the browser sets Origin from the page's real address. On a competition +// field (SetStrict) only the host's own pages and the trusted list are. Clients +// outside a browser send no Origin and are unaffected. +// +// Hosts: a request must name the host by something it really is (an IP, +// localhost, a .local or single-label name, its own hostname, or an allowed +// alias). That stops DNS rebinding, where an attacker's domain is pointed at +// this host so the browser treats it as same-origin. +package origins + +import ( + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "sync" + "sync/atomic" +) + +// Policy is the origin and host rules. +type Policy struct { + trusted map[string]bool + trustAll bool + allowAny bool + strict atomic.Bool + hostsMu sync.RWMutex + ownHosts map[string]bool + allowHosts map[string]bool +} + +// Options configures a Policy. +type Options struct { + // TrustedOrigins are allowed in both modes, written scheme://host[:port]. + // "*" trusts every origin. + TrustedOrigins []string + // AllowedHosts are extra names this host answers to, such as a DNS alias. + // "*" turns the host check off. + AllowedHosts []string +} + +// New builds a Policy in lab mode. +func New(opts Options) (*Policy, error) { + p := &Policy{trusted: map[string]bool{}, ownHosts: map[string]bool{}, allowHosts: map[string]bool{}} + + for _, origin := range opts.TrustedOrigins { + if origin == "*" { + p.trustAll = true + + continue + } + + u, err := url.Parse(origin) + if err != nil || u.Scheme == "" || u.Host == "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" { + return nil, fmt.Errorf("origin %q: want scheme://host[:port] or *", origin) + } + + p.trusted[strings.ToLower(u.Scheme+"://"+u.Host)] = true + } + + for _, host := range opts.AllowedHosts { + if host == "*" { + p.allowAny = true + + continue + } + + p.allowHosts[strings.ToLower(host)] = true + } + + return p, nil +} + +// SetStrict switches between lab mode (false) and competition mode (true). +func (p *Policy) SetStrict(strict bool) { p.strict.Store(strict) } + +// AddOwnHosts records names this machine goes by, such as its hostname and +// the names its addresses reverse-resolve to. +func (p *Policy) AddOwnHosts(names ...string) { + p.hostsMu.Lock() + defer p.hostsMu.Unlock() + + for _, name := range names { + if name = strings.TrimSuffix(strings.ToLower(name), "."); name != "" { + p.ownHosts[name] = true + } + } +} + +// HostAllowed reports whether r names this host by something it is. +func (p *Policy) HostAllowed(r *http.Request) bool { + if p.allowAny { + return true + } + + // Rebinding needs a domain name, so any IP literal is safe. + host := hostname(r.Host) + if _, err := netip.ParseAddr(host); err == nil || isLocalName(host) { + return true + } + + p.hostsMu.RLock() + defer p.hostsMu.RUnlock() + + return p.ownHosts[host] || p.allowHosts[host] +} + +// Allowed reports whether a browser page at r's Origin may use the API: no +// Origin (not a browser), the host's own origin, or a trusted one. For +// WebSocket upgrades as well as Handler. +func (p *Policy) Allowed(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + + u, err := url.Parse(origin) + if err != nil { + return false + } + + return strings.EqualFold(u.Host, r.Host) || p.trustedOrigin(u) +} + +func (p *Policy) trustedOrigin(u *url.URL) bool { + if p.trustAll || p.trusted[strings.ToLower(u.Scheme+"://"+u.Host)] { + return true + } + + return !p.strict.Load() && isLocalName(hostname(u.Host)) +} + +// Handler wraps next. A request naming an unknown host gets badHost. A +// state-changing request a browser sends on behalf of an untrusted page gets +// deny, as http.CrossOriginProtection decides it (Sec-Fetch-Site, else +// Origin). A trusted page gets the CORS headers that let it read responses, +// and its preflights are answered here. +func (p *Policy) Handler(next, deny, badHost http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !p.HostAllowed(r) { + badHost.ServeHTTP(w, r) + + return + } + + origin := r.Header.Get("Origin") + u, _ := url.Parse(origin) + trusted := origin != "" && u != nil && !strings.EqualFold(u.Host, r.Host) && p.trustedOrigin(u) + + if trusted { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + h.Set("Access-Control-Expose-Headers", "ETag") + + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE") + h.Set("Access-Control-Allow-Headers", "Content-Type, If-Match") + h.Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + + return + } + } + + if !safeMethod(r.Method) && !trusted && !p.sameOrigin(r) { + deny.ServeHTTP(w, r) + + return + } + + next.ServeHTTP(w, r) + }) +} + +// sameOrigin is http.CrossOriginProtection's test: Sec-Fetch-Site if the +// browser sent it, else Origin against Host, else not a browser. +func (p *Policy) sameOrigin(r *http.Request) bool { + switch r.Header.Get("Sec-Fetch-Site") { + case "same-origin", "none": + return true + case "": + default: + return false + } + + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + + u, err := url.Parse(origin) + + return err == nil && strings.EqualFold(u.Host, r.Host) +} + +func safeMethod(method string) bool { + return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions +} + +// hostname is host without its port, lowercased, without a trailing dot. +func hostname(hostport string) string { + host := hostport + if h, _, err := net.SplitHostPort(hostport); err == nil { + host = h + } + + return strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".") +} + +// isLocalName reports whether host can only mean something on this network: +// localhost, an IP literal that is loopback, private or link-local, a .local +// (mDNS) name, or a single-label name. No public website has one. +func isLocalName(host string) bool { + if addr, err := netip.ParseAddr(host); err == nil { + return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() + } + + return host == "localhost" || strings.HasSuffix(host, ".localhost") || + strings.HasSuffix(host, ".local") || (host != "" && !strings.Contains(host, ".")) +} diff --git a/gui/internal/origins/origins_test.go b/gui/internal/origins/origins_test.go new file mode 100644 index 0000000..f71268e --- /dev/null +++ b/gui/internal/origins/origins_test.go @@ -0,0 +1,179 @@ +package origins + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func request(method, host, origin string) *http.Request { + r := httptest.NewRequest(method, "http://"+host+"/api/config/reload", nil) + if origin != "" { + r.Header.Set("Origin", origin) + } + + return r +} + +func policy(t *testing.T, opts Options) *Policy { + t.Helper() + + p, err := New(opts) + if err != nil { + t.Fatal(err) + } + + return p +} + +func TestNewRejectsMalformedOrigins(t *testing.T) { + for _, bad := range []string{"localhost:5173", "http://", "http://a/path", "http://a/?q=1"} { + if _, err := New(Options{TrustedOrigins: []string{bad}}); err == nil { + t.Errorf("New(%q): want an error", bad) + } + } +} + +func TestAllowedInTheLab(t *testing.T) { + p := policy(t, Options{TrustedOrigins: []string{"https://ui.example.org"}}) + + for origin, want := range map[string]bool{ + "": true, // not a browser + "http://vision-pc:8085": true, // the host's own page + "http://localhost:5173": true, + "http://127.0.0.1:5173": true, + "http://192.168.1.20:3000": true, + "http://10.0.0.7": true, + "http://[fe80::1]:3000": true, + "http://laptop.local:5173": true, + "http://laptop:5173": true, + "https://ui.example.org": true, // listed + "http://evil.example": false, + "http://8.8.8.8": false, + "http://192.168.1.20.evil.io": false, + } { + if got := p.Allowed(request(http.MethodGet, "vision-pc:8085", origin)); got != want { + t.Errorf("lab: Allowed(Origin %q) = %v, want %v", origin, got, want) + } + } +} + +func TestAllowedOnACompetitionField(t *testing.T) { + p := policy(t, Options{TrustedOrigins: []string{"https://ui.example.org"}}) + p.SetStrict(true) + + for origin, want := range map[string]bool{ + "": true, + "http://vision-pc:8085": true, + "https://ui.example.org": true, + "http://192.168.1.20:3000": false, + "http://localhost:5173": false, + "http://evil.example": false, + } { + if got := p.Allowed(request(http.MethodGet, "vision-pc:8085", origin)); got != want { + t.Errorf("strict: Allowed(Origin %q) = %v, want %v", origin, got, want) + } + } + + p.SetStrict(false) + + if !p.Allowed(request(http.MethodGet, "vision-pc:8085", "http://192.168.1.20:3000")) { + t.Error("back in the lab: a LAN origin should be allowed again") + } +} + +func TestTrustAll(t *testing.T) { + p := policy(t, Options{TrustedOrigins: []string{"*"}}) + p.SetStrict(true) + + if !p.Allowed(request(http.MethodGet, "vision-pc:8085", "http://evil.example")) { + t.Error(`"*" should trust every origin`) + } +} + +func TestHostAllowed(t *testing.T) { + p := policy(t, Options{AllowedHosts: []string{"vision.example.edu"}}) + p.AddOwnHosts("vision-pc.cs.example.edu.") + + for host, want := range map[string]bool{ + "192.168.1.50:8085": true, + "203.0.113.9:8085": true, // any IP literal + "[::1]:8085": true, + "localhost:8085": true, + "vision-pc:8085": true, + "vision-pc.local:8085": true, + "vision-pc.cs.example.edu:8085": true, // its own reverse-DNS name + "VISION.example.edu": true, // listed alias + "evil.example:8085": false, // a rebinding attacker's name + "vision-pc.evil.example": false, + } { + if got := p.HostAllowed(request(http.MethodGet, host, "")); got != want { + t.Errorf("HostAllowed(%q) = %v, want %v", host, got, want) + } + } + + if !policy(t, Options{AllowedHosts: []string{"*"}}).HostAllowed(request(http.MethodGet, "evil.example", "")) { + t.Error(`"*" should turn the host check off`) + } +} + +func TestHandler(t *testing.T) { + p := policy(t, Options{}) + + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + deny := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }) + badHost := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusMisdirectedRequest) }) + h := p.Handler(ok, deny, badHost) + + for _, tc := range []struct { + name, host, origin, site string + want int + cors bool + }{ + {"same origin", "vision-pc:8085", "http://vision-pc:8085", "", http.StatusNoContent, false}, + {"same origin by fetch metadata", "vision-pc:8085", "", "same-origin", http.StatusNoContent, false}, + {"lab UI on the LAN", "vision-pc:8085", "http://192.168.1.20:3000", "cross-site", http.StatusNoContent, true}, + {"public site", "vision-pc:8085", "http://evil.example", "cross-site", http.StatusForbidden, false}, + {"cross-site without Origin", "vision-pc:8085", "", "cross-site", http.StatusForbidden, false}, + {"no browser", "vision-pc:8085", "", "", http.StatusNoContent, false}, + {"DNS rebinding", "evil.example:8085", "http://evil.example:8085", "same-origin", http.StatusMisdirectedRequest, false}, + } { + req := request(http.MethodPost, tc.host, tc.origin) + if tc.site != "" { + req.Header.Set("Sec-Fetch-Site", tc.site) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != tc.want { + t.Errorf("%s: status = %d, want %d", tc.name, rec.Code, tc.want) + } + + if got := rec.Header().Get("Access-Control-Allow-Origin") != ""; got != tc.cors { + t.Errorf("%s: CORS headers = %v, want %v", tc.name, got, tc.cors) + } + } + + preflight := request(http.MethodOptions, "vision-pc:8085", "http://localhost:5173") + preflight.Header.Set("Access-Control-Request-Method", "PATCH") + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, preflight) + + if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Headers") == "" { + t.Errorf("preflight = %d %v, want 204 with allowed headers", rec.Code, rec.Header()) + } + + p.SetStrict(true) + + req := request(http.MethodPost, "vision-pc:8085", "http://192.168.1.20:3000") + req.Header.Set("Sec-Fetch-Site", "cross-site") + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("competition: LAN UI write = %d, want 403", rec.Code) + } +} diff --git a/gui/internal/snapshot/snapshot.go b/gui/internal/snapshot/snapshot.go index dffd120..6023a56 100644 --- a/gui/internal/snapshot/snapshot.go +++ b/gui/internal/snapshot/snapshot.go @@ -7,13 +7,15 @@ package snapshot import ( - "encoding/json" "log/slog" "net/http" "os" "path/filepath" "regexp" "sort" + "strconv" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson" ) // filename matches "..", e.g. "0.raw.jpg" or @@ -24,33 +26,15 @@ import ( // validSegment below for the regex that actually guards a filepath.Glob call. var filename = regexp.MustCompile(`^(\d+)\.(.+)\.(?:jpg|jpeg|png)$`) -// Entry identifies one available snapshot. Field names match what the -// frontend already expects. +// Entry identifies one available snapshot. type Entry struct { - CamID string `json:"cam_id"` - View string `json:"view"` + CameraID int `json:"cameraId"` + View string `json:"view"` } -// HandleList serves the set of snapshots currently on disk in dir. -func HandleList(dir string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - entries, err := list(dir) - if err != nil { - slog.Error("listing snapshots", "dir", dir, "err", err) - http.Error(w, "internal error", http.StatusInternalServerError) - - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(entries); err != nil { - slog.Error("writing snapshot list response", "err", err) - } - } -} - -func list(dir string) ([]Entry, error) { +// List is the debug images vision_processor has written to dir (-imgDir), +// one entry per camera and view, sorted. A missing dir has none. +func List(dir string) ([]Entry, error) { files, err := os.ReadDir(dir) if os.IsNotExist(err) { return []Entry{}, nil @@ -68,7 +52,12 @@ func list(dir string) ([]Entry, error) { continue } - seen[Entry{CamID: m[1], View: m[2]}] = struct{}{} + id, err := strconv.Atoi(m[1]) + if err != nil { + continue // too many digits for an int + } + + seen[Entry{CameraID: id, View: m[2]}] = struct{}{} } entries := make([]Entry, 0, len(seen)) @@ -80,8 +69,8 @@ func list(dir string) ([]Entry, error) { // and map iteration is randomised on top of that -- fix an order so the // grid does not reshuffle itself every 5s poll. sort.Slice(entries, func(i, j int) bool { - if entries[i].CamID != entries[j].CamID { - return entries[i].CamID < entries[j].CamID + if entries[i].CameraID != entries[j].CameraID { + return entries[i].CameraID < entries[j].CameraID } return entries[i].View < entries[j].View @@ -98,14 +87,15 @@ func list(dir string) ([]Entry, error) { // (*, ?, [, ]), which is what actually matters; dots are harmless here. var validSegment = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) -// HandleGet serves the most recently written {camID}.{view}.* file in dir. +// HandleGet serves a camera's latest debug image for one view, e.g. +// /api/snapshot/0/raw. func HandleGet(dir string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { camID := r.PathValue("camID") view := r.PathValue("view") if !validSegment.MatchString(camID) || !validSegment.MatchString(view) { - http.NotFound(w, r) + httpjson.Error(w, http.StatusNotFound, httpjson.CodeNotFound, "no such snapshot") return } @@ -113,13 +103,13 @@ func HandleGet(dir string) http.HandlerFunc { path, err := latest(dir, camID, view) if err != nil { slog.Error("finding snapshot", "cam", camID, "view", view, "err", err) - http.Error(w, "internal error", http.StatusInternalServerError) + httpjson.Error(w, http.StatusInternalServerError, httpjson.CodeInternal, "internal error") return } if path == "" { - http.NotFound(w, r) + httpjson.Error(w, http.StatusNotFound, httpjson.CodeNotFound, "no such snapshot") return } diff --git a/gui/internal/snapshot/snapshot_test.go b/gui/internal/snapshot/snapshot_test.go index b0328ea..08f187c 100644 --- a/gui/internal/snapshot/snapshot_test.go +++ b/gui/internal/snapshot/snapshot_test.go @@ -1,7 +1,6 @@ package snapshot import ( - "encoding/json" "net/http" "net/http/httptest" "os" @@ -19,7 +18,7 @@ func writeFile(t *testing.T, dir, name string) { } func TestListReturnsEmptyForMissingDir(t *testing.T) { - entries, err := list(filepath.Join(t.TempDir(), "does-not-exist")) + entries, err := List(filepath.Join(t.TempDir(), "does-not-exist")) if err != nil { t.Fatalf("list: %v", err) } @@ -36,12 +35,12 @@ func TestListFindsMatchingFilesAndSkipsOthers(t *testing.T) { writeFile(t, dir, "not-a-snapshot.txt") writeFile(t, dir, "0.calib.json") // legacy / non-image extension - entries, err := list(dir) + entries, err := List(dir) if err != nil { t.Fatalf("list: %v", err) } - want := []Entry{{CamID: "0", View: "raw"}, {CamID: "1", View: "calib"}} + want := []Entry{{CameraID: 0, View: "raw"}, {CameraID: 1, View: "calib"}} if len(entries) != len(want) { t.Fatalf("entries = %v, want %v", entries, want) } @@ -59,12 +58,12 @@ func TestListHandlesADottedViewName(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "0.pixels.corner.png") - entries, err := list(dir) + entries, err := List(dir) if err != nil { t.Fatalf("list: %v", err) } - want := Entry{CamID: "0", View: "pixels.corner"} + want := Entry{CameraID: 0, View: "pixels.corner"} if len(entries) != 1 || entries[0] != want { t.Fatalf("entries = %v, want [%v]", entries, want) } @@ -75,7 +74,7 @@ func TestListDedupesSameCameraAndView(t *testing.T) { writeFile(t, dir, "0.raw.jpg") writeFile(t, dir, "0.raw.png") - entries, err := list(dir) + entries, err := List(dir) if err != nil { t.Fatalf("list: %v", err) } @@ -85,27 +84,6 @@ func TestListDedupesSameCameraAndView(t *testing.T) { } } -func TestHandleListServesJSON(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "0.raw.jpg") - - rec := httptest.NewRecorder() - HandleList(dir).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/snapshots", nil)) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - - var got []Entry - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("Unmarshal: %v", err) - } - - if len(got) != 1 || got[0] != (Entry{CamID: "0", View: "raw"}) { - t.Errorf("got %v", got) - } -} - func snapshotMux(dir string) http.Handler { mux := http.NewServeMux() mux.Handle("GET /api/snapshot/{camID}/{view}", HandleGet(dir)) diff --git a/gui/internal/v4l/v4l.go b/gui/internal/v4l/v4l.go index f556d6b..bc680f7 100644 --- a/gui/internal/v4l/v4l.go +++ b/gui/internal/v4l/v4l.go @@ -21,7 +21,7 @@ type Device struct { // Kind is "by-id" (follows the camera), "by-path" (follows the USB // port), or "node" (/dev/videoN, which can change when devices // re-enumerate). - Kind string `json:"kind"` + Kind string `json:"kind" enum:"by-id,by-path,node"` } // Roots are where List looks; tests point them at a fake tree. diff --git a/gui/internal/video/loopback_test.go b/gui/internal/video/loopback_test.go new file mode 100644 index 0000000..55ff43f --- /dev/null +++ b/gui/internal/video/loopback_test.go @@ -0,0 +1,101 @@ +package video + +// A camera's RTP stream over real loopback multicast, relayed to a viewer +// over /ws/video: what source_test's fakeEndpoint stands in for. Skipped +// unless loopback multicast is on (scripts/lo-multicast.sh on). + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/multicast/mctest" + "github.com/gorilla/websocket" +) + +func TestViewerGetsALoopbackStreamAsFMP4(t *testing.T) { + lo := mctest.RequireLoopback(t) + group := mctest.Group(t) + + m := NewManager(false) + m.Configure(map[int]Stream{0: {Active: true, Address: group}}, lo) + + mux := http.NewServeMux() + mux.Handle("GET /ws/video/{id}", HandleWebSocket(m, nil)) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + // The source opens its socket for its first viewer. + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws/video/0", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { conn.Close() }) + + // Replay the clip as RTP until the viewer has what a player needs. + clip := loadClip(t, "320x240.h264") + sender := mctest.NewSender(t) + ctx, stop := context.WithCancel(context.Background()) + t.Cleanup(stop) + + go func() { + p := &packetizer{ssrc: 0x5eed} + + for ctx.Err() == nil { + for _, au := range clip { + for _, pkt := range p.packets(au) { + data, err := pkt.Marshal() + if err != nil || ctx.Err() != nil { + return + } + + _ = sender.Send(group, data) + } + + time.Sleep(5 * time.Millisecond) + } + } + }() + + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + + var format struct { + Type string + Width int + Height int + Codec string + } + + binaries := 0 + + for format.Type != "format" || binaries < 2 { + kind, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("after format %+v and %d segments: %v", format, binaries, err) + } + + if kind == websocket.BinaryMessage { + binaries++ + + continue + } + + var head struct{ Type string } + if json.Unmarshal(data, &head) == nil && head.Type == "format" { + if err := json.Unmarshal(data, &format); err != nil { + t.Fatal(err) + } + } + } + + if format.Width != 320 || format.Height != 240 || !strings.HasPrefix(format.Codec, "avc1.") { + t.Errorf("format = %+v, want 320x240 H.264", format) + } +} diff --git a/gui/internal/video/video_test.go b/gui/internal/video/video_test.go index 3c02444..d740d04 100644 --- a/gui/internal/video/video_test.go +++ b/gui/internal/video/video_test.go @@ -453,7 +453,7 @@ func TestHandleWebSocketRejectsBadRequests(t *testing.T) { m.Configure(map[int]Stream{0: {Active: true, Address: "224.5.23.100:10100"}}, nil) mux := http.NewServeMux() - mux.Handle("GET /ws/video/{id}", HandleWebSocket(m)) + mux.Handle("GET /ws/video/{id}", HandleWebSocket(m, nil)) srv := httptest.NewServer(mux) defer srv.Close() diff --git a/gui/internal/video/websocket.go b/gui/internal/video/websocket.go index d3b9867..a42f68c 100644 --- a/gui/internal/video/websocket.go +++ b/gui/internal/video/websocket.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/RoboCup-SSL/ssl-vision-processor/gui/internal/httpjson" "github.com/gorilla/websocket" ) @@ -16,16 +17,17 @@ const ( writeWait = 10 * time.Second ) -var upgrader = websocket.Upgrader{} - // HandleWebSocket serves GET /ws/video/{id}?mode=full|keyframes: one camera's // stream as JSON format and status messages and binary fMP4 segments, ready -// for a MediaSource SourceBuffer in sequence mode. -func HandleWebSocket(m *Manager) http.HandlerFunc { +// for a MediaSource SourceBuffer in sequence mode. checkOrigin decides which +// pages may connect; nil allows only the host's own. +func HandleWebSocket(m *Manager, checkOrigin func(*http.Request) bool) http.HandlerFunc { + upgrader := websocket.Upgrader{CheckOrigin: checkOrigin} + return func(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { - http.Error(w, "camera id must be a number", http.StatusBadRequest) + httpjson.Error(w, http.StatusBadRequest, httpjson.CodeBadRequest, "id: want a camera_id") return } @@ -36,7 +38,7 @@ func HandleWebSocket(m *Manager) http.HandlerFunc { } if mode != ModeFull && mode != ModeKeyframes { - http.Error(w, `mode must be "full" or "keyframes"`, http.StatusBadRequest) + httpjson.Error(w, http.StatusBadRequest, httpjson.CodeBadRequest, `mode: want "full" or "keyframes"`) return } @@ -44,7 +46,7 @@ func HandleWebSocket(m *Manager) http.HandlerFunc { // Subscribed before upgrading, so an unknown camera is a plain 404. ch, unsubscribe, err := m.Subscribe(id, mode) if errors.Is(err, ErrUnknownCamera) { - http.Error(w, err.Error(), http.StatusNotFound) + httpjson.Error(w, http.StatusNotFound, httpjson.CodeNotFound, err.Error()) return } diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..9f0f150 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,23 @@ +# scripts + +Small utilities for developing and running vision_processor and its GUI host. Run them from anywhere; each prints +its usage for an argument it doesn't know. + +| Script | What it does | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `lo-multicast.sh` | Checks (`status`), enables (`on`) or disables (`off`) multicast on loopback. Needs sudo to change it. | + +## lo-multicast.sh + +vision_processor and the GUI host exchange multicast packets. When both run on one machine, or a test sends and +receives on the same machine, those packets go over loopback, which Ubuntu ships with multicast off. The GUI host +warns about this at startup. + +``` +scripts/lo-multicast.sh # status: exit 0 if on, 1 if off +scripts/lo-multicast.sh on # enable, until the next reboot +scripts/lo-multicast.sh off # back to Ubuntu's default +scripts/lo-multicast.sh status eth0 +``` + +CI uses `on` before tests that send real multicast over loopback. diff --git a/scripts/lo-multicast.sh b/scripts/lo-multicast.sh new file mode 100755 index 0000000..55611c1 --- /dev/null +++ b/scripts/lo-multicast.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# Check, enable, or disable multicast on the loopback interface. +# +# vision_processor and the gui host talk over multicast. When both run on one +# machine, or a test sends and receives on the same machine, the packets go +# over loopback, which Ubuntu ships with multicast off. The change takes effect +# at once and lasts until reboot. +# +# Usage: scripts/lo-multicast.sh [status|on|off] [interface] +# status print whether multicast is on; exit 0 if on, 1 if off (default) +# on enable it (uses sudo when not root) +# off disable it again (uses sudo when not root) +# interface defaults to lo + +set -euo pipefail + +action="${1:-status}" +iface="${2:-lo}" + +if ! command -v ip >/dev/null; then + echo "lo-multicast: the ip command (iproute2) is required" >&2 + exit 2 +fi + +if ! ip link show "$iface" >/dev/null 2>&1; then + echo "lo-multicast: no interface named $iface" >&2 + exit 2 +fi + +enabled() { + # The flags list, e.g. . + ip -o link show "$iface" | grep -q '<[^>]*\bMULTICAST\b[^>]*>' +} + +as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + +case "$action" in +status) + if enabled; then + echo "multicast is on for $iface" + else + echo "multicast is off for $iface; enable it with: $0 on $iface" + exit 1 + fi + ;; +on | off) + as_root ip link set "$iface" multicast "$action" + if enabled; then state=on; else state=off; fi + echo "multicast is now $state for $iface" + [ "$state" = "$action" ] + ;; +*) + echo "usage: $0 [status|on|off] [interface]" >&2 + exit 2 + ;; +esac