@trillboards/edge-sdk 0.2.113 → 0.2.119

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.
@@ -91,6 +91,7 @@ from box_units import ( # noqa: E402 (sibling module, stdlib-only on purpose)
91
91
  )
92
92
  from trillboards_detector.errors import ModelContractError # noqa: E402
93
93
  from trillboards_detector.labels import ( # noqa: E402
94
+ BOX_UNITS_NORMALIZED,
94
95
  BOX_UNITS_PIXELS,
95
96
  BUILTIN_VOCABULARIES,
96
97
  ModelVocabulary,
@@ -554,7 +555,11 @@ class Frame:
554
555
 
555
556
 
556
557
  def synthesise_vocabulary(
557
- name: str, class_names: "dict[int, str]", output_layout: str
558
+ name: str,
559
+ class_names: "dict[int, str]",
560
+ output_layout: str,
561
+ box_units: str = BOX_UNITS_PIXELS,
562
+ box_scale_to_pixels: float = 1.0,
558
563
  ) -> ModelVocabulary:
559
564
  """A ModelVocabulary over a model's OWN names, canonicalised the service's way.
560
565
 
@@ -602,7 +607,7 @@ def synthesise_vocabulary(
602
607
  # 0..1 boxes as 0..1; what it would lose is per-class detections on
603
608
  # padded frames, which is the signal to read the units verdict before
604
609
  # trusting that candidate's score.
605
- onnx_variant=VariantContract(output_layout, BOX_UNITS_PIXELS, 1.0),
610
+ onnx_variant=VariantContract(output_layout, box_units, box_scale_to_pixels),
606
611
  )
607
612
 
608
613
 
@@ -827,6 +832,75 @@ def main() -> int:
827
832
  if units["refusal"]:
828
833
  raise SystemExit(f"REFUSING: {units['refusal']}")
829
834
 
835
+ # ── MEASURE THE UNITS, THEN DECODE WITH THEM ─────────────────────────────
836
+ #
837
+ # The pass above ran with the identity contract (`pixels`/1.0) ON PURPOSE:
838
+ # it is the only pair that scales nothing, so the graph's own box magnitudes
839
+ # reach `box_abs_max` untouched and the classifier sees what the model
840
+ # actually emitted. What was missing is the second half — USING the answer.
841
+ #
842
+ # For a NORMALISED artifact the identity decode reads 0..1 boxes as pixels,
843
+ # and un-letterboxing then subtracts the pad offset from a sub-pixel box and
844
+ # pushes it out of frame. On a SQUARE source there is no padding and nothing
845
+ # is lost; on any other aspect ratio EVERY detection is dropped, silently,
846
+ # and the run reports a model that fires on nothing.
847
+ #
848
+ # That is not hypothetical. `fall_1cls_11x` is the one normalised ONNX
849
+ # artifact in the registry, and `fall-2026-08-25` scored it on 14 positives
850
+ # that are 1024x1024 SQUARE against 80 negatives that are 640x480. The
851
+ # positives survived, the negatives were all dropped, and the card published
852
+ # `precision 1.0` with `fire_rate_on_labelled_empty: 0` — a measurement of
853
+ # the harness's blind spot wearing the model's name. Measured 2026-08-28:
854
+ # the same 80 frames score up to 0.9400 through the SERVICE's decode, which
855
+ # reads the declared contract, against a published operating threshold of
856
+ # 0.8616873025894165.
857
+ #
858
+ # So a normalised verdict re-runs the scored pass with the measured contract.
859
+ # It costs a second pass on the artifacts that need one and nothing on the
860
+ # ones that do not, and it cannot flatter a candidate: the second pass is the
861
+ # decode the SERVICE would use, and it can only ADD detections the first pass
862
+ # was unable to represent — including on negatives.
863
+ decoded_units = BOX_UNITS_PIXELS
864
+ redecoded = False
865
+ if units["units"] == BOX_UNITS_NORMALIZED and isinstance(runner, ShippedRunner) and declared_names:
866
+ redecoded = True
867
+ runner = ShippedRunner(
868
+ model_path,
869
+ synthesise_vocabulary(
870
+ args.candidate, declared_names, loadability["output_layout"],
871
+ BOX_UNITS_NORMALIZED, float(units["scale_to_pixels"]),
872
+ ),
873
+ )
874
+ detections = {}
875
+ infer_ms = []
876
+ wall_ms = []
877
+ for key in keys:
878
+ frame = Frame(frames_dir / os.path.basename(key))
879
+ started = time.perf_counter()
880
+ per_class, ms, _box_max = runner.run(frame, args.conf_floor)
881
+ wall_ms.append((time.perf_counter() - started) * 1000.0)
882
+ infer_ms.append(ms)
883
+ detections[key] = per_class
884
+ frame.release()
885
+ decoded_units = BOX_UNITS_NORMALIZED
886
+ sys.stderr.write(
887
+ f" re-decoded {len(keys)} frame(s) with the MEASURED contract "
888
+ f"({BOX_UNITS_NORMALIZED} x{units['scale_to_pixels']:g}); the first pass used the identity "
889
+ "to measure the units and could not represent this artifact's boxes\n"
890
+ )
891
+ elif units["units"] == BOX_UNITS_NORMALIZED:
892
+ # No path to a correct decode from here: either the graph declares no
893
+ # names to synthesise a vocabulary from, or the runner is not the shipped
894
+ # one. REFUSE rather than emit a run whose detections were decoded with
895
+ # units the same run reports as wrong — that combination is what produced
896
+ # `fall_1cls_11x`'s card.
897
+ raise SystemExit(
898
+ f"REFUSING: {model_path.name} emits NORMALIZED boxes (ratio {units['ratio']:.4f}) and this "
899
+ "runner cannot be rebuilt with that contract, so every detection on a non-square frame was "
900
+ "silently dropped. Score it through the detector service instead: "
901
+ "node trillboard-api/scripts/sensing/score-venue-corpus.js --model <id> --sweep"
902
+ )
903
+
830
904
  same_source = bool(rss_before and rss_after_probe and rss_before[1] == rss_after_probe[1])
831
905
  rss = {
832
906
  "source": rss_before[1] if rss_before else None,
@@ -855,6 +929,17 @@ def main() -> int:
855
929
  "sha256": sha256_file(model_path),
856
930
  "bytes": model_path.stat().st_size,
857
931
  },
932
+ "decode": {
933
+ "units_measured": units["units"],
934
+ "units_decoded_with": decoded_units,
935
+ "rescored_after_measuring_units": redecoded,
936
+ "why": (
937
+ "The scored pass is decoded with the units the run MEASURED, not with the identity it "
938
+ "measured them with. A normalized artifact decoded as pixels loses every detection on a "
939
+ "letterbox-padded frame and keeps them on a square one, which is a property of the eval "
940
+ "set's aspect ratios and not of the model."
941
+ ),
942
+ },
858
943
  "eval_set_manifest": {
859
944
  "path": str(manifest_path),
860
945
  "sha256": sha256_file(manifest_path),
@@ -0,0 +1,126 @@
1
+ """Who answers a class when two models on one box can both emit it.
2
+
3
+ `BUILTIN_VOCABULARIES` states the principle — "a class stays on the model it
4
+ was SCORED on" — and used catalogue position as the mechanism. Position is a
5
+ proxy and it had already failed in the shipped catalogue: `fall detected` is
6
+ named by `ppe_vyra_14cls_v8m` (position 3) and by `fall_1cls_11x` (position 6),
7
+ so order handed falls to vyra, whose score card fitted that class at the 0.05
8
+ FLOOR on 1 hit of a HARDHAT corpus, while the model actually scored on
9
+ `person_fall` — 18/94, fitted threshold 0.8783 — sat idle on the same box.
10
+
11
+ Nothing about that is visible downstream. Both models emit real boxes with
12
+ plausible confidences; the only difference is a threshold nobody fitted, on a
13
+ class nobody scored, and every health surface stays green.
14
+
15
+ So ownership is now DECLARED (`ModelVocabulary.owns`) and position decides only
16
+ what no model declares. These tests pin the three things that can silently
17
+ regress: the declared owner wins, an absent declarer does not black-hole its
18
+ class, and two declarers is an error rather than a coin flip.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import unittest
24
+
25
+ from trillboards_detector.labels import (
26
+ BUILTIN_VOCABULARIES,
27
+ ClassIndex,
28
+ ModelVocabulary,
29
+ VariantContract,
30
+ normalize_class_name,
31
+ )
32
+
33
+ _VARIANT = VariantContract(
34
+ output_layout="anchor_4_plus_nc", box_units="normalized", box_scale_to_pixels=640.0
35
+ )
36
+
37
+
38
+ def _vocab(name, classes, owns=frozenset()):
39
+ return ModelVocabulary(
40
+ model_name=name,
41
+ slot_names={i: c for i, c in enumerate(classes)},
42
+ logit_slots=len(classes),
43
+ approx_ram_bytes=1,
44
+ onnx_variant=_VARIANT,
45
+ owns=owns,
46
+ )
47
+
48
+
49
+ class DeclaredOwnershipTest(unittest.TestCase):
50
+ def test_declared_owner_beats_catalogue_order(self):
51
+ general = _vocab("general", ["person", "fall detected"])
52
+ specialist = _vocab("specialist", ["fall detected"], owns=frozenset({"fall detected"}))
53
+ # `general` is FIRST, which is what used to decide it.
54
+ index = ClassIndex([general, specialist])
55
+ self.assertEqual(index.owner_of("fall detected"), "specialist")
56
+ self.assertEqual(index.owner_of("person"), "general")
57
+
58
+ def test_position_still_decides_what_nobody_declares(self):
59
+ a = _vocab("a", ["mask", "gloves"])
60
+ b = _vocab("b", ["mask"])
61
+ self.assertEqual(ClassIndex([a, b]).owner_of("mask"), "a")
62
+ self.assertEqual(ClassIndex([b, a]).owner_of("mask"), "b")
63
+
64
+ def test_a_declarer_that_is_not_resident_owns_nothing(self):
65
+ # `ClassIndex` is built over models whose artifact is ON THE BOX. If the
66
+ # specialist has not arrived, its class must fall through to the model
67
+ # that is actually there rather than becoming unanswerable.
68
+ general = _vocab("general", ["person", "fall detected"])
69
+ index = ClassIndex([general])
70
+ self.assertEqual(index.owner_of("fall detected"), "general")
71
+ self.assertIn("fall detected", index.class_names)
72
+
73
+ def test_two_declarers_is_an_error_not_a_coin_flip(self):
74
+ a = _vocab("a", ["fire"], owns=frozenset({"fire"}))
75
+ b = _vocab("b", ["fire"], owns=frozenset({"fire"}))
76
+ with self.assertRaisesRegex(ValueError, "one authority"):
77
+ ClassIndex([a, b])
78
+
79
+ def test_a_model_cannot_own_a_class_it_cannot_emit(self):
80
+ with self.assertRaisesRegex(ValueError, "not one of its own classes"):
81
+ _vocab("a", ["person"], owns=frozenset({"forklift"}))
82
+
83
+ def test_owned_names_must_be_canonical(self):
84
+ # `owner_of` keys on the canonical name, so `fall_detected` here would
85
+ # own nothing at all while looking correct in the source.
86
+ with self.assertRaisesRegex(ValueError, "not canonical"):
87
+ _vocab("a", ["fall detected"], owns=frozenset({"fall_detected"}))
88
+
89
+
90
+ class ShippedCatalogueTest(unittest.TestCase):
91
+ """The catalogue as it ships, so a new model cannot quietly take a class."""
92
+
93
+ def test_every_owned_class_is_canonical_and_emittable(self):
94
+ for vocabulary in BUILTIN_VOCABULARIES:
95
+ for owned in vocabulary.owns:
96
+ self.assertEqual(owned, normalize_class_name(owned), vocabulary.model_name)
97
+ self.assertIn(owned, vocabulary.class_names, vocabulary.model_name)
98
+
99
+ def test_the_scored_model_owns_its_class(self):
100
+ index = ClassIndex(BUILTIN_VOCABULARIES)
101
+ # Each of these is a class whose fitted threshold lives on exactly one
102
+ # model's score card, and which at least one OTHER model also emits.
103
+ self.assertEqual(index.owner_of("fall detected"), "fall_1cls_11x")
104
+ self.assertEqual(index.owner_of("person"), "rfdetr_medium")
105
+ self.assertEqual(index.owner_of("hardhat"), "ppe_vyra_14cls_v8m")
106
+
107
+ def test_declaration_changes_exactly_one_ownership(self):
108
+ # The blast radius, asserted rather than assumed: `owns` is a correction
109
+ # to one class, not a re-shuffle of a 124-class union. If a future model
110
+ # moves a second class, that is a decision someone must make on purpose.
111
+ index = ClassIndex(BUILTIN_VOCABULARIES)
112
+ by_position: dict[str, str] = {}
113
+ for vocabulary in BUILTIN_VOCABULARIES:
114
+ for name in sorted(vocabulary.class_names):
115
+ by_position.setdefault(name, vocabulary.model_name)
116
+ moved = {n: (b, index.owner_of(n)) for n, b in by_position.items() if b != index.owner_of(n)}
117
+ self.assertEqual(moved, {"fall detected": ("ppe_vyra_14cls_v8m", "fall_1cls_11x")})
118
+
119
+ def test_no_class_is_left_without_an_owner(self):
120
+ index = ClassIndex(BUILTIN_VOCABULARIES)
121
+ for name in index.class_names:
122
+ self.assertIsNotNone(index.owner_of(name), name)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()
@@ -275,6 +275,26 @@ class ModelVocabulary:
275
275
  #: axis over. A model whose output layout is inferred is a model whose boxes
276
276
  #: are plausible and wrong.
277
277
  onnx_variant: "VariantContract"
278
+ #: The classes THIS model is the authority for, canonical spelling.
279
+ #:
280
+ #: The box-side copy of a relation the registry already holds: a model earns
281
+ #: a class by declaring `<class>_present` / `<class>_count` in its
282
+ #: `outputs_schema`, which is also the field its FITTED threshold hangs off
283
+ #: in `liveness_evidence.classes`. Same discipline as `slot_names` and
284
+ #: `onnx_variant` — the registry row is the authority, this is the copy the
285
+ #: service reads because there is no PostgreSQL on a shop PC, and check 10
286
+ #: of `check-sensing-model-publish-gate.js` fails the build when they drift.
287
+ #:
288
+ #: WHY THIS EXISTS RATHER THAN CATALOGUE ORDER. `BUILTIN_VOCABULARIES` states
289
+ #: the right principle — a class stays on the model it was SCORED on — and
290
+ #: position is a proxy for it that has already failed once. `fall detected`
291
+ #: is named by `ppe_vyra_14cls_v8m` (position 3) and by `fall_1cls_11x`
292
+ #: (position 6), so order hands falls to vyra, whose score card fitted that
293
+ #: class at the 0.05 FLOOR on 1 hit of a hardhat corpus, while the model
294
+ #: actually scored on `person_fall` — 18/94, fitted 0.8783 — sits idle. A
295
+ #: threshold nobody fitted, on plausible boxes, with every health surface
296
+ #: green. Declaration is the in-band signal position was standing in for.
297
+ owns: "frozenset[str]" = frozenset()
278
298
 
279
299
  def __post_init__(self) -> None:
280
300
  # The model name IS A DIRECTORY NAME — `resolve_model_path` puts the
@@ -310,6 +330,19 @@ class ModelVocabulary:
310
330
  f"{self.model_name}: class name {name!r} is not canonical "
311
331
  f"(expected {normalize_class_name(name)!r})"
312
332
  )
333
+ for owned in sorted(self.owns):
334
+ # A model cannot be the authority for a class it cannot emit, and an
335
+ # un-canonical entry here would silently own nothing — `ClassIndex`
336
+ # keys on the canonical name.
337
+ if normalize_class_name(owned) != owned:
338
+ raise ValueError(
339
+ f"{self.model_name}: owned class {owned!r} is not canonical "
340
+ f"(expected {normalize_class_name(owned)!r})"
341
+ )
342
+ if owned not in set(self.slot_names.values()):
343
+ raise ValueError(
344
+ f"{self.model_name}: claims to own {owned!r}, which is not one of its own classes"
345
+ )
313
346
  if len(set(self.slot_names.values())) != len(self.slot_names):
314
347
  raise ValueError(f"{self.model_name}: two slots share one class name, so a filter would be ambiguous")
315
348
 
@@ -330,6 +363,7 @@ RFDETR_MEDIUM = ModelVocabulary(
330
363
  logit_slots=EXPECTED_LOGIT_SLOTS,
331
364
  approx_ram_bytes=632 * 1024 * 1024,
332
365
  onnx_variant=RFDETR_MEDIUM_ONNX,
366
+ owns=frozenset({"person"}),
333
367
  )
334
368
 
335
369
  #: Every model this build knows how to label, IN PRIORITY ORDER.
@@ -367,6 +401,7 @@ GH_FORKLIFT_COMBINE = ModelVocabulary(
367
401
  box_units="pixels",
368
402
  box_scale_to_pixels=1.0,
369
403
  ),
404
+ owns=frozenset({"forklift"}),
370
405
  )
371
406
 
372
407
  #: PPE Detector (hard hat) — published 2026-08-25 by
@@ -401,6 +436,7 @@ PPE_VYRA_14CLS_V8M = ModelVocabulary(
401
436
  box_units="pixels",
402
437
  box_scale_to_pixels=1.0,
403
438
  ),
439
+ owns=frozenset({"hardhat"}),
404
440
  )
405
441
 
406
442
  #: Heavy Machinery Detector — published 2026-08-25 by
@@ -438,6 +474,7 @@ PPE_CONSTRUCTION_17CLS_V8N = ModelVocabulary(
438
474
  box_units="pixels",
439
475
  box_scale_to_pixels=1.0,
440
476
  ),
477
+ owns=frozenset({"excavators"}),
441
478
  )
442
479
 
443
480
  #: Fire-Safety Detector — published 2026-08-25 by
@@ -477,6 +514,7 @@ PPE_KILLUMINATI_19CLS_V8S = ModelVocabulary(
477
514
  box_units="pixels",
478
515
  box_scale_to_pixels=1.0,
479
516
  ),
517
+ owns=frozenset({"fire extinguisher"}),
480
518
  )
481
519
 
482
520
  #: FIVE SPECIALISTS WERE SCORED; FOUR ARE HERE. `gh_fall_tonlong` IS ABSENT ON
@@ -542,6 +580,7 @@ FALL_1CLS_11X = ModelVocabulary(
542
580
  box_units="normalized",
543
581
  box_scale_to_pixels=640.0,
544
582
  ),
583
+ owns=frozenset({"fall detected"}),
545
584
  )
546
585
 
547
586
  #: Heavy Machinery Detector (venue-adjudicated) — published 2026-08-27 by
@@ -572,6 +611,7 @@ GH_PPE_VINAYAK_L = ModelVocabulary(
572
611
  box_units="pixels",
573
612
  box_scale_to_pixels=1.0,
574
613
  ),
614
+ owns=frozenset({"machinery"}),
575
615
  )
576
616
 
577
617
  #: Fire and Smoke Detector — published 2026-08-27 by
@@ -594,6 +634,7 @@ FIRE_SMOKE_WESLLEYSKAH_11N = ModelVocabulary(
594
634
  box_units="pixels",
595
635
  box_scale_to_pixels=1.0,
596
636
  ),
637
+ owns=frozenset({"fire", "smoke"}),
597
638
  )
598
639
 
599
640
  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,)
@@ -611,11 +652,27 @@ class ClassIndex:
611
652
  def __init__(self, vocabularies: "Sequence[ModelVocabulary]"):
612
653
  self._vocabularies = tuple(vocabularies)
613
654
  owner: "dict[str, str]" = {}
655
+ # DECLARED ownership first, and it is exclusive. `owns` is the class a
656
+ # model was scored on, so this is the principle the catalogue order was
657
+ # standing in for, applied directly. Two models declaring one class is
658
+ # unresolvable rather than arbitrary, so it raises here instead of
659
+ # letting whichever sorted first answer at whatever threshold it carries.
660
+ for vocabulary in self._vocabularies:
661
+ for name in sorted(vocabulary.owns):
662
+ if name in owner:
663
+ raise ValueError(
664
+ f"{name!r} is declared owned by both {owner[name]} and "
665
+ f"{vocabulary.model_name}; a class has one authority"
666
+ )
667
+ owner[name] = vocabulary.model_name
614
668
  for vocabulary in self._vocabularies:
615
669
  for name in sorted(vocabulary.class_names):
616
- # First-in-catalogue-order wins. `setdefault`, not assignment:
617
- # a later specialist must not capture a class an earlier model
618
- # already owns.
670
+ # Catalogue order for everything NOBODY declares — a class no
671
+ # model was scored on has no principled owner, and order at
672
+ # least keeps it stable. `setdefault`, not assignment: a later
673
+ # specialist must not capture a class an earlier model already
674
+ # owns. A declarer that is not RESIDENT owns nothing, so its
675
+ # class falls through to the best model actually on the box.
619
676
  owner.setdefault(name, vocabulary.model_name)
620
677
  if len({v.model_name for v in self._vocabularies}) != len(self._vocabularies):
621
678
  raise ValueError("two vocabularies share one model name")
@@ -112,7 +112,12 @@ export declare function brandPathCandidates(brand: string | undefined, ch: numbe
112
112
  export declare function credentialFreePathTemplate(pathValue: string, brand: string | undefined): string;
113
113
  /** Restore a credential-free path recipe while writing the local-only config. */
114
114
  export declare function materializeCredentialPathTemplate(pathTemplate: string, username: string, password: string): string;
115
- /** Percent-encode credentials into an rtsp:// URL (the Hiten `pass@123` class). */
115
+ /**
116
+ * Build an RTSP URL without duplicating a vendor's in-path authentication.
117
+ * Ordinary recorders keep RFC userinfo with percent-encoded credentials.
118
+ * XMEye/NETSurveillance paths already carry both values and older firmware
119
+ * rejects the same login when it is also repeated in the URL authority.
120
+ */
116
121
  export declare function buildRtspUrl(host: string, port: number, path: string, username: string, password: string): string;
117
122
  /** Inject creds into an ONVIF-returned rtsp URI (they usually come credless). */
118
123
  export declare function injectCreds(uri: string, username: string, password: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"ChannelProber.d.ts","sourceRoot":"","sources":["../src/ChannelProber.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ChannelProber.d.ts","sourceRoot":"","sources":["../src/ChannelProber.ts"],"names":[],"mappings":"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"}
@@ -160,12 +160,10 @@ function brandPathCandidates(brand, ch, credentials) {
160
160
  `/ch${String(ch).padStart(2, '0')}/0`,
161
161
  ];
162
162
  const uniview = [`/unicast/c${ch}/s1/live`, `/unicast/c${ch}/s0/live`];
163
- const xmeyeUser = encodeURIComponent(credentials?.username ?? 'admin');
164
- const xmeyePassword = encodeURIComponent(credentials?.password ?? '');
165
163
  const hanwha = [`/LiveChannel/${Math.max(0, ch - 1)}/media.smp`];
166
164
  const xmeye = [
167
- `/user=${xmeyeUser}&password=${xmeyePassword}&channel=${ch}&stream=1.sdp?real_stream`,
168
- `/user=${xmeyeUser}&password=${xmeyePassword}&channel=${ch}&stream=0.sdp?real_stream`,
165
+ materializeCredentialPathTemplate(`/user={username}&password={password}&channel=${ch}&stream=1.sdp?real_stream`, credentials?.username ?? 'admin', credentials?.password ?? ''),
166
+ materializeCredentialPathTemplate(`/user={username}&password={password}&channel=${ch}&stream=0.sdp?real_stream`, credentials?.username ?? 'admin', credentials?.password ?? ''),
169
167
  ];
170
168
  const h264Preview = [
171
169
  `/h264Preview_${String(ch).padStart(2, '0')}_sub`,
@@ -286,17 +284,45 @@ function credentialFreePathTemplate(pathValue, brand) {
286
284
  return pathValue;
287
285
  return pathValue.replace(/^\/user=[^&]*&password=[^&]*(&channel=)/i, '/user={username}&password={password}$1');
288
286
  }
287
+ function canUseLegacyXmeyePathCredential(value) {
288
+ // Old NETSurveillance/Xiongmai firmware parses these values itself instead
289
+ // of URL-decoding them. In the field, a password containing `@` worked only
290
+ // as a literal path value and was rejected as `%40`. Keep that compatibility
291
+ // fallback deliberately narrow: delimiters/control characters would change
292
+ // the request target and therefore remain percent-encoded.
293
+ return !/[&?#%/=\s\u0000-\u001f\u007f]/.test(value);
294
+ }
295
+ function isXmeyeCredentialPathTemplate(pathTemplate) {
296
+ return /^\/user=\{username\}&password=\{password\}&channel=/i.test(pathTemplate);
297
+ }
289
298
  /** Restore a credential-free path recipe while writing the local-only config. */
290
299
  function materializeCredentialPathTemplate(pathTemplate, username, password) {
300
+ const legacyRaw = isXmeyeCredentialPathTemplate(pathTemplate) &&
301
+ canUseLegacyXmeyePathCredential(username) &&
302
+ canUseLegacyXmeyePathCredential(password);
303
+ const materialize = (value) => legacyRaw ? value : encodeURIComponent(value);
291
304
  return pathTemplate
292
- .replace(/\{username\}/g, encodeURIComponent(username))
293
- .replace(/\{password\}/g, encodeURIComponent(password));
305
+ .replace(/\{username\}/g, materialize(username))
306
+ .replace(/\{password\}/g, materialize(password));
294
307
  }
295
- /** Percent-encode credentials into an rtsp:// URL (the Hiten `pass@123` class). */
308
+ function pathCarriesCredentials(pathValue) {
309
+ const hasUser = /(^|[?&/])(user|username|usr)=[^&#]*/i.test(pathValue);
310
+ const hasPassword = /(^|[?&/])(password|passwd|pwd|pass)=[^&#]*/i.test(pathValue);
311
+ return hasUser && hasPassword;
312
+ }
313
+ /**
314
+ * Build an RTSP URL without duplicating a vendor's in-path authentication.
315
+ * Ordinary recorders keep RFC userinfo with percent-encoded credentials.
316
+ * XMEye/NETSurveillance paths already carry both values and older firmware
317
+ * rejects the same login when it is also repeated in the URL authority.
318
+ */
296
319
  function buildRtspUrl(host, port, path, username, password) {
320
+ const norm = path.startsWith('/') ? path : `/${path}`;
321
+ if (pathCarriesCredentials(norm)) {
322
+ return `rtsp://${host}:${port}${norm}`;
323
+ }
297
324
  const u = encodeURIComponent(username);
298
325
  const p = encodeURIComponent(password);
299
- const norm = path.startsWith('/') ? path : `/${path}`;
300
326
  return `rtsp://${u}:${p}@${host}:${port}${norm}`;
301
327
  }
302
328
  /** Inject creds into an ONVIF-returned rtsp URI (they usually come credless). */