The shipped config could not load on a machine without a .env

config/default.yaml reads its camera's host, username and password from
${ENV}. Unset placeholders parse as YAML null, and CameraConfig had no
_normalize_blanks - the guard ApiSection and EmailSection have had all
along - so loading it raised three pydantic errors and the engine would
not start at all. On a developer's checkout .env is right there, which is
why this survived: the failing machine is every machine the product is
actually installed on, and installer/build.ps1 runs this suite, so the
Windows build would have failed on a fresh clone.

The normalisation is field-by-field, never a blanket None -> "": `webcam`
is an Optional[int] whose None means "this is not a webcam", and `tuning`
is a nested model. Sweeping either trades one validation error for
another - which it did, on the first attempt.

Second bug behind the same line: that camera entry would then have been
SEEDED into a fresh install, giving a shop a camera called cam1 that
nobody added, retrying a connection to "" forever, with the first task on
a new PC being to work out what it was. CameraConfig.addressed() says
what a camera entry needs to be one, and seed() drops the rest.

Found by cloning the repository into a temp directory and running the
tests there. Nothing in a working tree can find this class of bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 12:15:53 +05:30
parent 2b69a0be3d
commit c7024b57ca
3 changed files with 110 additions and 3 deletions

View File

@@ -55,6 +55,43 @@ class CameraConfig(BaseModel):
max_width: int = 1280 # frames wider than this are downscaled at ingest
tuning: CameraTuning = CameraTuning()
@model_validator(mode="before")
@classmethod
def _normalize_blanks(cls, values):
# Unset ${ENV} placeholders parse as YAML null, not "" - the same trap
# ApiSection and EmailSection already guard against, and this section
# did not. The bundled config/default.yaml reads its camera's host,
# username and password from the environment, so on any machine
# WITHOUT a .env - which is every machine the product is installed on -
# loading it raised three pydantic errors and the engine would not
# start at all. Found by cloning the repository and running the tests.
if not isinstance(values, dict):
return values
# Named fields only, never a blanket None -> "". `webcam` is an
# Optional[int] whose None is meaningful ("this is not a webcam"), and
# `tuning` is a nested model; sweeping either into "" trades one
# validation error for another.
values = dict(values)
for key in ("url", "host", "path", "username", "password"):
if values.get(key) is None:
values[key] = ""
for key, default in (("port", 554), ("max_width", 1280), ("path", "/")):
if values.get(key) in (None, ""):
values[key] = default
return values
def addressed(self) -> bool:
"""True when this entry actually names something to connect to.
The bundled config declares a camera whose address comes from the
environment, for development. On an installed PC there is no such
environment, so that entry resolves to a camera with no address - and
seeding it would put a permanently-failing camera nobody added into
every fresh install, retrying a connection to "" forever. An entry with
no url, no host and no webcam is not a camera.
"""
return bool(self.url or self.host) or self.webcam is not None
def source(self) -> "str | int":
"""Resolved capture source: webcam index, explicit URL, or a URL
built from parts with percent-encoded credentials."""