@trillboards/edge-sdk 0.2.110 → 0.2.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -334,6 +334,31 @@
334
334
  "litert_input_layout": "nhwc",
335
335
  "measured_at": "2026-08-26",
336
336
  "note": "The 22nd twin, added 2026-08-26, and the FIRST whose LiteRT input is NHWC. gh_fall_tonlong had no parity measurement at all until now: upstream ships `Violence` at slot 0 and `violence` at slot 6, normalize_class_name folds them to one name and ModelVocabulary refuses at construction, so neither format could be probed. Both were re-exported from ok.pt (sha256 1e12d89198e28d7c650ba86886c511fecd9f70b9d7f8bf985e76704690e58dd9) with the slots disambiguated by INDEX -- violence_slot0 / violence_slot6 -- which preserves upstream truth and asserts no semantics we have not measured."
337
+ },
338
+ {
339
+ "layout": "anchor_4_plus_nc",
340
+ "imgsz": 640,
341
+ "box_abs_max": 1.052759051322937,
342
+ "units": "normalized",
343
+ "box_scale_applied": 640.0,
344
+ "box_residual_px": 0.001220703125,
345
+ "box_residual_unscaled": 672.7130490541458,
346
+ "cls_residual": 1.3779020591186963e-07,
347
+ "pass": true,
348
+ "reason": "ok",
349
+ "onnx": "fire_smoke_weslleyskah_11n.onnx",
350
+ "litert": "fire_smoke_weslleyskah_11n.tflite",
351
+ "model_id": "fire_smoke_weslleyskah_11n",
352
+ "box_units": "normalized",
353
+ "box_scale_to_pixels": 640.0,
354
+ "litert_input_layout": "nhwc",
355
+ "litert_sha256": "1511903fece4e163348105cfddb89c657ccc43f1482cde7b090e3f1f7b003c84",
356
+ "litert_bytes": 9809490,
357
+ "onnx_sha256": "af8b648caf175edb928eeb3dc6ef73dffc55feaca4097f8133809d8d0ac16419",
358
+ "onnx_bytes": 10216160,
359
+ "measured_at": "2026-08-27",
360
+ "evidence": "twin parity: box residual 0.001221 px after x640, UNSCALED 672.7 px \u2014 the scale is load-bearing, not cosmetic. Class residual 1.38e-07 with no scaling at all. The ONNX half emits PIXELS (max|box| 945.30 over 1,670 real frames, ratio 1.4770) and the LiteRT half NORMALIZED (box_abs_max 1.0528), which is the pair this contract exists for.",
361
+ "note": "Exported from weslleyskah/fire_smoke_detection_box weights/best.pt (sha256 d2b0be21...) with ultralytics 8.3.201, imgsz 640, opset 19, nms=False. `units` and `box_abs_max` are DERIVED from the LiteRT artifact's own forward pass, not assumed from the layout. Verified twice \u2014 once under the contract at production HEAD and once under PR #13224's derived-units contract \u2014 byte-identical numbers and `pass` under both, because this twin IS normalised, the case the hardcoded x640 already handled. The row does not depend on that PR."
337
362
  }
338
363
  ]
339
364
  }
@@ -74,17 +74,51 @@ def classify_layout(shape, output_names) -> str:
74
74
  raise ParityError(f"shape {shape} matches no known detector layout")
75
75
 
76
76
 
77
- def box_scale_for(layout: str, imgsz: int) -> float:
77
+ def box_scale_for(layout: str, imgsz: int, box_abs_max: "float | None" = None) -> float:
78
78
  """The multiplier that puts LiteRT boxes into the ONNX pixel frame.
79
79
 
80
- Ultralytics wraps a LiteRT export of a `task=detect` / `end2end=False`
81
- model in `_NormalizeCoords`, which divides the four BOX channels by (W, H)
82
- and leaves the class channels alone. An end2end head is `end2end=True`, so
83
- the wrapper never runs and both runtimes emit pixels.
80
+ UNITS ARE A PROPERTY OF THE CHECKPOINT, NOT OF THE LAYOUT.
84
81
 
85
- No layout gets a scale by default. A fall-through to 1.0 would be a guess
86
- about an artifact nobody measured, and the wrong guess is silent.
82
+ This returned `imgsz` for every ANCHOR-layout twin, on the reasoning that
83
+ ultralytics wraps a `task=detect` / `end2end=False` LiteRT export in
84
+ `_NormalizeCoords`. That reasoning is disproved by a control: two models
85
+ exported MINUTES APART in one venv, same tool (8.3.201), same flags
86
+ (opset 19, imgsz 640, `nms=False`), both classified ANCHOR, measured
87
+
88
+ 0.0024 (normalized) and 2.8526 (pixels)
89
+
90
+ Same exporter, same settings, same run, opposite units. So no function of
91
+ (layout, imgsz) can be right in general, and the previous signature could
92
+ not express the truth even when it knew it.
93
+
94
+ `box_abs_max` — the largest absolute box value the LiteRT artifact actually
95
+ produced on a forward pass — is what settles it. Pass it and the units are
96
+ DERIVED via `classify_box_units`; the layout is then only a sanity check.
97
+
98
+ Omitting it is still allowed for the historical rows in
99
+ `litert-twin-parity.json`, which were recorded before anything measured
100
+ box_abs_max — those keep the old layout-derived answer, and the ANCHOR case
101
+ is exactly where that answer may be wrong. It is deliberately NOT the path
102
+ a new measurement takes: `verify_litert_twin_parity.py` always passes it,
103
+ and `verdict()` refuses a new ANCHOR row that arrives without one.
104
+
105
+ No layout and no measurement gets a scale by default. A fall-through to 1.0
106
+ would be a guess about an artifact nobody measured, and the wrong guess is
107
+ silent.
87
108
  """
109
+ if box_abs_max is not None:
110
+ # Deferred import: the contract module is stdlib-only so the bare
111
+ # `detector-service` CI lane can import it, and box_units is too.
112
+ from box_units import classify_box_units # noqa: PLC0415
113
+
114
+ measured = classify_box_units(box_abs_max, imgsz)
115
+ if measured["scale_to_pixels"] is None:
116
+ raise ParityError(
117
+ f"box units are unmeasured for this artifact: {measured['refusal']}. "
118
+ "Refusing to assume a scale from the layout — that assumption is "
119
+ "what the 0.0024-vs-2.8526 control disproved."
120
+ )
121
+ return float(measured["scale_to_pixels"])
88
122
  if layout == ANCHOR:
89
123
  return float(imgsz)
90
124
  if layout in (END2END, DETS_LABELS):
@@ -95,14 +129,49 @@ def box_scale_for(layout: str, imgsz: int) -> float:
95
129
  def verdict(result) -> "tuple[bool, str]":
96
130
  """Does this recorded measurement pass, and if not, which assertion failed."""
97
131
  layout = result["layout"]
98
- # The scale must be LOAD-BEARING on the anchor layout. If scaling and not
99
- # scaling both pass, the model is not telling us which one is right and the
100
- # tolerance is doing no work — that is the 640x bug passing its own check.
101
- if layout == ANCHOR and result["box_residual_unscaled"] <= BOX_TOL_PX:
132
+ scale = result.get("box_scale_applied")
133
+ # THE LOAD-BEARING CHECK APPLIES TO A NORMALISED ARTIFACT, NOT TO A LAYOUT.
134
+ #
135
+ # This keyed on `layout == ANCHOR` and read an unscaled match as "the layout
136
+ # is misread". The control disproved the premise: an ANCHOR twin can emit
137
+ # PIXELS, and for that artifact an unscaled match is the CORRECT result, not
138
+ # a misread. Keying it on the layout refused a good artifact and is why the
139
+ # two models published 2026-08-27 shipped ONNX-only.
140
+ #
141
+ # Keyed on the applied scale, the assertion says what it always meant: if we
142
+ # scaled, the scaling has to matter. A scale of 1.0 is the measured answer
143
+ # for a pixel artifact and asserts nothing about normalisation.
144
+ if scale is not None and scale > 1.0 and result["box_residual_unscaled"] <= BOX_TOL_PX:
102
145
  return False, (
103
- "the imgsz scale changed nothing — an anchor-layout twin whose raw boxes "
104
- "already match its ONNX source is not normalised, so the layout is misread"
146
+ f"the x{scale:g} scale changed nothing — a twin whose RAW boxes already match "
147
+ "its ONNX source is not normalised, so the measured units are wrong"
148
+ )
149
+ # THE ROW MUST ESTABLISH ITS UNITS, BY EITHER ROUTE.
150
+ #
151
+ # `box_abs_max` settles it directly. But a row recorded before anything
152
+ # measured that field can still be decisive: if the scaled residual passes
153
+ # and the UNSCALED one misses by orders of magnitude, the artifact is
154
+ # demonstrably normalised — the 22 committed rows carry exactly that shape
155
+ # (e.g. 0.00087 px scaled against 641.03 unscaled). Demanding the field when
156
+ # the evidence is already present in another form would refuse good rows to
157
+ # satisfy a schema, so the check asks whether the units are ESTABLISHED, not
158
+ # whether a particular key exists.
159
+ #
160
+ # What it still refuses is the ambiguous row: no measurement, and two
161
+ # residuals that do not discriminate. That is a twin whose units nobody knows.
162
+ if result.get("box_abs_max") is None and result.get("units") is None:
163
+ scaled = result.get("box_residual_px")
164
+ unscaled = result.get("box_residual_unscaled")
165
+ decisive = (
166
+ scaled is not None and unscaled is not None
167
+ and scaled <= BOX_TOL_PX and unscaled > BOX_TOL_PX * 100
105
168
  )
169
+ if not decisive:
170
+ return False, (
171
+ "twin recorded without box_abs_max, and its scaled/unscaled residuals do "
172
+ "not discriminate — units were assumed from the layout, and layout does "
173
+ "not determine units (0.0024 vs 2.8526 control, one venv, one exporter)"
174
+ )
106
175
  if result["box_residual_px"] > BOX_TOL_PX:
107
176
  return False, (
108
177
  f"box residual {result['box_residual_px']:.4g} px exceeds {BOX_TOL_PX} "
@@ -130,7 +130,13 @@ CLASS_CONTROL = {
130
130
  "gloves": ["ppe_full", "wc_glove"], "glove": ["ppe_full", "wc_glove"], "glove_hand": ["wc_glove", "ppe_full"],
131
131
  "apron": ["wc_apron"], "Apron": ["wc_apron"], "hairnet": ["n_hairnet"], "Hairnet": ["n_hairnet"],
132
132
  "safety cone": ["n_cone"], "safety_cone": ["n_cone"],
133
- "fire_extinguisher": ["n_extinguisher"], "ladder": ["n_ladder"],
133
+ "fire_extinguisher": ["n_extinguisher"],
134
+ # `ladder_openimages` (22 frames / 22 scenes, `good`) leads because it is the set that
135
+ # can rate the class: human-verified Open Images boxes, hand-graded on the pixels.
136
+ # `n_ladder` is KEPT and stays second so its POOR number remains visible in
137
+ # `per_control` -- the same reason `fire_generic` was kept after it was demoted. A
138
+ # control that was refused is evidence about the search, and deleting it hides that.
139
+ "ladder": ["ladder_openimages", "n_ladder"],
134
140
  "person": ["helmet", "ppe_full", "n_crowd"], "worker": ["ppe_full", "helmet", "n_crowd"],
135
141
  "human": ["helmet", "ppe_full", "n_crowd"], "head": ["helmet", "n_crowd"],
136
142
  "forklift": ["forklift"], "forklift-partial": ["forklift"], "Forklift-Partial": ["forklift"], "yoto": ["forklift"],
@@ -209,6 +215,33 @@ def assert_every_control_is_graded(pos_sets, ctlq) -> None:
209
215
  "would be treated as able to rate a class." % ", ".join(missing))
210
216
 
211
217
 
218
+ #: The sellable-capability SHAPE contract, read by this tool and by
219
+ #: `.github/scripts/check-sensing-capability-shape.js`. ONE definition, two consumers --
220
+ #: the same posture as `LIVENESS_ONLY_GRADES` above and for the same reason: a tool and a
221
+ #: guard that each carried their own idea of which capabilities a frame can answer would
222
+ #: let a file through that the other would never emit.
223
+ CAPABILITY_SHAPE_PATH = os.path.join(
224
+ os.path.dirname(os.path.abspath(__file__)),
225
+ *([os.pardir] * 6), ".github", "contracts", "sensing-capability-shape.json")
226
+
227
+
228
+ def load_capability_shape(path: str = CAPABILITY_SHAPE_PATH) -> dict:
229
+ """`capability -> row` from the shape contract.
230
+
231
+ REFUSES rather than degrading when the contract is missing. An adjudication that could
232
+ not read it would silently fall back to grading every capability as if a frame answered
233
+ it, which is exactly the state this file is being changed to end: on 2026-08-26 five
234
+ capabilities came back UNINFORMATIVE under one sentence -- "no usable positive control
235
+ exists for this class" -- and for four of them that was a statement about a set somebody
236
+ sourced and refused, while for `shoplifting / theft` it was a statement about a dict that
237
+ never had an entry. The reader could not tell, and `cctv-detector-tier.md` s10.7 then
238
+ told them all five needed "an afternoon of labelling".
239
+ """
240
+ with open(path) as fh:
241
+ doc = json.load(fh)
242
+ return doc.get("capabilities") or {}
243
+
244
+
212
245
  def normalise_class(name: str) -> str:
213
246
  """`labels.py::normalize_class_name`: lower, and `-`/`_` folded to space.
214
247
 
@@ -403,6 +436,10 @@ def main() -> None:
403
436
  "`assert_every_control_is_graded` refuses the run rather than "
404
437
  "adjudicating on a set nobody looked at."
405
438
  )
439
+ ap.add_argument("--capability-shape", default=CAPABILITY_SHAPE_PATH,
440
+ help="the sellable-capability SHAPE contract. Defaults to "
441
+ ".github/contracts/sensing-capability-shape.json; an override "
442
+ "exists for replaying an old run, not for the normal path.")
406
443
  ap.add_argument("--out", required=True)
407
444
  ap.add_argument("--markdown", help="also write the runbook table here")
408
445
  args = ap.parse_args()
@@ -420,6 +457,15 @@ def main() -> None:
420
457
  for _k, _v in json.load(open(_q)).items():
421
458
  if isinstance(_v, dict) and isinstance(_v.get("grade"), str):
422
459
  ctlq[_k] = _v
460
+ shape_rows = load_capability_shape(args.capability_shape)
461
+ missing_shape = sorted(set(CAPS) - set(shape_rows))
462
+ if missing_shape:
463
+ raise SystemExit(
464
+ "capability shape undeclared for: %s. Every sellable capability must say "
465
+ "whether a FRAME can answer it before a firing rate on it means anything; "
466
+ "add a row to .github/contracts/sensing-capability-shape.json. The "
467
+ "`sensing-capability-shape` guard refuses the same state statically."
468
+ % ", ".join(missing_shape))
423
469
  index = json.load(open(os.path.join(args.out_dir, "_index.json")))
424
470
 
425
471
  meta = combo["meta"]
@@ -779,7 +825,7 @@ def main() -> None:
779
825
  c["per_control"] = {}
780
826
  c["gate"] = None
781
827
 
782
- now = decide(cap, cands)
828
+ now = decide(cap, cands, shape_rows)
783
829
  was, was_model = PRIOR_VERDICT.get(cap, (None, None))
784
830
  rows.append({"capability": cap, "n_candidates": len(cands),
785
831
  "prior_decision_taxi_negatives": was,
@@ -790,6 +836,10 @@ def main() -> None:
790
836
  json.dump({
791
837
  "schema": "trillboards.venue-adjudication/1",
792
838
  "negative_set": "venue-phase3-source-2026-08-26",
839
+ "capability_shape_contract": os.path.relpath(args.capability_shape, os.path.join(
840
+ os.path.dirname(os.path.abspath(__file__)), *([os.pardir] * 6))),
841
+ "inexpressible_capabilities": sorted(
842
+ k for k, v in shape_rows.items() if v.get("expressible_today") is False),
793
843
  "supersedes": "the 1,500-frame `production` negative set of 2026-08-25, "
794
844
  "1,494 of which were taxi_rideshare_tv cabin interiors",
795
845
  "bar": {
@@ -892,11 +942,32 @@ def _other_controls_clause(win: dict) -> str:
892
942
  return "; the same artifact at that threshold keeps " + ", ".join(parts)
893
943
 
894
944
 
895
- def decide(cap: str, cands: list) -> dict:
896
- """PUBLISH / NO MODEL CLEARS BAR / NOT SCREENABLE, with the reason.
945
+ def decide(cap: str, cands: list, shape_rows: dict = None) -> dict:
946
+ """NOT A FRAME QUESTION / PUBLISH / NO MODEL CLEARS BAR / NOT SCREENABLE, with the reason.
947
+
948
+ Same ladder as the 2026-08-25 screen below the first rung. Only the negative set differs.
897
949
 
898
- Same ladder as the 2026-08-25 screen. Only the negative set differs.
950
+ THE FIRST RUNG IS NEW, and it runs before candidates are considered at all, because
951
+ whether a frame can answer the question is not a fact about which artifacts were sourced.
952
+ A capability the shape contract declares inexpressible gets `NOT A FRAME QUESTION` and
953
+ names the substrate it is blocked on -- never `UNINFORMATIVE`, which in this file means
954
+ "a control was sourced for this class and its contact-sheet grade refuses it" and is a
955
+ corpus problem an afternoon of labelling fixes.
899
956
  """
957
+ row = (shape_rows or {}).get(cap) or {}
958
+ if row.get("expressible_today") is False:
959
+ return {"decision": "NOT A FRAME QUESTION",
960
+ "shape": row.get("shape"),
961
+ "frame_observable": row.get("frame_observable"),
962
+ "substrate_required": row.get("substrate_required"),
963
+ "n_candidates_screened": len(cands),
964
+ "why": "this product cannot express the question, so no positive control "
965
+ "would make a firing rate here mean anything. Shape is `%s`; the "
966
+ "per-frame observable is %s; blocked on `%s`. See "
967
+ ".github/contracts/sensing-capability-shape.json for the evidence and "
968
+ "docs/runbooks/sensing-model-library.md s1a for the working."
969
+ % (row.get("shape"), row.get("frame_observable"),
970
+ row.get("substrate_required"))}
900
971
  if not cands:
901
972
  return {"decision": "NO CANDIDATE",
902
973
  "why": "no sourced artifact declares a class for this capability"}
@@ -909,9 +980,20 @@ def decide(cap: str, cands: list) -> dict:
909
980
  live = [c for c in cands if (c["control_rate_025"] or 0) > 0]
910
981
  if not live:
911
982
  if all(c["best_control"] is None for c in cands):
983
+ # NAME the sets, because "no usable positive control exists" reads as a claim
984
+ # about the world and is a claim about two dicts. A class with a control that was
985
+ # sourced, rendered on a contact sheet and REFUSED is one afternoon of labelling
986
+ # from a verdict; a class that was never mapped may be a class no frame answers,
987
+ # and `NOT A FRAME QUESTION` above is where that ends up. Both used to print here.
988
+ mapped = sorted({n for c in cands
989
+ for n in CLASS_CONTROL_N.get(normalise_class(c["class"]), [])})
990
+ detail = ("the mapped control set(s) %s were all refused by their contact-sheet "
991
+ "grade" % ", ".join("`%s`" % m for m in mapped)) if mapped else (
992
+ "no control set is mapped to this class in CLASS_CONTROL at all")
912
993
  return {"decision": "UNINFORMATIVE",
994
+ "controls_mapped": mapped,
913
995
  "why": "no usable positive control exists for this class, so no firing "
914
- "rate here is a finding"}
996
+ "rate here is a finding -- %s" % detail}
915
997
  return {"decision": "NO MODEL CLEARS BAR",
916
998
  "why": "no candidate fires on its positive control"}
917
999
  retained = [c for c in live if (c["control_rate_at_quiet_t"] or 0) >= RECALL_RETENTION_BAR]
@@ -53,6 +53,7 @@ import numpy as np
53
53
 
54
54
  sys.path.insert(0, str(Path(__file__).resolve().parent))
55
55
 
56
+ from box_units import classify_box_units
56
57
  from twin_parity_contract import ( # noqa: E402 (same directory)
57
58
  ANCHOR,
58
59
  BOX_TOL_PX,
@@ -93,12 +94,19 @@ def compare(onnx_out: np.ndarray, litert_out: np.ndarray, layout: str, imgsz: in
93
94
  raise ParityError(f"shape mismatch onnx={onnx_out.shape} litert={litert_out.shape}")
94
95
  a = litert_out.astype(np.float64)
95
96
  b = onnx_out.astype(np.float64)
96
- scale = box_scale_for(layout, imgsz)
97
97
  box_l, cls_l = split_channels(a, layout)
98
98
  box_o, cls_o = split_channels(b, layout)
99
+ # MEASURE the units off this artifact's own boxes rather than inferring them
100
+ # from the layout. Two anchor-layout twins exported minutes apart in one venv
101
+ # measured 0.0024 and 2.8526 — same tool, same flags, opposite units.
102
+ box_abs_max = float(np.abs(box_l).max()) if box_l.size else 0.0
103
+ units = classify_box_units(box_abs_max, imgsz)
104
+ scale = box_scale_for(layout, imgsz, box_abs_max=box_abs_max)
99
105
  return {
100
106
  "layout": layout,
101
107
  "imgsz": imgsz,
108
+ "box_abs_max": box_abs_max,
109
+ "units": units["units"],
102
110
  "box_scale_applied": scale,
103
111
  "box_residual_px": float(np.abs(box_l * scale - box_o).max()),
104
112
  "box_residual_unscaled": float(np.abs(box_l - box_o).max()),
@@ -106,23 +114,19 @@ def compare(onnx_out: np.ndarray, litert_out: np.ndarray, layout: str, imgsz: in
106
114
  }
107
115
 
108
116
 
109
- def verdict(result: dict) -> tuple[bool, str]:
110
- # The scale must be LOAD-BEARING on the anchor layout. If scaling and not
111
- # scaling both pass, the model is not telling us which one is right and the
112
- # tolerance is doing no work — that is the 640x bug passing its own check.
113
- if result["layout"] == ANCHOR and result["box_residual_unscaled"] <= BOX_TOL_PX:
114
- return False, (
115
- "the imgsz scale changed nothing — an anchor-layout twin whose raw boxes "
116
- "already match its ONNX source is not normalised, so the layout is misread"
117
- )
118
- if result["box_residual_px"] > BOX_TOL_PX:
119
- return False, (
120
- f"box residual {result['box_residual_px']:.4g} px exceeds {BOX_TOL_PX} "
121
- f"under layout {result['layout']} (scale x{result['box_scale_applied']})"
122
- )
123
- if result["cls_residual"] > CLS_TOL:
124
- return False, f"class residual {result['cls_residual']:.4g} exceeds {CLS_TOL}"
125
- return True, "ok"
117
+ # `verdict` is NOT redefined here. It lives in `twin_parity_contract` and is
118
+ # imported above.
119
+ #
120
+ # A second copy stood at this exact spot and SHADOWED the import. It was a
121
+ # faithful duplicate when written, so nothing looked wrong — and then the
122
+ # contract's copy was fixed to derive box units from the artifact while this one
123
+ # kept keying on the layout. The verifier went on refusing the pixel-space
124
+ # anchor twin the fix was written to admit, and the contract's own tests passed
125
+ # the whole time, because they exercise the module that is not the one running.
126
+ #
127
+ # Caught by codex on the very PR that introduced the divergence. One rule, one
128
+ # home: everything that decides pass/fail for a twin is in the contract module,
129
+ # which is also the module the stdlib-only CI lane can import without numpy.
126
130
 
127
131
 
128
132
  def main() -> int:
@@ -0,0 +1,137 @@
1
+ """Box units are a property of the CHECKPOINT, not of the layout.
2
+
3
+ `box_scale_for` returned `imgsz` for every anchor-layout twin, reasoning that
4
+ ultralytics wraps a `task=detect` / `end2end=False` LiteRT export in
5
+ `_NormalizeCoords`. A control disproves the premise: two models exported MINUTES
6
+ APART in one venv — same tool 8.3.201, same flags (opset 19, imgsz 640,
7
+ nms=False), both classified anchor — measured
8
+
9
+ box residual 0.0024 under the normalized reading, and 2.8526 under the
10
+ pixel reading — one artifact matches its ONNX twin only when scaled, the
11
+ other only when not
12
+
13
+ so no function of (layout, imgsz) can be right in general. (Those two figures are
14
+ RESIDUALS, not box maxima; the maxima below are what `classify_box_units` grades,
15
+ and a normalized artifact's boxes top out near 1.0 while a pixel artifact's
16
+ approach imgsz.) Keying the verdict on
17
+ the layout refused a good artifact, which is why two models published
18
+ 2026-08-27 shipped ONNX-only.
19
+
20
+ Stdlib only, like the rest of this suite, so the bare `detector-service` lane can
21
+ run it with no numpy and no artifact.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import unittest
27
+
28
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bench"))
29
+
30
+ from twin_parity_contract import ( # noqa: E402
31
+ ANCHOR,
32
+ BOX_TOL_PX,
33
+ ParityError,
34
+ box_scale_for,
35
+ verdict,
36
+ )
37
+
38
+
39
+ def row(**kw):
40
+ base = {
41
+ "layout": ANCHOR, "imgsz": 640, "cls_residual": 1e-7,
42
+ "box_scale_applied": 640.0, "box_residual_px": 0.0008,
43
+ "box_residual_unscaled": 641.0,
44
+ }
45
+ base.update(kw)
46
+ return base
47
+
48
+
49
+ class BoxUnitsAreMeasured(unittest.TestCase):
50
+ def test_pixel_anchor_twin_passes(self):
51
+ """The artifact the old rule refused. Anchor layout, PIXEL units."""
52
+ ok, why = verdict(row(box_abs_max=512.0, units="pixels",
53
+ box_scale_applied=1.0, box_residual_unscaled=0.0009,
54
+ box_residual_px=0.0009))
55
+ self.assertTrue(ok, why)
56
+
57
+ def test_normalised_anchor_twin_passes(self):
58
+ ok, why = verdict(row(box_abs_max=0.87, units="normalized"))
59
+ self.assertTrue(ok, why)
60
+
61
+ def test_the_640x_bug_is_still_caught(self):
62
+ """Scaled by 640 and the scale changed nothing — the original defect."""
63
+ ok, why = verdict(row(box_abs_max=0.87, units="normalized",
64
+ box_residual_unscaled=0.0008))
65
+ self.assertFalse(ok)
66
+ self.assertIn("changed nothing", why)
67
+
68
+ def test_unmeasured_and_undiscriminating_is_refused(self):
69
+ """No measurement AND residuals that do not separate = units unknown."""
70
+ ok, _ = verdict({"layout": ANCHOR, "imgsz": 640, "box_scale_applied": 640.0,
71
+ "box_residual_px": 0.0009,
72
+ "box_residual_unscaled": BOX_TOL_PX * 2, "cls_residual": 1e-7})
73
+ self.assertFalse(ok)
74
+
75
+ def test_historical_rows_establish_units_without_the_field(self):
76
+ """The 22 committed rows predate box_abs_max and are still decisive.
77
+
78
+ Demanding the field when the evidence is present in another form would
79
+ refuse good rows to satisfy a schema.
80
+ """
81
+ ok, why = verdict({"layout": ANCHOR, "imgsz": 640, "box_scale_applied": 640.0,
82
+ "box_residual_px": 0.00087, "box_residual_unscaled": 641.03,
83
+ "cls_residual": 2e-6})
84
+ self.assertTrue(ok, why)
85
+
86
+ def test_measurement_overrides_the_layout(self):
87
+ """Same layout, same imgsz, opposite scales — decided by the artifact."""
88
+ self.assertEqual(box_scale_for(ANCHOR, 640, box_abs_max=0.87), 640.0)
89
+ self.assertEqual(box_scale_for(ANCHOR, 640, box_abs_max=512.0), 1.0)
90
+
91
+ def test_a_useless_measurement_refuses_rather_than_guessing(self):
92
+ with self.assertRaises(ParityError):
93
+ box_scale_for(ANCHOR, 640, box_abs_max=0.0)
94
+
95
+ def test_layout_route_survives_for_callers_with_no_measurement(self):
96
+ self.assertEqual(box_scale_for(ANCHOR, 640), 640.0)
97
+
98
+
99
+ class TheVerifierUsesTheContractsVerdict(unittest.TestCase):
100
+ """One rule, one home — the verifier must not carry its own copy.
101
+
102
+ A faithful duplicate of `verdict` sat in verify_litert_twin_parity.py and
103
+ SHADOWED the import. It was identical when written, so nothing looked wrong;
104
+ then the contract's copy learned to derive box units from the artifact and
105
+ this one kept keying on the layout. The verifier went on refusing the
106
+ pixel-space anchor twin the fix existed to admit — and every contract test
107
+ passed throughout, because they exercise the module that is not the one
108
+ running. Caught by review on the PR that introduced the divergence.
109
+
110
+ Source-level, deliberately: importing the verifier needs numpy, and this
111
+ suite runs on the bare `detector-service` lane where numpy is absent.
112
+ """
113
+
114
+ def test_verifier_defines_no_verdict_of_its_own(self):
115
+ path = os.path.join(
116
+ os.path.dirname(__file__), "..", "bench", "verify_litert_twin_parity.py"
117
+ )
118
+ with open(path, encoding="utf-8") as fh:
119
+ src = fh.read()
120
+ self.assertNotIn(
121
+ "\ndef verdict(", src,
122
+ "verify_litert_twin_parity.py defines its own verdict(), which shadows the "
123
+ "imported contract copy — the two then drift silently and the tests exercise "
124
+ "the wrong one",
125
+ )
126
+
127
+ def test_verifier_imports_the_contract_verdict(self):
128
+ path = os.path.join(
129
+ os.path.dirname(__file__), "..", "bench", "verify_litert_twin_parity.py"
130
+ )
131
+ with open(path, encoding="utf-8") as fh:
132
+ src = fh.read()
133
+ self.assertIn("verdict", src.split("from twin_parity_contract import (")[1][:400])
134
+
135
+
136
+ if __name__ == "__main__":
137
+ unittest.main()
@@ -574,7 +574,29 @@ GH_PPE_VINAYAK_L = ModelVocabulary(
574
574
  ),
575
575
  )
576
576
 
577
- BUILTIN_VOCABULARIES: "tuple[ModelVocabulary, ...]" = (RFDETR_MEDIUM, GH_FORKLIFT_COMBINE, PPE_VYRA_14CLS_V8M, PPE_CONSTRUCTION_17CLS_V8N, PPE_KILLUMINATI_19CLS_V8S, FALL_1CLS_11X, GH_PPE_VINAYAK_L,)
577
+ #: Fire and Smoke Detector — published 2026-08-27 by
578
+ #: scripts/sensing/publish-sensing-model.js. The class list is the artifact's own
579
+ #: (graph metadata key 'names'); the footprint is MEASURED, 123.7031 MiB after a
580
+ #: probe inference via rusage-maxrss, not an estimate from the file size.
581
+ #: Coordinate space MEASURED, not assumed: max|box channel| = 945.3029 over 1670 real frames; / imgsz 640 = 1.4770. pixels at >= 0.2, normalized at <= 0.02, refused in between.
582
+ FIRE_SMOKE_WESLLEYSKAH_11N_CLASS_NAMES: "MappingProxyType[int, str]" = MappingProxyType({
583
+ 0: "fire",
584
+ 1: "smoke",
585
+ })
586
+
587
+ FIRE_SMOKE_WESLLEYSKAH_11N = ModelVocabulary(
588
+ model_name="fire_smoke_weslleyskah_11n",
589
+ slot_names=FIRE_SMOKE_WESLLEYSKAH_11N_CLASS_NAMES,
590
+ logit_slots=2,
591
+ approx_ram_bytes=129712102,
592
+ onnx_variant=VariantContract(
593
+ output_layout="anchor_4_plus_nc",
594
+ box_units="pixels",
595
+ box_scale_to_pixels=1.0,
596
+ ),
597
+ )
598
+
599
+ BUILTIN_VOCABULARIES: "tuple[ModelVocabulary, ...]" = (RFDETR_MEDIUM, GH_FORKLIFT_COMBINE, PPE_VYRA_14CLS_V8M, PPE_CONSTRUCTION_17CLS_V8N, PPE_KILLUMINATI_19CLS_V8S, FALL_1CLS_11X, GH_PPE_VINAYAK_L, FIRE_SMOKE_WESLLEYSKAH_11N,)
578
600
 
579
601
 
580
602
  class ClassIndex:
@@ -668,6 +690,7 @@ class ClassIndex:
668
690
 
669
691
 
670
692
  __all__ = [
693
+ "FIRE_SMOKE_WESLLEYSKAH_11N",
671
694
  "GH_PPE_VINAYAK_L",
672
695
  "FALL_1CLS_11X",
673
696
  "PPE_KILLUMINATI_19CLS_V8S",
@@ -45,12 +45,16 @@ static BOOL acquireSetupInstanceLock(void) {
45
45
  @property(nonatomic, strong) NSTextField *metric;
46
46
  @property(nonatomic, strong) NSProgressIndicator *progress;
47
47
  @property(nonatomic, strong) NSButton *actionButton;
48
+ @property(nonatomic, strong) NSButton *closeButton;
49
+ @property(nonatomic, strong) NSButton *updateButton;
50
+ @property(nonatomic, strong) NSTextField *updateStatus;
48
51
  @property(nonatomic, strong) NSArray<NSTextField *> *stepLabels;
49
52
  @property(nonatomic, strong) NSTimer *timer;
50
53
  @property(nonatomic, assign) pid_t pairProcessIdentifier;
51
54
  @property(nonatomic, strong) dispatch_source_t pairProcessSource;
52
55
  @property(nonatomic, copy) NSString *lastStage;
53
56
  @property(nonatomic, copy) NSString *portalURL;
57
+ @property(nonatomic, copy) NSString *requiredUpdateURL;
54
58
  @property(nonatomic, assign) BOOL setupStarted;
55
59
  @property(nonatomic, assign) BOOL terminating;
56
60
  @end
@@ -88,6 +92,8 @@ static BOOL acquireSetupInstanceLock(void) {
88
92
  repeats:YES];
89
93
  [self.window makeKeyAndOrderFront:nil];
90
94
  [NSApp activateIgnoringOtherApps:YES];
95
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.6 * NSEC_PER_SEC)),
96
+ dispatch_get_main_queue(), ^{ [self checkForUpdates:NO]; });
91
97
  }
92
98
 
93
99
  - (void)activateSetupWindow:(NSNotification *)notification {
@@ -279,6 +285,21 @@ static BOOL acquireSetupInstanceLock(void) {
279
285
  self.progress.maxValue = 100;
280
286
  [root addSubview:self.progress];
281
287
 
288
+ self.updateButton = [[NSButton alloc] initWithFrame:NSMakeRect(236, 101, 145, 30)];
289
+ self.updateButton.title = @"Check for updates";
290
+ self.updateButton.bezelStyle = NSBezelStyleRounded;
291
+ self.updateButton.target = self;
292
+ self.updateButton.action = @selector(checkForUpdatesPressed);
293
+ [root addSubview:self.updateButton];
294
+
295
+ NSString *installedVersion = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"0.0.0";
296
+ self.updateStatus = [self label:[NSString stringWithFormat:@"Connector v%@", installedVersion]
297
+ size:12
298
+ weight:NSFontWeightRegular
299
+ frame:NSMakeRect(393, 104, 308, 24)];
300
+ self.updateStatus.textColor = NSColor.secondaryLabelColor;
301
+ [root addSubview:self.updateStatus];
302
+
282
303
  NSTextField *privacy = [self label:@"Camera credentials stay on this computer. Cloud inference is off by default."
283
304
  size:12
284
305
  weight:NSFontWeightRegular
@@ -292,6 +313,14 @@ static BOOL acquireSetupInstanceLock(void) {
292
313
  self.actionButton.target = self;
293
314
  self.actionButton.action = @selector(actionPressed);
294
315
  [root addSubview:self.actionButton];
316
+
317
+ self.closeButton = [[NSButton alloc] initWithFrame:NSMakeRect(450, 36, 100, 40)];
318
+ self.closeButton.title = @"Close";
319
+ self.closeButton.bezelStyle = NSBezelStyleRounded;
320
+ self.closeButton.target = self;
321
+ self.closeButton.action = @selector(closePressed);
322
+ self.closeButton.hidden = YES;
323
+ [root addSubview:self.closeButton];
295
324
  }
296
325
 
297
326
  - (void)showWelcome {
@@ -304,6 +333,7 @@ static BOOL acquireSetupInstanceLock(void) {
304
333
  self.progress.doubleValue = 0;
305
334
  self.actionButton.title = @"Start setup";
306
335
  self.actionButton.enabled = YES;
336
+ self.closeButton.hidden = YES;
307
337
  [self updateSteps:0];
308
338
  }
309
339
 
@@ -317,9 +347,109 @@ static BOOL acquireSetupInstanceLock(void) {
317
347
  self.progress.doubleValue = 0;
318
348
  self.actionButton.title = @"Try again";
319
349
  self.actionButton.enabled = YES;
350
+ self.closeButton.hidden = YES;
351
+ }
352
+
353
+ - (void)closePressed {
354
+ [self.window close];
355
+ }
356
+
357
+ - (void)checkForUpdatesPressed {
358
+ [self checkForUpdates:YES];
359
+ }
360
+
361
+ - (void)checkForUpdates:(BOOL)manual {
362
+ if (!self.updateButton.enabled) return;
363
+ self.updateButton.enabled = NO;
364
+ self.updateButton.title = @"Checking...";
365
+ self.updateStatus.stringValue = @"Checking for connector updates...";
366
+
367
+ NSURL *url = [NSURL URLWithString:@"https://screen.trillboards.com/connector/trillboards-connector-macos.version.json"];
368
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url
369
+ cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
370
+ timeoutInterval:15.0];
371
+ [request setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
372
+ NSURLSessionDataTask *task = [NSURLSession.sharedSession dataTaskWithRequest:request
373
+ completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
374
+ (void)response;
375
+ dispatch_async(dispatch_get_main_queue(), ^{
376
+ self.updateButton.enabled = YES;
377
+ self.updateButton.title = @"Check for updates";
378
+ NSDictionary *manifest = data && !error
379
+ ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]
380
+ : nil;
381
+ NSString *availableText = [manifest[@"connectorVersion"] isKindOfClass:NSString.class]
382
+ ? manifest[@"connectorVersion"] : nil;
383
+ NSString *downloadURL = [manifest[@"downloadUrl"] isKindOfClass:NSString.class]
384
+ ? manifest[@"downloadUrl"] : nil;
385
+ NSString *installedText = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"0.0.0";
386
+ if (!availableText || !downloadURL) {
387
+ self.updateStatus.stringValue = @"Could not check for updates";
388
+ if (manual) {
389
+ NSAlert *alert = [[NSAlert alloc] init];
390
+ alert.messageText = @"Update check unavailable";
391
+ alert.informativeText = @"The update service could not be reached. Your connector can keep running; try again later.";
392
+ [alert runModal];
393
+ }
394
+ return;
395
+ }
396
+
397
+ NSComparisonResult order = [availableText compare:installedText options:NSNumericSearch];
398
+ if (order != NSOrderedDescending) {
399
+ self.updateStatus.stringValue = [NSString stringWithFormat:@"Connector v%@ is up to date", installedText];
400
+ if (manual) {
401
+ NSAlert *alert = [[NSAlert alloc] init];
402
+ alert.messageText = @"No updates available";
403
+ alert.informativeText = [NSString stringWithFormat:@"Sense Anything Connector v%@ is up to date.", installedText];
404
+ [alert runModal];
405
+ }
406
+ return;
407
+ }
408
+
409
+ NSInteger availableMajor = [availableText componentsSeparatedByString:@"."].firstObject.integerValue;
410
+ NSInteger installedMajor = [installedText componentsSeparatedByString:@"."].firstObject.integerValue;
411
+ if (availableMajor > installedMajor) {
412
+ if (self.setupStarted) {
413
+ self.setupStarted = NO;
414
+ [self stopPairProcess];
415
+ }
416
+ self.requiredUpdateURL = downloadURL;
417
+ self.lastStage = @"update_required";
418
+ self.headline.stringValue = @"Update required before setup can continue";
419
+ self.detail.stringValue = @"Install the latest Sense Anything Connector, then reopen it to finish camera setup.";
420
+ self.statusValue.stringValue = [NSString stringWithFormat:@"Version %@", availableText];
421
+ self.metric.stringValue = @"Camera setup is paused safely until the update is installed.";
422
+ self.progress.doubleValue = 0;
423
+ self.closeButton.hidden = YES;
424
+ self.updateStatus.stringValue = [NSString stringWithFormat:@"Required update v%@ available", availableText];
425
+ self.actionButton.title = @"Install required update";
426
+ self.actionButton.enabled = YES;
427
+ NSAlert *alert = [[NSAlert alloc] init];
428
+ alert.messageText = @"Required connector update";
429
+ alert.informativeText = [NSString stringWithFormat:@"Sense Anything Connector v%@ is a required major update. Install it before continuing.", availableText];
430
+ [alert addButtonWithTitle:@"Install Required Update"];
431
+ [alert runModal];
432
+ [self openURL:downloadURL];
433
+ return;
434
+ }
435
+
436
+ self.updateStatus.stringValue = [NSString stringWithFormat:@"Optional update v%@ available", availableText];
437
+ NSAlert *alert = [[NSAlert alloc] init];
438
+ alert.messageText = @"Optional connector update";
439
+ alert.informativeText = [NSString stringWithFormat:@"Sense Anything Connector v%@ is available. You can install it now or keep this version.", availableText];
440
+ [alert addButtonWithTitle:@"Install Update"];
441
+ [alert addButtonWithTitle:@"Not Now"];
442
+ if ([alert runModal] == NSAlertFirstButtonReturn) [self openURL:downloadURL];
443
+ });
444
+ }];
445
+ [task resume];
320
446
  }
321
447
 
322
448
  - (void)actionPressed {
449
+ if (self.requiredUpdateURL) {
450
+ [self openURL:self.requiredUpdateURL];
451
+ return;
452
+ }
323
453
  if ([self.lastStage isEqualToString:@"waiting_for_account"] ||
324
454
  [self.lastStage isEqualToString:@"account_attention"]) {
325
455
  [self openURL:self.portalURL];
@@ -435,6 +565,7 @@ static BOOL acquireSetupInstanceLock(void) {
435
565
 
436
566
  self.progress.doubleValue = [self progressForStage:stage];
437
567
  [self updateSteps:[self stepForStage:stage]];
568
+ self.closeButton.hidden = ![stage isEqualToString:@"ready"];
438
569
  if ([stage isEqualToString:@"waiting_for_account"]) {
439
570
  self.actionButton.title = @"Open Sense Anything";
440
571
  self.actionButton.enabled = YES;
@@ -454,6 +585,10 @@ static BOOL acquireSetupInstanceLock(void) {
454
585
  self.actionButton.title = @"Working...";
455
586
  self.actionButton.enabled = NO;
456
587
  }
588
+ if (self.requiredUpdateURL) {
589
+ self.actionButton.title = @"Install required update";
590
+ self.actionButton.enabled = YES;
591
+ }
457
592
  }
458
593
 
459
594
  - (NSInteger)stepForStage:(NSString *)stage {
@@ -8,6 +8,7 @@ Set-StrictMode -Version Latest
8
8
 
9
9
  Add-Type -AssemblyName System.Windows.Forms
10
10
  Add-Type -AssemblyName System.Drawing
11
+ Add-Type -AssemblyName System.Net.Http
11
12
  [System.Windows.Forms.Application]::EnableVisualStyles()
12
13
 
13
14
  $BinDir = $PSScriptRoot
@@ -22,6 +23,14 @@ $script:SetupStarted = $false
22
23
  $script:PortalUrl = 'https://app.senseanything.com/sense/cameras'
23
24
  $script:PairProcess = $null
24
25
  $script:SetupMutex = $null
26
+ $script:UpdateTask = $null
27
+ $script:UpdateClient = $null
28
+ $script:UpdateWasManual = $false
29
+ $script:RequiredUpdateUrl = $null
30
+ $UpdateManifestUrl = 'https://screen.trillboards.com/connector/trillboards-connector-windows.version.json'
31
+ $InstalledVersion = try {
32
+ [string](Get-ItemPropertyValue -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TrillboardsEdge' -Name DisplayVersion -ErrorAction Stop)
33
+ } catch { '0.0.0' }
25
34
  $StepNames = @('Welcome', 'Account', 'Recorder', 'Cameras', 'Local AI', 'Ready')
26
35
  $StepLabels = New-Object System.Collections.Generic.List[System.Windows.Forms.Label]
27
36
 
@@ -168,6 +177,19 @@ $Progress.Minimum = 0
168
177
  $Progress.Maximum = 100
169
178
  $Form.Controls.Add($Progress)
170
179
 
180
+ $UpdateAction = New-Object System.Windows.Forms.Button
181
+ $UpdateAction.Text = 'Check for updates'
182
+ $UpdateAction.Font = New-Object System.Drawing.Font('Segoe UI', 8.5)
183
+ $UpdateAction.Location = New-Object System.Drawing.Point(236, 414)
184
+ $UpdateAction.Size = New-Object System.Drawing.Size(145, 30)
185
+ $Form.Controls.Add($UpdateAction)
186
+
187
+ $UpdateStatus = New-Label -Text "Connector v$InstalledVersion" -Size 8.5
188
+ $UpdateStatus.ForeColor = [System.Drawing.Color]::FromArgb(96, 113, 122)
189
+ $UpdateStatus.Location = New-Object System.Drawing.Point(393, 416)
190
+ $UpdateStatus.Size = New-Object System.Drawing.Size(308, 26)
191
+ $Form.Controls.Add($UpdateStatus)
192
+
171
193
  $Privacy = New-Label -Text 'Camera credentials stay on this computer. Cloud inference is off by default.' -Size 8.5
172
194
  $Privacy.ForeColor = [System.Drawing.Color]::FromArgb(96, 113, 122)
173
195
  $Privacy.Location = New-Object System.Drawing.Point(236, 468)
@@ -185,6 +207,14 @@ $Action.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
185
207
  $Action.FlatAppearance.BorderSize = 0
186
208
  $Form.Controls.Add($Action)
187
209
 
210
+ $CloseAction = New-Object System.Windows.Forms.Button
211
+ $CloseAction.Text = 'Close'
212
+ $CloseAction.Font = New-Object System.Drawing.Font('Segoe UI', 10)
213
+ $CloseAction.Location = New-Object System.Drawing.Point(450, 466)
214
+ $CloseAction.Size = New-Object System.Drawing.Size(100, 42)
215
+ $CloseAction.Visible = $false
216
+ $Form.Controls.Add($CloseAction)
217
+
188
218
  function Show-Welcome {
189
219
  $script:LastStage = 'welcome'
190
220
  $Headline.Text = 'Set up this computer for CCTV'
@@ -194,6 +224,7 @@ function Show-Welcome {
194
224
  $Progress.Value = 0
195
225
  $Action.Text = 'Start setup'
196
226
  $Action.Enabled = $true
227
+ $CloseAction.Visible = $false
197
228
  Update-Steps 0
198
229
  }
199
230
 
@@ -206,6 +237,123 @@ function Show-LocalError([string]$Title, [string]$Message) {
206
237
  $Progress.Value = 0
207
238
  $Action.Text = 'Try again'
208
239
  $Action.Enabled = $true
240
+ $CloseAction.Visible = $false
241
+ }
242
+
243
+ function Convert-ToVersion([string]$Value) {
244
+ try { return [version](($Value -split '[-+]', 2)[0]) } catch { return $null }
245
+ }
246
+
247
+ function Open-ConnectorUpdate([string]$Url) {
248
+ if ([string]::IsNullOrWhiteSpace($Url)) { return }
249
+ try { Start-Process $Url } catch {
250
+ [System.Windows.Forms.MessageBox]::Show(
251
+ "Open this address to update:`r`n$Url",
252
+ 'Sense Anything Connector update',
253
+ [System.Windows.Forms.MessageBoxButtons]::OK,
254
+ [System.Windows.Forms.MessageBoxIcon]::Information
255
+ ) | Out-Null
256
+ }
257
+ }
258
+
259
+ function Start-UpdateCheck([bool]$Manual = $true) {
260
+ if ($null -ne $script:UpdateTask) { return }
261
+ $script:UpdateWasManual = $Manual
262
+ $UpdateAction.Enabled = $false
263
+ $UpdateAction.Text = 'Checking...'
264
+ $UpdateStatus.Text = 'Checking for connector updates...'
265
+ try {
266
+ $script:UpdateClient = New-Object System.Net.Http.HttpClient
267
+ $script:UpdateClient.Timeout = [TimeSpan]::FromSeconds(15)
268
+ $script:UpdateClient.DefaultRequestHeaders.CacheControl = New-Object System.Net.Http.Headers.CacheControlHeaderValue
269
+ $script:UpdateClient.DefaultRequestHeaders.CacheControl.NoCache = $true
270
+ $script:UpdateTask = $script:UpdateClient.GetStringAsync($UpdateManifestUrl)
271
+ } catch {
272
+ $script:UpdateTask = $null
273
+ if ($null -ne $script:UpdateClient) { $script:UpdateClient.Dispose(); $script:UpdateClient = $null }
274
+ $UpdateAction.Enabled = $true
275
+ $UpdateAction.Text = 'Check for updates'
276
+ $UpdateStatus.Text = 'Update check unavailable'
277
+ }
278
+ }
279
+
280
+ function Complete-UpdateCheck {
281
+ $task = $script:UpdateTask
282
+ if ($null -eq $task -or -not $task.IsCompleted) { return }
283
+ $script:UpdateTask = $null
284
+ $manual = $script:UpdateWasManual
285
+ $UpdateAction.Enabled = $true
286
+ $UpdateAction.Text = 'Check for updates'
287
+ try {
288
+ $manifest = ($task.GetAwaiter().GetResult() | ConvertFrom-Json)
289
+ $availableText = [string]$manifest.connectorVersion
290
+ $downloadUrl = [string]$manifest.downloadUrl
291
+ $available = Convert-ToVersion $availableText
292
+ $installed = Convert-ToVersion $InstalledVersion
293
+ if ($null -eq $available -or $null -eq $installed -or [string]::IsNullOrWhiteSpace($downloadUrl)) {
294
+ throw 'The update manifest is incomplete.'
295
+ }
296
+ if ($available -le $installed) {
297
+ $UpdateStatus.Text = "Connector v$InstalledVersion is up to date"
298
+ if ($manual) {
299
+ [System.Windows.Forms.MessageBox]::Show(
300
+ "Sense Anything Connector v$InstalledVersion is up to date.",
301
+ 'No updates available',
302
+ [System.Windows.Forms.MessageBoxButtons]::OK,
303
+ [System.Windows.Forms.MessageBoxIcon]::Information
304
+ ) | Out-Null
305
+ }
306
+ return
307
+ }
308
+
309
+ if ($available.Major -gt $installed.Major) {
310
+ if ($script:SetupStarted) {
311
+ $script:SetupStarted = $false
312
+ Stop-SetupProcess
313
+ Resume-BackgroundSensing
314
+ }
315
+ $script:RequiredUpdateUrl = $downloadUrl
316
+ $script:LastStage = 'update_required'
317
+ $Headline.Text = 'Update required before setup can continue'
318
+ $Detail.Text = 'Install the latest Sense Anything Connector, then reopen it to finish camera setup.'
319
+ $StatusValue.Text = "Version $availableText"
320
+ $Metric.Text = 'Any existing camera service has been restored while setup is paused.'
321
+ $Progress.Value = 0
322
+ $CloseAction.Visible = $false
323
+ $UpdateStatus.Text = "Required update v$availableText available"
324
+ $Action.Text = 'Install required update'
325
+ $Action.Enabled = $true
326
+ [System.Windows.Forms.MessageBox]::Show(
327
+ "Sense Anything Connector v$availableText is a required major update. Install it before continuing.",
328
+ 'Required connector update',
329
+ [System.Windows.Forms.MessageBoxButtons]::OK,
330
+ [System.Windows.Forms.MessageBoxIcon]::Warning
331
+ ) | Out-Null
332
+ Open-ConnectorUpdate $downloadUrl
333
+ return
334
+ }
335
+
336
+ $UpdateStatus.Text = "Optional update v$availableText available"
337
+ $choice = [System.Windows.Forms.MessageBox]::Show(
338
+ "Sense Anything Connector v$availableText is available. Install it now?`r`n`r`nChoose No to keep this version.",
339
+ 'Optional connector update',
340
+ [System.Windows.Forms.MessageBoxButtons]::YesNo,
341
+ [System.Windows.Forms.MessageBoxIcon]::Information
342
+ )
343
+ if ($choice -eq [System.Windows.Forms.DialogResult]::Yes) { Open-ConnectorUpdate $downloadUrl }
344
+ } catch {
345
+ $UpdateStatus.Text = 'Could not check for updates'
346
+ if ($manual) {
347
+ [System.Windows.Forms.MessageBox]::Show(
348
+ 'The update service could not be reached. Your connector can keep running; try again later.',
349
+ 'Update check unavailable',
350
+ [System.Windows.Forms.MessageBoxButtons]::OK,
351
+ [System.Windows.Forms.MessageBoxIcon]::Information
352
+ ) | Out-Null
353
+ }
354
+ } finally {
355
+ if ($null -ne $script:UpdateClient) { $script:UpdateClient.Dispose(); $script:UpdateClient = $null }
356
+ }
209
357
  }
210
358
 
211
359
  function Start-Setup {
@@ -378,6 +526,7 @@ function Render-State($State) {
378
526
 
379
527
  $Progress.Value = [Math]::Max(0, [Math]::Min(100, (Get-ProgressValue $stage $State)))
380
528
  Update-Steps (Get-StepIndex $stage) ($stage -eq 'ready')
529
+ $CloseAction.Visible = $stage -eq 'ready'
381
530
  switch ($stage) {
382
531
  'waiting_for_account' { $Action.Text = 'Open Sense Anything'; $Action.Enabled = $true }
383
532
  'account_attention' { $Action.Text = 'Switch account'; $Action.Enabled = $true }
@@ -386,9 +535,17 @@ function Render-State($State) {
386
535
  'error' { $Action.Text = 'Try again'; $Action.Enabled = $true }
387
536
  default { $Action.Text = 'Working...'; $Action.Enabled = $false }
388
537
  }
538
+ if ($null -ne $script:RequiredUpdateUrl) {
539
+ $Action.Text = 'Install required update'
540
+ $Action.Enabled = $true
541
+ }
389
542
  }
390
543
 
391
544
  $Action.Add_Click({
545
+ if ($null -ne $script:RequiredUpdateUrl) {
546
+ Open-ConnectorUpdate $script:RequiredUpdateUrl
547
+ return
548
+ }
392
549
  switch ($script:LastStage) {
393
550
  'waiting_for_account' { Start-Process $script:PortalUrl }
394
551
  'account_attention' { Start-Process $script:PortalUrl }
@@ -402,6 +559,9 @@ $Action.Add_Click({
402
559
  }
403
560
  })
404
561
 
562
+ $UpdateAction.Add_Click({ Start-UpdateCheck $true })
563
+ $CloseAction.Add_Click({ $Form.Close() })
564
+
405
565
  $Form.Add_FormClosing({
406
566
  Stop-SetupProcess
407
567
  Resume-BackgroundSensing
@@ -410,11 +570,16 @@ $Form.Add_FormClosing({
410
570
  $script:SetupMutex.Dispose()
411
571
  $script:SetupMutex = $null
412
572
  }
573
+ if ($null -ne $script:UpdateClient) {
574
+ $script:UpdateClient.Dispose()
575
+ $script:UpdateClient = $null
576
+ }
413
577
  })
414
578
 
415
579
  $Timer = New-Object System.Windows.Forms.Timer
416
580
  $Timer.Interval = 500
417
581
  $Timer.Add_Tick({
582
+ Complete-UpdateCheck
418
583
  if ($script:SetupStarted -and (Test-Path $StatePath)) {
419
584
  try {
420
585
  $state = Get-Content -Raw -Path $StatePath | ConvertFrom-Json
@@ -477,5 +642,6 @@ if (-not $createdNew) {
477
642
  }
478
643
 
479
644
  $Timer.Start()
645
+ Start-UpdateCheck $false
480
646
  [void]$Form.ShowDialog()
481
647
  $Timer.Stop()
package/dist/EdgeAgent.js CHANGED
@@ -44,7 +44,7 @@ const edge_ads_1 = require("@trillboards/edge-ads");
44
44
  const CommandRouter_1 = require("./CommandRouter");
45
45
  const config_1 = require("./config");
46
46
  const dataDir_1 = require("./dataDir");
47
- const SDK_VERSION = '0.2.110';
47
+ const SDK_VERSION = '0.2.111';
48
48
  class EdgeAgent extends events_1.EventEmitter {
49
49
  constructor(config, deps) {
50
50
  super();
@@ -31,7 +31,7 @@ export interface DiagnosticReport {
31
31
  skipped: number;
32
32
  };
33
33
  }
34
- declare const SDK_VERSION = "0.2.110";
34
+ declare const SDK_VERSION = "0.2.111";
35
35
  declare function checkNodeVersion(): CheckResult;
36
36
  declare function checkPlatform(): CheckResult;
37
37
  declare function checkMemory(): CheckResult;
package/dist/diagnose.js CHANGED
@@ -63,7 +63,7 @@ const localNetworkAccess_1 = require("./localNetworkAccess");
63
63
  // ---------------------------------------------------------------------------
64
64
  // SDK version — mirrors package.json; kept in sync by CI
65
65
  // ---------------------------------------------------------------------------
66
- const SDK_VERSION = '0.2.110';
66
+ const SDK_VERSION = '0.2.111';
67
67
  exports._SDK_VERSION = SDK_VERSION;
68
68
  // ---------------------------------------------------------------------------
69
69
  // ANSI helpers (no external deps)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trillboards/edge-sdk",
3
- "version": "0.2.110",
3
+ "version": "0.2.111",
4
4
  "description": "Composable cross-platform Edge AI SDK for audience sensing on DOOH/CTV devices",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",