diff --git a/CLAUDE.md b/CLAUDE.md index 1e20a896..22570b0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,10 @@ FastAPI backend and React front-ends for the RETINA passive-radar network. ONBOARDING, "Before you push", for what it runs and where it lies to you. - **Every PR runs the full matrix**, whatever it is based on. Branches opened before #187 predate that and ran nothing unless they targeted `main`. -- **The node API contract is generated.** Change a route under `/v1/nodes` or one - of its models and `contracts/nodes-v1.openapi.yaml` moves with it; regenerate it - in the same commit or CI fails. See ONBOARDING, "Before you push". +- **The node API contract is generated.** Change a route under `/v1/nodes`, one of + its models, or a configuration bound in `backend/services/node_config.py`, and + `contracts/nodes-v1.openapi.yaml` moves with it; regenerate it in the same commit + or CI fails. See ONBOARDING, "Before you push". - **This repo is public.** Refer to hosts by SSH alias, never by address, as `justfile` already does. No credentials, no droplet addresses, no personal accounts in anything committed here. diff --git a/ONBOARDING.md b/ONBOARDING.md index 91302e89..78f38bef 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -200,8 +200,11 @@ twice, once per copy of the shared standard in this repo. A change can pass `ruff check` and `ruff format` by hand and still fail CI on dead code. Touching a node route or one of its models also moves the node API's wire -contract, which is generated rather than written. Regenerate it in the same -commit, or CI fails on a file you never edited: +contract, which is generated rather than written. So does changing a +configuration bound: the schema published for `config` is built from the +validator's own tables, so `backend/services/node_config.py` moves the contract +with no route touched. Regenerate it in the same commit, or CI fails on a file +you never edited: ```bash cd backend && RETINA_ENV=dev .venv/bin/python -m scripts.generate_openapi diff --git a/backend/routes/node_config.py b/backend/routes/node_config.py index 81e79a30..1c640016 100644 --- a/backend/routes/node_config.py +++ b/backend/routes/node_config.py @@ -22,7 +22,7 @@ from routes.node_responses import INVALID_CONFIG, NODE_BODY_LIMITS, SERVER_ERROR, TOO_LARGE, UNAUTHORIZED from routes.node_schemas import ConfigResponse, ErrorBody from services.node_auth import bearer_node, node_bearer_scheme -from services.node_config import ConfigInvalid, validate_config +from services.node_config import ConfigInvalid, config_json_schema, validate_config from services.node_config_store import upsert_config from services.node_pipeline import register_with_pipeline @@ -83,17 +83,13 @@ def _error(status_code: int, error: str, detail: str | None = None) -> JSONRespo "x-max-body-bytes": NODE_BODY_LIMITS["/v1/nodes/config"], # The body is read inside the handler rather than declared, so FastAPI has # nothing to describe it with and the published operation would otherwise - # take no body at all. This says only what registration's `config` already - # says — a free-form object — so it is not a second statement of the - # bounds, which stay in services/node_config.py. Publishing the closed - # schema from that module's own table is 86cb6d7he. + # take no body at all. The same object registration's `config` publishes, + # which scripts/generate_openapi.py then hoists into the one component + # both operations reference. "requestBody": { "required": True, - "description": ( - "The full configuration, in the same shape as `config` on `POST /v1/nodes/register`. " - "Free-form here for the reason given on that endpoint." - ), - "content": {"application/json": {"schema": {"type": "object", "additionalProperties": True}}}, + "description": "The full configuration, in the same shape as `config` on `POST /v1/nodes/register`.", + "content": {"application/json": {"schema": config_json_schema()}}, }, }, ) diff --git a/backend/routes/node_schemas.py b/backend/routes/node_schemas.py index 519895f9..62d7a7f6 100644 --- a/backend/routes/node_schemas.py +++ b/backend/routes/node_schemas.py @@ -34,6 +34,8 @@ from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema +from services.node_config import config_json_schema + def _reject_non_number(value: Any) -> Any: """Three shapes that are not a JSON `number` but that Pydantic's lax mode @@ -148,12 +150,15 @@ class RegisterRequest(_RequestModel): node_id: NodeId board_model: str = Field(max_length=64) agreements: Agreements - # Deliberately untyped. A Pydantic model here would 422 on a bad value before - # the handler runs, putting a config-shaped rejection in front of identity - # resolution and making the response an oracle for which identities exist. - # Validation is services/node_config.validate_config, called from inside the - # handler once the identity has resolved. - config: dict[str, Any] + # Deliberately untyped, for the reason routes/node_register.py's module + # docstring gives: a Pydantic model here would refuse a bad value before the + # handler runs, ahead of identity resolution. Validation is + # services/node_config.validate_config, from inside the handler. + # + # WithJsonSchema describes without enforcing: it replaces what is published + # and leaves validation alone, so anything this schema forbids still reaches + # the handler and is refused there. + config: Annotated[dict[str, Any], WithJsonSchema(config_json_schema())] class RegisterResponse(BaseModel): diff --git a/backend/routes/nodes.py b/backend/routes/nodes.py index a2deaf6a..d1c4dc4d 100644 --- a/backend/routes/nodes.py +++ b/backend/routes/nodes.py @@ -42,13 +42,23 @@ # and no client can have depended on either, since the server has never emitted # one. See 86cb6d7cq for the configuration one, which comes back with the limit. # -# Publishing NodeConfig would be the minor bump, since that is the one thing -# here a client cannot already do (86cb6d7he). +# 1.1.3 makes the six coordinate fields of the configuration nullable, so a node +# whose owner cannot supply the geometry can still register. A patch rather than +# a minor bump because the document gained no field a client could read. # -# 1.1.3 makes the six coordinate fields of NodeConfig nullable, so a node whose -# owner cannot supply the geometry can still register. A patch rather than a -# minor bump for the same reason as above: NodeConfig is not published, so the -# document gains no field and no capability a client can read (86cb6d7he). +# 1.1.3 also publishes the configuration schema, built from the same tables the +# validator enforces (86cb6d7he), and the version does not move for it: the +# server accepts and refuses exactly what it did, so there is no change in +# behaviour for a version to describe. +# +# What did change is the document, and not additively. Where `config` was an +# object with no required keys and `additionalProperties: true`, it is now +# fifteen required fields with unknown keys forbidden, and its generated type is +# named NodeConfig where it was Config. So the document narrows, to what this +# server has always enforced, and renames. Two documents therefore carry this +# version and a client cannot tell them apart by it: a payload the earlier one +# called valid, one omitting cpi_s say, the later one rejects, and a client +# regenerated against the later one renames its config type. NODE_API_VERSION = "1.1.3" # No tag here: each sub-router carries the contract's own grouping, since those diff --git a/backend/scripts/generate_openapi.py b/backend/scripts/generate_openapi.py index 878d5505..d32e7382 100644 --- a/backend/scripts/generate_openapi.py +++ b/backend/scripts/generate_openapi.py @@ -19,7 +19,9 @@ models, the security scheme from the dependency, and `info.description` from the application's own. The three constants this file reaches for by name (`NODE_API_VERSION`, `NODE_API_TAGS`, `NODE_API_SERVERS`) live in routes/nodes.py -beside the router they describe. +beside the router they describe. It also reaches for `config_json_schema`, to +recognise the configuration schema in the document and hoist it into a component +the two operations reference; see `_hoisted`. Numeric bounds publish as floats (`minimum: 1.0` rather than `1`) because FastAPI validates its own output through `openapi.models`, whose @@ -42,6 +44,7 @@ from main import app from routes.nodes import NODE_API_SERVERS, NODE_API_TAGS, NODE_API_VERSION, is_node_path +from services.node_config import config_json_schema TITLE = "RETINA node ingest" @@ -51,6 +54,15 @@ _REF_PREFIX = "#/components/schemas/" +# The configuration schema is the one component this file names into existence +# rather than finding among the application's models. Both operations that take +# a configuration carry it inline, because Pydantic resolves every `$ref` it +# emits against its own definitions and this schema is not one of its models, so +# the reference is made here: this is already the layer that shapes the +# document, and one component is what a generated client needs to produce one +# type for one wire object. +_CONFIG_SCHEMA_NAME = "NodeConfig" + def _referenced(node: Any, found: set[str]) -> None: """Every schema reachable from `node`, transitively. @@ -85,6 +97,27 @@ def _closure(paths: dict[str, Any], schemas: dict[str, Any]) -> dict[str, Any]: return {name: schemas[name] for name in sorted(found) if name in schemas} +def _hoisted(node: Any, inline: dict[str, Any]) -> Any: + """`node` with every inline copy of `inline` replaced by a `$ref` to it. + + Rebuilds rather than mutates: `app.openapi()` caches its result, and editing + it in place would leave the application's own docs holding a reference to a + component only this document defines. + + Matched by equality on the whole schema, so a partial copy is left alone + rather than silently referred to something it does not equal. + tests/test_node_openapi.py holds the other end, that both operations really + do end up referring to it. + """ + if isinstance(node, dict): + if node == inline: + return {"$ref": _REF_PREFIX + _CONFIG_SCHEMA_NAME} + return {key: _hoisted(value, inline) for key, value in node.items()} + if isinstance(node, list): + return [_hoisted(item, inline) for item in node] + return node + + def _node_paths(paths: dict[str, Any]) -> dict[str, Any]: """The four operations, with FastAPI's automatic 422 dropped. @@ -113,8 +146,16 @@ def _node_paths(paths: dict[str, Any]) -> dict[str, Any]: def contract() -> dict[str, Any]: schema = app.openapi() - paths = _node_paths(schema["paths"]) - components: dict[str, Any] = {"schemas": _closure(paths, schema["components"]["schemas"])} + inline_config = config_json_schema() + declared = schema["components"]["schemas"] + if _CONFIG_SCHEMA_NAME in declared: + # A Pydantic model of this name anywhere in the application would be + # published under it instead, silently, and a pinned consumer would see + # one type become another. Refused rather than clobbered. + raise RuntimeError(f"{_CONFIG_SCHEMA_NAME} is already an application model; the contract cannot inject it") + paths = _hoisted(_node_paths(schema["paths"]), inline_config) + schemas = _hoisted(declared, inline_config) | {_CONFIG_SCHEMA_NAME: inline_config} + components: dict[str, Any] = {"schemas": _closure(paths, schemas)} # Only the node routes declare one today, but filtering keeps that true # rather than assuming it. declared = schema.get("components", {}).get("securitySchemes", {}) diff --git a/backend/services/node_config.py b/backend/services/node_config.py index 2147245d..b0c2d2be 100644 --- a/backend/services/node_config.py +++ b/backend/services/node_config.py @@ -1,11 +1,13 @@ """The one configuration validator, shared by registration and PUT /nodes/config, and the one normaliser every in-process copy of a node's config passes through. -Bounds are the wire contract's, at version 1.1.1. Three checks are here and not -there because a JSON schema cannot express them: a receiver and illuminator at -the same point give the solver a degenerate baseline; bool is a subclass of int -in Python, so a plain range check accepts True as a latitude of 1; and NaN -compares false against every bound, so it survives a range check untouched. +These bounds are the wire contract's, and they are its source: config_json_schema +below is what the document publishes, so the contract is built from this module +rather than copied into it. Three checks cannot travel with them, because a JSON +schema expresses none of them: a receiver and illuminator at the same point give +the solver a degenerate baseline; bool is a subclass of int in Python, so a plain +range check accepts True as a latitude of 1; and NaN compares false against every +bound, so it survives a range check untouched. A leaf on purpose. It takes a dict and returns a dict, knowing nothing of identity, HTTP or status codes, so both callers can share it and it stays testable without a @@ -13,6 +15,7 @@ """ import math +from copy import deepcopy from typing import Any, Literal # About 0.11 m. Below this the receiver and illuminator are the same point as far as @@ -56,7 +59,98 @@ def __init__(self, field: str, reason: str = "out of range") -> None: # zero wherever the geodesy reads it. _NULLABLE = {"rx_lat", "rx_lon", "rx_alt_ft", "tx_lat", "tx_lon", "tx_alt_ft"} -_REQUIRED = set(_NUMERIC_BOUNDS) | {"tx_callsign", "beam_width_deg", "beam_azimuth_deg"} +# The three fields the table above does not carry: the callsign, which is not +# numeric, and the two beam fields, whose bounds are checked beside it because +# both are nullable. Written out rather than derived, so +# tests/test_node_config_validation.py exercises every bound published here +# against what validate_config actually accepts: that is what stops these +# drifting from the checks below, since they are not the same literals. +_UNTABLED_PROPERTIES: dict[str, dict[str, Any]] = { + "tx_callsign": {"type": "string", "minLength": 1, "maxLength": 32}, + "beam_width_deg": {"type": "number", "exclusiveMinimum": 0.0, "maximum": 360.0}, + "beam_azimuth_deg": {"type": "number", "minimum": 0.0, "exclusiveMaximum": 360.0}, +} + +# Derived from the two tables rather than listed again, so a field added to +# either is required by this validator and published as required in the same +# edit. +_REQUIRED = set(_NUMERIC_BOUNDS) | set(_UNTABLED_PROPERTIES) + +# Nullable beyond the coordinates, and for a different reason: no node has its +# antenna characterised, so null is what the whole fleet sends for both. +_NULLABLE_BEAM = {"beam_width_deg", "beam_azimuth_deg"} + +_SCHEMA_DESCRIPTION = """\ +The receiver and illuminator geometry, the radio parameters and the association +tolerances. Every field is required. + +The six coordinate fields are nullable, for a node whose owner cannot supply the +geometry. Such a node registers and streams, and its detections are counted, but +it places nothing on the map until a position arrives. A latitude and its +longitude are given together or both null. + +Necessary but not sufficient. A receiver and illuminator at the same point are +refused, as is a value that is not a finite number, and neither is expressible +here: both answer `400 invalid_config` naming the field.""" + + +def _numeric_property(low: float, high: float, low_inclusive: bool, high_inclusive: bool) -> dict[str, Any]: + # An unbounded end is what JSON Schema says by omission: neither an infinity + # nor a NaN is a bound, and either would publish a ceiling the server does + # not have in a literal no JSON parser can read. Both ends are guarded, so + # adding an unbounded low to the table cannot slip one out. + schema: dict[str, Any] = {"type": "number"} + if math.isfinite(low): + schema["minimum" if low_inclusive else "exclusiveMinimum"] = float(low) + if math.isfinite(high): + schema["maximum" if high_inclusive else "exclusiveMaximum"] = float(high) + return schema + + +def config_json_schema() -> dict[str, Any]: + """The configuration's published JSON Schema, built from the tables above. + + Generated from what this module enforces rather than written beside it, so + the document a node is built against cannot state a bound the server does + not apply. Describing the shape is not enforcing it; registration's body + stays untyped, for the reason routes/node_register.py gives. + + Fresh every call, nested dicts included. Two callers publish this and the + frameworks under both mutate schema dicts in place, so a shared sub-dict + would let one operation's published bounds rewrite the other's. + """ + properties = {field: _numeric_property(*bounds) for field, bounds in _NUMERIC_BOUNDS.items()} + properties |= deepcopy(_UNTABLED_PROPERTIES) + nullable = _NULLABLE | _NULLABLE_BEAM + return { + "type": "object", + "title": "NodeConfig", + "description": _SCHEMA_DESCRIPTION, + "properties": { + field: {"anyOf": [schema, {"type": "null"}]} if field in nullable else schema + for field, schema in properties.items() + }, + # Sorted for the same reason the refusals above are: a document that + # reordered between runs would show as a diff in the CI gate. + "required": sorted(_REQUIRED), + # The validator names an unknown key back to the caller rather than + # ignoring it. + "additionalProperties": False, + } + + +def numeric_branch(published: dict[str, Any]) -> dict[str, Any]: + """The number half of a published property, whether or not it is nullable. + + Beside the builder because it is how the builder's output is read back, and + the alternative is each caller re-deriving where the bounds live. Selects on + the type rather than on position in the `anyOf`, so reordering the branches + cannot leave a caller reading the null one and finding no bounds at all. + """ + for alternative in published.get("anyOf", [published]): + if alternative.get("type") == "number": + return alternative + return published def _as_finite_float(value: Any) -> tuple[float | None, str]: diff --git a/backend/tests/test_node_config_validation.py b/backend/tests/test_node_config_validation.py index 77b14e23..7a21a9d1 100644 --- a/backend/tests/test_node_config_validation.py +++ b/backend/tests/test_node_config_validation.py @@ -2,7 +2,14 @@ import pytest -from services.node_config import ConfigInvalid, canonical_config, position_status, validate_config +from services.node_config import ( + ConfigInvalid, + canonical_config, + config_json_schema, + numeric_branch, + position_status, + validate_config, +) VALID = { "rx_lat": 51.42, @@ -645,3 +652,75 @@ def test_canonicalising_twice_changes_nothing(): raw = {"lat": "51.42", "lon": "-0.91", "tx_lat": 0.0, "tx_lon": 0.0, "rx_alt_ft": None} once = canonical_config(raw) assert canonical_config(once) == once + + +# ── the published schema against the checks it describes ───────────────────── +# +# config_json_schema builds twelve of the fifteen from _NUMERIC_BOUNDS, which +# the loop in validate_config reads too, so those cannot drift. The callsign and +# the two beam fields are written out beside checks that read their own +# literals, and those can. Both halves are pinned the same way here, against the +# boundary rather than against the table: what a client generating from the +# contract is entitled to rely on is that a value the document permits is one +# this end accepts. + + +# Taken from the published schema rather than from NUMERIC_FIELDS above, so a +# field added to either table is covered by the time it reaches the document. +# A hand-written list would leave a new bound published, enforced, and unchecked. +PUBLISHED_NUMERIC_FIELDS = sorted( + field + for field, published in config_json_schema()["properties"].items() + if numeric_branch(published).get("type") == "number" +) + + +@pytest.mark.parametrize("field", PUBLISHED_NUMERIC_FIELDS) +def test_every_published_bound_is_where_the_validator_refuses(field): + schema = numeric_branch(config_json_schema()["properties"][field]) + + for keyword, outward in (("minimum", -math.inf), ("maximum", math.inf)): + if keyword in schema: + # Inclusive: the bound itself passes and the very next float out + # does not. nextafter rather than an epsilon, so the assertion sits + # on the bound rather than near it. + validate_config(dict(VALID, **{field: schema[keyword]})) + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, **{field: math.nextafter(schema[keyword], outward)})) + assert refusal.value.field == field + + for keyword, inward in (("exclusiveMinimum", math.inf), ("exclusiveMaximum", -math.inf)): + if keyword in schema: + # Exclusive: the bound itself is refused, and the first float on the + # permitted side of it is not. + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, **{field: schema[keyword]})) + assert refusal.value.field == field + validate_config(dict(VALID, **{field: math.nextafter(schema[keyword], inward)})) + + +def test_the_published_callsign_length_is_where_the_validator_refuses(): + schema = config_json_schema()["properties"]["tx_callsign"] + + for length in (schema["minLength"], schema["maxLength"]): + validate_config(dict(VALID, tx_callsign="x" * length)) + + for length in (schema["minLength"] - 1, schema["maxLength"] + 1): + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, tx_callsign="x" * length)) + assert refusal.value.field == "tx_callsign" + + +def test_a_null_is_accepted_for_exactly_the_fields_published_as_nullable(): + published = config_json_schema()["properties"] + nullable = {field for field, schema in published.items() if {"type": "null"} in schema.get("anyOf", [])} + + # All six coordinates at once: a latitude without its longitude is refused, + # so the pairs cannot be exercised one field at a time. + accepted = validate_config(dict(VALID, **dict.fromkeys(nullable))) + assert {field for field in nullable if accepted[field] is None} == nullable + + for field in set(published) - nullable: + with pytest.raises(ConfigInvalid) as refusal: + validate_config(dict(VALID, **{field: None})) + assert refusal.value.field == field diff --git a/backend/tests/test_node_openapi.py b/backend/tests/test_node_openapi.py index 377aabac..c483470d 100644 --- a/backend/tests/test_node_openapi.py +++ b/backend/tests/test_node_openapi.py @@ -11,11 +11,14 @@ finding it in CI is a round trip. """ +import math + import pytest import yaml from routes.nodes import NODE_API_SERVERS, NODE_API_VERSION from scripts.generate_openapi import CONTRACT_PATH, contract, render +from services.node_config import _NULLABLE, _NULLABLE_BEAM, _NUMERIC_BOUNDS, _REQUIRED, numeric_branch FRAME = { "t": 1753900000.123, @@ -177,6 +180,87 @@ def test_the_timestamps_are_still_typed_as_datetimes(document): assert schemas[model]["properties"]["server_time"]["format"] == "date-time" +# ── the configuration schema ───────────────────────────────────────────────── +# +# Built from the validator's own tables rather than written beside them. What +# wants testing here is the document: that both operations reach one object, and +# that every field and bound the server enforces reaches it. Where those bounds +# actually sit is pinned against validate_config itself in +# tests/test_node_config_validation.py, which is the half that catches a bound +# moving. + +CONFIG_REF = {"$ref": "#/components/schemas/NodeConfig"} + +# The two places a configuration enters the API. +CONFIG_BODIES = { + "POST /v1/nodes/register": lambda document: document["components"]["schemas"]["RegisterRequest"]["properties"][ + "config" + ], + "PUT /v1/nodes/config": lambda document: document["paths"]["/v1/nodes/config"]["put"]["requestBody"]["content"][ + "application/json" + ]["schema"], +} + + +def _published_config(document): + return document["components"]["schemas"]["NodeConfig"] + + +def test_both_operations_reach_one_configuration_component(document): + """It is the same object on the wire, so a client that generated two + incompatible types from it has been told something untrue. + + Neither operation can emit this `$ref` itself, so the generator makes it. + That is exactly the sort of substitution that fails by doing nothing, which + is what this catches: both bodies are the reference and nothing else. + """ + for name, body in CONFIG_BODIES.items(): + assert body(document) == CONFIG_REF, name + + assert _published_config(document)["title"] == "NodeConfig" + + +def test_every_field_the_validator_requires_is_published_and_required(document): + schema = _published_config(document) + + assert set(schema["properties"]) == _REQUIRED + assert set(schema["required"]) == _REQUIRED + # The validator names an unknown key back to the caller rather than ignoring + # it, so a document permitting one would describe a different server. + assert schema["additionalProperties"] is False + + +def test_every_bound_the_validator_enforces_reaches_the_schema(document): + """Inclusive bounds as `minimum`/`maximum` and exclusive ones as their + `exclusive*` counterparts. A client checks a value against these before + sending, so an inclusivity published the wrong way round rejects a config + the server accepts.""" + properties = _published_config(document)["properties"] + + for field, (low, high, low_inclusive, high_inclusive) in _NUMERIC_BOUNDS.items(): + numeric = numeric_branch(properties[field]) + + assert numeric["minimum" if low_inclusive else "exclusiveMinimum"] == low, field + if math.isinf(high): + # The two tolerances have no ceiling, which JSON Schema says by + # omission. A published `.inf` would state a limit that is not + # there and that no client could act on. + assert "maximum" not in numeric and "exclusiveMaximum" not in numeric, field + else: + assert numeric["maximum" if high_inclusive else "exclusiveMaximum"] == high, field + + +def test_the_nullable_fields_publish_a_null_branch(document): + """The six coordinates, so a node whose owner cannot supply the geometry + still registers, and the two beam fields, which no node has characterised. + A client generated from a document that omitted these cannot express the + config the fleet actually sends.""" + properties = _published_config(document)["properties"] + nullable = {field for field, published in properties.items() if {"type": "null"} in published.get("anyOf", [])} + + assert nullable == _NULLABLE | _NULLABLE_BEAM + + # ── the credential ─────────────────────────────────────────────────────────── diff --git a/contracts/nodes-v1.openapi.yaml b/contracts/nodes-v1.openapi.yaml index d83fc7c3..c9551700 100644 --- a/contracts/nodes-v1.openapi.yaml +++ b/contracts/nodes-v1.openapi.yaml @@ -195,12 +195,10 @@ paths: operationId: putConfig requestBody: description: The full configuration, in the same shape as `config` on `POST /v1/nodes/register`. - Free-form here for the reason given on that endpoint. content: application/json: schema: - additionalProperties: true - type: object + $ref: '#/components/schemas/NodeConfig' required: true responses: '200': @@ -704,6 +702,113 @@ components: - streaming_allowed - node_ref title: HeartbeatResponse + NodeConfig: + type: object + title: NodeConfig + description: |- + The receiver and illuminator geometry, the radio parameters and the association + tolerances. Every field is required. + + The six coordinate fields are nullable, for a node whose owner cannot supply the + geometry. Such a node registers and streams, and its detections are counted, but + it places nothing on the map until a position arrives. A latitude and its + longitude are given together or both null. + + Necessary but not sufficient. A receiver and illuminator at the same point are + refused, as is a value that is not a finite number, and neither is expressible + here: both answer `400 invalid_config` naming the field. + properties: + rx_lat: + anyOf: + - type: number + minimum: -90.0 + maximum: 90.0 + - type: 'null' + rx_lon: + anyOf: + - type: number + minimum: -180.0 + maximum: 180.0 + - type: 'null' + rx_alt_ft: + anyOf: + - type: number + minimum: -1500.0 + maximum: 30000.0 + - type: 'null' + tx_lat: + anyOf: + - type: number + minimum: -90.0 + maximum: 90.0 + - type: 'null' + tx_lon: + anyOf: + - type: number + minimum: -180.0 + maximum: 180.0 + - type: 'null' + tx_alt_ft: + anyOf: + - type: number + minimum: -1500.0 + maximum: 30000.0 + - type: 'null' + fc_hz: + type: number + minimum: 1000000.0 + maximum: 6000000000.0 + fs_hz: + type: number + minimum: 100000.0 + maximum: 20000000.0 + max_range_km: + type: number + exclusiveMinimum: 0.0 + maximum: 1000.0 + cpi_s: + type: number + exclusiveMinimum: 0.0 + maximum: 10.0 + delay_tolerance_us: + type: number + exclusiveMinimum: 0.0 + doppler_tolerance_hz: + type: number + exclusiveMinimum: 0.0 + tx_callsign: + type: string + minLength: 1 + maxLength: 32 + beam_width_deg: + anyOf: + - type: number + exclusiveMinimum: 0.0 + maximum: 360.0 + - type: 'null' + beam_azimuth_deg: + anyOf: + - type: number + minimum: 0.0 + exclusiveMaximum: 360.0 + - type: 'null' + required: + - beam_azimuth_deg + - beam_width_deg + - cpi_s + - delay_tolerance_us + - doppler_tolerance_hz + - fc_hz + - fs_hz + - max_range_km + - rx_alt_ft + - rx_lat + - rx_lon + - tx_alt_ft + - tx_callsign + - tx_lat + - tx_lon + additionalProperties: false NodeHealth: properties: cpu_pct: @@ -822,9 +927,7 @@ components: agreements: $ref: '#/components/schemas/Agreements' config: - additionalProperties: true - type: object - title: Config + $ref: '#/components/schemas/NodeConfig' additionalProperties: false type: object required: