@camstack/addon-post-analysis 1.2.71 → 1.2.73
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.
- package/dist/{dist-DjPaVd2L.js → dist-DDv-TKUR.js} +2011 -81
- package/dist/{dist-B--dE3Ry.mjs → dist-_D0jdUh4.mjs} +2006 -82
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-3nHxWRIp.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cbess8m-.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DE456p1Q.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-_DSzSo5N.mjs → hostInit-D4P0Tmio.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2810 -271
- package/dist/pipeline-analytics/index.mjs +2803 -271
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B57Jy4_o.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-DDv-TKUR.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -1579,6 +1579,401 @@ var NC_ALARM_DECLARATION = {
|
|
|
1579
1579
|
DeviceClass: NcAlarmPanelDevice
|
|
1580
1580
|
};
|
|
1581
1581
|
//#endregion
|
|
1582
|
+
//#region src/notification-center/artifact-index.ts
|
|
1583
|
+
var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
|
|
1584
|
+
var NcArtifactKindSchema = require_dist._enum([
|
|
1585
|
+
"timelapse",
|
|
1586
|
+
"timelapse-preview",
|
|
1587
|
+
"summary-mosaic"
|
|
1588
|
+
]);
|
|
1589
|
+
/**
|
|
1590
|
+
* How the viewer should DRAW it. Derived from the mime once, here, so no client
|
|
1591
|
+
* has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
|
|
1592
|
+
* the JPEG it actually is.
|
|
1593
|
+
*/
|
|
1594
|
+
var NcArtifactMediaTypeSchema = require_dist._enum([
|
|
1595
|
+
"image",
|
|
1596
|
+
"gif",
|
|
1597
|
+
"video"
|
|
1598
|
+
]);
|
|
1599
|
+
/** The relative hub path of the authenticated bytes route for an artefact id. */
|
|
1600
|
+
var NC_ARTIFACT_READ_PREFIX = "nc-artifact-read";
|
|
1601
|
+
var NcArtifactRecordSchema = require_dist.object({
|
|
1602
|
+
/** The `NcArtifactStore` id — the same id the bytes route resolves. */
|
|
1603
|
+
id: require_dist.string().min(1),
|
|
1604
|
+
kind: NcArtifactKindSchema,
|
|
1605
|
+
mediaType: NcArtifactMediaTypeSchema,
|
|
1606
|
+
mime: require_dist.string().min(1),
|
|
1607
|
+
sizeBytes: require_dist.number().int().nonnegative(),
|
|
1608
|
+
/** When the artefact was PRODUCED. */
|
|
1609
|
+
createdAt: require_dist.number().int(),
|
|
1610
|
+
/** What the content is ABOUT (window end / event time). The sort key: a
|
|
1611
|
+
* timelapse rendered at 06:05 is about the night before, and a list ordered
|
|
1612
|
+
* by production time puts it in the wrong morning. */
|
|
1613
|
+
capturedAt: require_dist.number().int(),
|
|
1614
|
+
/** After this the bytes are gone. Derived from the retained shelf's own age
|
|
1615
|
+
* bound — never a second retention policy. */
|
|
1616
|
+
expiresAt: require_dist.number().int(),
|
|
1617
|
+
/** The camera the artefact is FILED under. Null only if it belongs to none. */
|
|
1618
|
+
deviceId: require_dist.number().int().nullable(),
|
|
1619
|
+
/** Every camera the artefact covers, for display. */
|
|
1620
|
+
deviceIds: require_dist.array(require_dist.number().int()),
|
|
1621
|
+
ruleId: require_dist.string().optional(),
|
|
1622
|
+
ruleName: require_dist.string().optional(),
|
|
1623
|
+
title: require_dist.string().optional(),
|
|
1624
|
+
/** A companion still for a video artefact (a timelapse's preview frame) —
|
|
1625
|
+
* what a list renders as the thumbnail. */
|
|
1626
|
+
posterArtifactId: require_dist.string().optional(),
|
|
1627
|
+
/** RELATIVE hub path of the authenticated bytes route. Relative on purpose:
|
|
1628
|
+
* the viewer already holds the hub origin it is authenticated against, and
|
|
1629
|
+
* only the SIGNED plane needs to guess which host a stranger can reach. */
|
|
1630
|
+
downloadPath: require_dist.string().min(1)
|
|
1631
|
+
});
|
|
1632
|
+
var NcArtifactQuerySchema = require_dist.object({
|
|
1633
|
+
kinds: require_dist.array(NcArtifactKindSchema).optional(),
|
|
1634
|
+
deviceIds: require_dist.array(require_dist.number().int()).optional(),
|
|
1635
|
+
ruleId: require_dist.string().optional(),
|
|
1636
|
+
/** Inclusive `capturedAt` window. */
|
|
1637
|
+
fromMs: require_dist.number().int().optional(),
|
|
1638
|
+
toMs: require_dist.number().int().optional(),
|
|
1639
|
+
limit: require_dist.number().int().min(1).max(200).default(50),
|
|
1640
|
+
offset: require_dist.number().int().min(0).default(0)
|
|
1641
|
+
});
|
|
1642
|
+
var NC_ARTIFACTS_COLUMNS = [
|
|
1643
|
+
{
|
|
1644
|
+
name: "id",
|
|
1645
|
+
type: "TEXT",
|
|
1646
|
+
primaryKey: true,
|
|
1647
|
+
notNull: true
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
name: "kind",
|
|
1651
|
+
type: "TEXT",
|
|
1652
|
+
notNull: true
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
name: "deviceId",
|
|
1656
|
+
type: "INTEGER"
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
name: "ruleId",
|
|
1660
|
+
type: "TEXT"
|
|
1661
|
+
},
|
|
1662
|
+
(
|
|
1663
|
+
/** `''` for a global artefact — see the header note on visibility. */
|
|
1664
|
+
{
|
|
1665
|
+
name: "ownerUserId",
|
|
1666
|
+
type: "TEXT",
|
|
1667
|
+
notNull: true
|
|
1668
|
+
}),
|
|
1669
|
+
{
|
|
1670
|
+
name: "capturedAt",
|
|
1671
|
+
type: "INTEGER",
|
|
1672
|
+
notNull: true
|
|
1673
|
+
},
|
|
1674
|
+
{
|
|
1675
|
+
name: "expiresAt",
|
|
1676
|
+
type: "INTEGER",
|
|
1677
|
+
notNull: true
|
|
1678
|
+
},
|
|
1679
|
+
(
|
|
1680
|
+
/** The FULL record (Zod-validated on read) — the scalars above are indexed
|
|
1681
|
+
* projections only. */
|
|
1682
|
+
{
|
|
1683
|
+
name: "record",
|
|
1684
|
+
type: "JSON",
|
|
1685
|
+
notNull: true
|
|
1686
|
+
})
|
|
1687
|
+
];
|
|
1688
|
+
var NC_ARTIFACTS_INDEXES = [
|
|
1689
|
+
{
|
|
1690
|
+
name: "idx_nc_artifacts_captured",
|
|
1691
|
+
columns: ["capturedAt"]
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
name: "idx_nc_artifacts_device",
|
|
1695
|
+
columns: ["deviceId", "capturedAt"]
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
name: "idx_nc_artifacts_kind",
|
|
1699
|
+
columns: ["kind", "capturedAt"]
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
name: "idx_nc_artifacts_expires",
|
|
1703
|
+
columns: ["expiresAt"]
|
|
1704
|
+
}
|
|
1705
|
+
];
|
|
1706
|
+
/** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
|
|
1707
|
+
var TIME_MAX = 864e13;
|
|
1708
|
+
var TIME_MIN = -864e13;
|
|
1709
|
+
/** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
|
|
1710
|
+
* entries by default), so this only has to be comfortably above it. */
|
|
1711
|
+
var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
|
|
1712
|
+
/** `image/gif` → `gif`. Keyed on what the bytes ARE, never on what was asked
|
|
1713
|
+
* for: a GIF that degraded to a still must be drawn as a still. */
|
|
1714
|
+
function mediaTypeForMime(mime) {
|
|
1715
|
+
if (mime === "image/gif") return "gif";
|
|
1716
|
+
if (mime.startsWith("video/")) return "video";
|
|
1717
|
+
return "image";
|
|
1718
|
+
}
|
|
1719
|
+
/** The relative hub path an authenticated client fetches the bytes from. */
|
|
1720
|
+
function artifactDownloadPath(addonId, artifactId) {
|
|
1721
|
+
return `/addon/${addonId}/${NC_ARTIFACT_READ_PREFIX}/${artifactId}`;
|
|
1722
|
+
}
|
|
1723
|
+
var NcArtifactIndex = class {
|
|
1724
|
+
store;
|
|
1725
|
+
logger;
|
|
1726
|
+
addonId;
|
|
1727
|
+
retentionMs;
|
|
1728
|
+
now;
|
|
1729
|
+
constructor(deps) {
|
|
1730
|
+
this.store = deps.store;
|
|
1731
|
+
this.logger = deps.logger;
|
|
1732
|
+
this.addonId = deps.addonId;
|
|
1733
|
+
this.retentionMs = deps.retentionMs;
|
|
1734
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1735
|
+
}
|
|
1736
|
+
static async declare(store) {
|
|
1737
|
+
await store.declareCollection.mutate({
|
|
1738
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1739
|
+
columns: [...NC_ARTIFACTS_COLUMNS],
|
|
1740
|
+
indexes: [...NC_ARTIFACTS_INDEXES]
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Record one retained artefact. Returns the row as the viewer will see it, or
|
|
1745
|
+
* null when the write failed — and a failure is LOGGED, per camera: an
|
|
1746
|
+
* artefact silently absent from the index reads to an operator as an artefact
|
|
1747
|
+
* that was never produced.
|
|
1748
|
+
*/
|
|
1749
|
+
async record(input) {
|
|
1750
|
+
const createdAt = this.now();
|
|
1751
|
+
const record = NcArtifactRecordSchema.parse({
|
|
1752
|
+
id: input.id,
|
|
1753
|
+
kind: input.kind,
|
|
1754
|
+
mediaType: mediaTypeForMime(input.mime),
|
|
1755
|
+
mime: input.mime,
|
|
1756
|
+
sizeBytes: input.sizeBytes,
|
|
1757
|
+
createdAt,
|
|
1758
|
+
capturedAt: input.capturedAt,
|
|
1759
|
+
expiresAt: createdAt + this.retentionMs,
|
|
1760
|
+
deviceId: input.deviceId,
|
|
1761
|
+
deviceIds: [...input.deviceIds],
|
|
1762
|
+
...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
|
|
1763
|
+
...input.ruleName !== void 0 ? { ruleName: input.ruleName } : {},
|
|
1764
|
+
...input.title !== void 0 ? { title: input.title } : {},
|
|
1765
|
+
...input.posterArtifactId !== void 0 ? { posterArtifactId: input.posterArtifactId } : {},
|
|
1766
|
+
downloadPath: artifactDownloadPath(this.addonId, input.id)
|
|
1767
|
+
});
|
|
1768
|
+
try {
|
|
1769
|
+
await this.store.set.mutate({
|
|
1770
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1771
|
+
key: record.id,
|
|
1772
|
+
value: {
|
|
1773
|
+
kind: record.kind,
|
|
1774
|
+
deviceId: record.deviceId,
|
|
1775
|
+
ruleId: record.ruleId ?? "",
|
|
1776
|
+
ownerUserId: input.ownerUserId ?? "",
|
|
1777
|
+
capturedAt: record.capturedAt,
|
|
1778
|
+
expiresAt: record.expiresAt,
|
|
1779
|
+
record
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
return record;
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
this.logger.warn("artifact not indexed — it will not appear in the viewer", {
|
|
1785
|
+
...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
|
|
1786
|
+
meta: {
|
|
1787
|
+
artifactId: record.id,
|
|
1788
|
+
kind: record.kind,
|
|
1789
|
+
error: String(err)
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* One page, newest `capturedAt` first.
|
|
1797
|
+
*
|
|
1798
|
+
* Expiry is a PREDICATE, not a post-filter: a row whose bytes the shelf has
|
|
1799
|
+
* already swept must not consume a slot of the page the caller asked for.
|
|
1800
|
+
*/
|
|
1801
|
+
async list(query, viewer) {
|
|
1802
|
+
const filter = this.buildFilter(query, viewer);
|
|
1803
|
+
try {
|
|
1804
|
+
const [rows, total] = await Promise.all([this.store.query.query({
|
|
1805
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1806
|
+
filter: {
|
|
1807
|
+
...filter,
|
|
1808
|
+
orderBy: {
|
|
1809
|
+
field: "capturedAt",
|
|
1810
|
+
direction: "desc"
|
|
1811
|
+
},
|
|
1812
|
+
limit: query.limit,
|
|
1813
|
+
offset: query.offset
|
|
1814
|
+
}
|
|
1815
|
+
}), this.store.count.query({
|
|
1816
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1817
|
+
filter
|
|
1818
|
+
})]);
|
|
1819
|
+
const artifacts = this.parseRows(rows);
|
|
1820
|
+
return {
|
|
1821
|
+
artifacts,
|
|
1822
|
+
total,
|
|
1823
|
+
hasMore: query.offset + artifacts.length < total
|
|
1824
|
+
};
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
this.logger.warn("artifact index query failed", { meta: { error: String(err) } });
|
|
1827
|
+
return {
|
|
1828
|
+
artifacts: [],
|
|
1829
|
+
total: 0,
|
|
1830
|
+
hasMore: false
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* One artefact by id, or null when it does not exist, has expired, or belongs
|
|
1836
|
+
* to another user. All three answer the same way ON PURPOSE — a surface that
|
|
1837
|
+
* distinguishes "not yours" from "not there" tells a caller what other people
|
|
1838
|
+
* own.
|
|
1839
|
+
*/
|
|
1840
|
+
async get(artifactId, viewer) {
|
|
1841
|
+
const row = await this.readRow(artifactId);
|
|
1842
|
+
if (row === null) return null;
|
|
1843
|
+
if (row.record.expiresAt <= this.now()) return null;
|
|
1844
|
+
if (!viewer.isAdmin && row.ownerUserId !== "" && row.ownerUserId !== viewer.userId) return null;
|
|
1845
|
+
return row.record;
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Resolve an id for the BYTES route — visibility-free by design.
|
|
1849
|
+
*
|
|
1850
|
+
* The route is served `authenticated`, so the hub has already established a
|
|
1851
|
+
* principal; what this answers is only "is this id an indexed artefact, and is
|
|
1852
|
+
* it still meant to exist". Per-user artefact scoping on the byte route would
|
|
1853
|
+
* be a second authorization model layered on an unguessable id, and this repo
|
|
1854
|
+
* has one of those already (`data-plane-access.ts`): a plane serving ONE item
|
|
1855
|
+
* the caller had to be told about is `authenticated`.
|
|
1856
|
+
*/
|
|
1857
|
+
async resolve(artifactId) {
|
|
1858
|
+
return (await this.readRow(artifactId))?.record ?? null;
|
|
1859
|
+
}
|
|
1860
|
+
/** One row by primary key, parsed. Null when absent or no longer valid. */
|
|
1861
|
+
async readRow(artifactId) {
|
|
1862
|
+
let raw;
|
|
1863
|
+
try {
|
|
1864
|
+
raw = await this.store.get.query({
|
|
1865
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1866
|
+
key: artifactId
|
|
1867
|
+
});
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
this.logger.warn("artifact index lookup failed", { meta: {
|
|
1870
|
+
artifactId,
|
|
1871
|
+
error: String(err)
|
|
1872
|
+
} });
|
|
1873
|
+
return null;
|
|
1874
|
+
}
|
|
1875
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
1876
|
+
const row = { ...raw };
|
|
1877
|
+
const parsed = NcArtifactRecordSchema.safeParse(row["record"]);
|
|
1878
|
+
if (!parsed.success) return null;
|
|
1879
|
+
const owner = row["ownerUserId"];
|
|
1880
|
+
return {
|
|
1881
|
+
record: parsed.data,
|
|
1882
|
+
ownerUserId: typeof owner === "string" ? owner : ""
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Drop rows whose bytes are gone, in ONE `deleteWhere` rather than an N+1
|
|
1887
|
+
* drain.
|
|
1888
|
+
*
|
|
1889
|
+
* TWO halves, and only one of them is safe everywhere:
|
|
1890
|
+
*
|
|
1891
|
+
* - **Expired rows** are time-based and node-independent. Always swept.
|
|
1892
|
+
* - **Orphans** — rows whose file a byte/count bound took before the age bound
|
|
1893
|
+
* did — are found by reconciling against the shelf. That reconcile is only
|
|
1894
|
+
* valid for a caller whose shelf holds the bytes for the WHOLE index, and
|
|
1895
|
+
* the index is CENTRAL while the shelf is local: a second node running this
|
|
1896
|
+
* with its own (empty) shelf would read every row as an orphan and delete
|
|
1897
|
+
* the entire library. Hence `keptIds: null` — a caller that is not the
|
|
1898
|
+
* producing node states so, and the destructive half becomes unreachable
|
|
1899
|
+
* rather than merely un-called.
|
|
1900
|
+
*
|
|
1901
|
+
* @param keptIds the ids the caller's shelf holds, or `null` when this node is
|
|
1902
|
+
* not the one that produces artefacts.
|
|
1903
|
+
*/
|
|
1904
|
+
async prune(keptIds) {
|
|
1905
|
+
let removed = 0;
|
|
1906
|
+
try {
|
|
1907
|
+
const expired = await this.store.deleteWhere.mutate({
|
|
1908
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1909
|
+
filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
|
|
1910
|
+
});
|
|
1911
|
+
removed += expired.deleted;
|
|
1912
|
+
if (keptIds === null) {
|
|
1913
|
+
if (removed > 0) this.logger.debug("artifact index pruned (expired only)", { meta: { removed } });
|
|
1914
|
+
return removed;
|
|
1915
|
+
}
|
|
1916
|
+
const alive = new Set(keptIds);
|
|
1917
|
+
const orphans = (await this.store.query.query({
|
|
1918
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1919
|
+
filter: { limit: NC_ARTIFACT_PRUNE_SCAN_LIMIT }
|
|
1920
|
+
})).map((r) => r.id).filter((id) => !alive.has(id));
|
|
1921
|
+
for (const id of orphans) {
|
|
1922
|
+
await this.store.delete.mutate({
|
|
1923
|
+
collection: NC_ARTIFACTS_COLLECTION,
|
|
1924
|
+
key: id
|
|
1925
|
+
});
|
|
1926
|
+
removed += 1;
|
|
1927
|
+
}
|
|
1928
|
+
if (removed > 0) this.logger.debug("artifact index pruned", { meta: {
|
|
1929
|
+
removed,
|
|
1930
|
+
expired: expired.deleted,
|
|
1931
|
+
orphaned: orphans.length
|
|
1932
|
+
} });
|
|
1933
|
+
return removed;
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
this.logger.warn("artifact index prune failed", { meta: { error: String(err) } });
|
|
1936
|
+
return removed;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
/** The visibility predicate: a caller sees global artefacts plus his own; an
|
|
1940
|
+
* admin sees every one. */
|
|
1941
|
+
visibility(viewer) {
|
|
1942
|
+
if (viewer.isAdmin) return {};
|
|
1943
|
+
return { whereIn: { ownerUserId: ["", viewer.userId] } };
|
|
1944
|
+
}
|
|
1945
|
+
buildFilter(query, viewer) {
|
|
1946
|
+
const whereIn = { ...this.visibility(viewer).whereIn };
|
|
1947
|
+
if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
|
|
1948
|
+
if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
|
|
1949
|
+
const whereBetween = {
|
|
1950
|
+
capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
|
|
1951
|
+
expiresAt: [this.now(), TIME_MAX]
|
|
1952
|
+
};
|
|
1953
|
+
return {
|
|
1954
|
+
...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
|
|
1955
|
+
...Object.keys(whereIn).length > 0 ? { whereIn } : {},
|
|
1956
|
+
whereBetween
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
/** Parse the JSON column, skipping rows that no longer validate. A degraded
|
|
1960
|
+
* row must never cost the whole page. */
|
|
1961
|
+
parseRows(rows) {
|
|
1962
|
+
const out = [];
|
|
1963
|
+
let skipped = 0;
|
|
1964
|
+
for (const row of rows) {
|
|
1965
|
+
const parsed = NcArtifactRecordSchema.safeParse(row.data["record"]);
|
|
1966
|
+
if (!parsed.success) {
|
|
1967
|
+
skipped += 1;
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
out.push(parsed.data);
|
|
1971
|
+
}
|
|
1972
|
+
if (skipped > 0) this.logger.warn("artifact index rows skipped as invalid", { meta: { skipped } });
|
|
1973
|
+
return out;
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
//#endregion
|
|
1582
1977
|
//#region ../types/dist/node.mjs
|
|
1583
1978
|
var node_exports = /* @__PURE__ */ require_dist.__exportAll({
|
|
1584
1979
|
FilesystemStorageProvider: () => FilesystemStorageProvider,
|
|
@@ -1969,6 +2364,61 @@ var NcArtifactPlane = class {
|
|
|
1969
2364
|
}
|
|
1970
2365
|
}
|
|
1971
2366
|
/**
|
|
2367
|
+
* Publish bytes AND file them in the viewer's browsable index (task #36).
|
|
2368
|
+
*
|
|
2369
|
+
* Two differences from {@link publish}, both deliberate:
|
|
2370
|
+
*
|
|
2371
|
+
* 1. **The bytes are stored FIRST, and stored even when no public base URL
|
|
2372
|
+
* exists.** `publish` returns early on a loopback-only install because a
|
|
2373
|
+
* signed link nobody can open is worth nothing — but the VIEWER reaches
|
|
2374
|
+
* the hub directly over its own session, so an install with no tunnel
|
|
2375
|
+
* still gets a full artifact library. Making the index depend on a
|
|
2376
|
+
* reachable public address would have made the feature absent exactly on
|
|
2377
|
+
* the installs that are LAN-only.
|
|
2378
|
+
* 2. **It answers the artefact id**, so a caller can link one artefact to
|
|
2379
|
+
* another — a timelapse's poster is its preview frame, and the video row
|
|
2380
|
+
* cannot name it without knowing the id the preview got.
|
|
2381
|
+
*
|
|
2382
|
+
* Never throws, for the same reason `publish` does not: an artefact library
|
|
2383
|
+
* is an enhancement and must never cost a notification.
|
|
2384
|
+
*/
|
|
2385
|
+
async publishIndexed(bytes, mime, meta) {
|
|
2386
|
+
let artifactId = null;
|
|
2387
|
+
try {
|
|
2388
|
+
const index = this.deps.index;
|
|
2389
|
+
const stored = await this.deps.store.put(bytes, mime, { retain: index !== void 0 });
|
|
2390
|
+
if (index !== void 0) artifactId = (await index.record({
|
|
2391
|
+
...meta,
|
|
2392
|
+
id: stored.id,
|
|
2393
|
+
mime,
|
|
2394
|
+
sizeBytes: stored.bytes
|
|
2395
|
+
}))?.id ?? null;
|
|
2396
|
+
const baseUrl = await this.resolveBaseUrl();
|
|
2397
|
+
return {
|
|
2398
|
+
url: baseUrl === null ? null : buildArtifactUrl({
|
|
2399
|
+
baseUrl,
|
|
2400
|
+
routePrefix: this.deps.routePrefix,
|
|
2401
|
+
id: stored.id,
|
|
2402
|
+
secret: this.deps.secret,
|
|
2403
|
+
expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
|
|
2404
|
+
}),
|
|
2405
|
+
artifactId
|
|
2406
|
+
};
|
|
2407
|
+
} catch (err) {
|
|
2408
|
+
this.deps.logger.warn("indexed artifact publish failed — shipping bytes only", {
|
|
2409
|
+
...meta.deviceId !== null ? { tags: { deviceId: meta.deviceId } } : {},
|
|
2410
|
+
meta: {
|
|
2411
|
+
kind: meta.kind,
|
|
2412
|
+
error: String(err)
|
|
2413
|
+
}
|
|
2414
|
+
});
|
|
2415
|
+
return {
|
|
2416
|
+
url: null,
|
|
2417
|
+
artifactId
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
1972
2422
|
* Absolutize a path against the SAME base URL an artifact link would use.
|
|
1973
2423
|
*
|
|
1974
2424
|
* For links this plane does not host but a RECIPIENT still has to open — a
|
|
@@ -2034,6 +2484,124 @@ var NcArtifactPlane = class {
|
|
|
2034
2484
|
}
|
|
2035
2485
|
};
|
|
2036
2486
|
//#endregion
|
|
2487
|
+
//#region src/notification-center/artifact-read-plane.ts
|
|
2488
|
+
/**
|
|
2489
|
+
* Parse a single-range `Range` header against a known length.
|
|
2490
|
+
*
|
|
2491
|
+
* Returns `null` when there is nothing to honour (absent, malformed, multi-range
|
|
2492
|
+
* — all of which mean "send the whole thing"), and `'unsatisfiable'` when the
|
|
2493
|
+
* client asked for a range that cannot exist, which is a 416 and not a 200.
|
|
2494
|
+
*/
|
|
2495
|
+
function parseByteRange(header, length) {
|
|
2496
|
+
if (header === void 0) return null;
|
|
2497
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
2498
|
+
if (match === null) return null;
|
|
2499
|
+
const rawStart = match[1] ?? "";
|
|
2500
|
+
const rawEnd = match[2] ?? "";
|
|
2501
|
+
if (rawStart === "" && rawEnd === "") return null;
|
|
2502
|
+
if (length === 0) return "unsatisfiable";
|
|
2503
|
+
if (rawStart === "") {
|
|
2504
|
+
const suffix = Number(rawEnd);
|
|
2505
|
+
if (suffix === 0) return "unsatisfiable";
|
|
2506
|
+
return {
|
|
2507
|
+
start: Math.max(0, length - suffix),
|
|
2508
|
+
end: length - 1
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
const start = Number(rawStart);
|
|
2512
|
+
if (start >= length) return "unsatisfiable";
|
|
2513
|
+
const end = rawEnd === "" ? length - 1 : Math.min(Number(rawEnd), length - 1);
|
|
2514
|
+
if (end < start) return "unsatisfiable";
|
|
2515
|
+
return {
|
|
2516
|
+
start,
|
|
2517
|
+
end
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
/** The last path segment, percent-decoded. */
|
|
2521
|
+
function artifactIdFrom(req) {
|
|
2522
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
2523
|
+
try {
|
|
2524
|
+
return decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
2525
|
+
} catch {
|
|
2526
|
+
return "";
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
var NcArtifactReadPlane = class {
|
|
2530
|
+
deps;
|
|
2531
|
+
constructor(deps) {
|
|
2532
|
+
this.deps = deps;
|
|
2533
|
+
}
|
|
2534
|
+
handler = async (req, res) => {
|
|
2535
|
+
const artifactId = artifactIdFrom(req);
|
|
2536
|
+
const record = await this.deps.index.resolve(artifactId);
|
|
2537
|
+
if (record === null) {
|
|
2538
|
+
this.refuse(res, 404, "not found");
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
const found = await this.deps.store.read(artifactId);
|
|
2542
|
+
if (found === null) {
|
|
2543
|
+
this.deps.logger.warn("artifact bytes gone — index row outlived the shelf", {
|
|
2544
|
+
...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
|
|
2545
|
+
meta: {
|
|
2546
|
+
artifactId,
|
|
2547
|
+
kind: record.kind,
|
|
2548
|
+
expiresAt: record.expiresAt
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
this.refuse(res, 404, "not found");
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
if (req.method === "HEAD") {
|
|
2555
|
+
res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
|
|
2556
|
+
res.end();
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
const range = parseByteRange(headerOf(req, "range"), found.bytes.byteLength);
|
|
2560
|
+
if (range === "unsatisfiable") {
|
|
2561
|
+
res.writeHead(416, {
|
|
2562
|
+
"content-range": `bytes */${found.bytes.byteLength}`,
|
|
2563
|
+
"content-type": "text/plain"
|
|
2564
|
+
});
|
|
2565
|
+
res.end("range not satisfiable");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
if (range === null) {
|
|
2569
|
+
res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
|
|
2570
|
+
res.end(found.bytes);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
const slice = found.bytes.subarray(range.start, range.end + 1);
|
|
2574
|
+
res.writeHead(206, {
|
|
2575
|
+
...this.baseHeaders(record, slice.byteLength, found.mime),
|
|
2576
|
+
"content-range": `bytes ${range.start}-${range.end}/${found.bytes.byteLength}`
|
|
2577
|
+
});
|
|
2578
|
+
res.end(slice);
|
|
2579
|
+
};
|
|
2580
|
+
baseHeaders(record, length, mime) {
|
|
2581
|
+
return {
|
|
2582
|
+
"content-type": mime,
|
|
2583
|
+
"content-length": String(length),
|
|
2584
|
+
"accept-ranges": "bytes",
|
|
2585
|
+
"cache-control": "private, max-age=3600",
|
|
2586
|
+
"content-disposition": `inline; filename="${filenameFor(record)}"`
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
refuse(res, status, body) {
|
|
2590
|
+
res.writeHead(status, { "content-type": "text/plain" });
|
|
2591
|
+
res.end(body);
|
|
2592
|
+
}
|
|
2593
|
+
};
|
|
2594
|
+
/** A name a client will accept, from what the artefact IS. */
|
|
2595
|
+
function filenameFor(record) {
|
|
2596
|
+
const ext = record.mime === "video/mp4" ? "mp4" : record.mime === "image/gif" ? "gif" : record.mime === "image/png" ? "png" : "jpg";
|
|
2597
|
+
return `${record.kind}-${record.capturedAt}.${ext}`;
|
|
2598
|
+
}
|
|
2599
|
+
function headerOf(req, name) {
|
|
2600
|
+
const value = req.headers[name];
|
|
2601
|
+
if (Array.isArray(value)) return value[0];
|
|
2602
|
+
return value;
|
|
2603
|
+
}
|
|
2604
|
+
//#endregion
|
|
2037
2605
|
//#region src/notification-center/artifact-store.ts
|
|
2038
2606
|
/**
|
|
2039
2607
|
* NcArtifactStore — the bytes behind a signed notification-artifact URL.
|
|
@@ -2048,9 +2616,25 @@ var NcArtifactPlane = class {
|
|
|
2048
2616
|
* bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
|
|
2049
2617
|
* left behind — the directory is disposable by construction, so a lost file is
|
|
2050
2618
|
* a 404 on one link, never a corrupt state.
|
|
2619
|
+
*
|
|
2620
|
+
* ## The retained shelf (`kept/`)
|
|
2621
|
+
*
|
|
2622
|
+
* Task #36 gave the viewer a browsable artifact index, and an index whose bytes
|
|
2623
|
+
* evaporate in six hours is an index of 404s. So a `put` may ask to be RETAINED:
|
|
2624
|
+
* the bytes then land on a second shelf inside the same directory with its OWN
|
|
2625
|
+
* three bounds (age, count, and — because a timelapse mp4 is megabytes — total
|
|
2626
|
+
* BYTES). It is still a scratch, just a longer one: the index row carries an
|
|
2627
|
+
* `expiresAt` derived from the same age bound, so "gone" is a state the viewer
|
|
2628
|
+
* is told about rather than a surprise 404.
|
|
2629
|
+
*
|
|
2630
|
+
* The shelf is a subdirectory ON PURPOSE. The scratch's `list()` skips
|
|
2631
|
+
* non-files, so the two shelves cannot sweep each other, and neither bound had
|
|
2632
|
+
* to learn about the other's entries.
|
|
2051
2633
|
*/
|
|
2052
2634
|
var DEFAULT_MAX_AGE_MS = 6 * 36e5;
|
|
2053
2635
|
var DEFAULT_MAX_ENTRIES = 500;
|
|
2636
|
+
/** The retained shelf's subdirectory name, inside the scratch directory. */
|
|
2637
|
+
var KEPT_SUBDIR = "kept";
|
|
2054
2638
|
/** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
|
|
2055
2639
|
function extensionFor(mime) {
|
|
2056
2640
|
if (mime === "image/jpeg") return "jpg";
|
|
@@ -2064,6 +2648,10 @@ var NcArtifactStore = class {
|
|
|
2064
2648
|
now;
|
|
2065
2649
|
maxAgeMs;
|
|
2066
2650
|
maxEntries;
|
|
2651
|
+
keptMaxAgeMs;
|
|
2652
|
+
keptMaxEntries;
|
|
2653
|
+
keptMaxBytes;
|
|
2654
|
+
keptDir;
|
|
2067
2655
|
/** id → mime, so the route can answer with the right content-type without
|
|
2068
2656
|
* re-deriving it from the extension on every request. */
|
|
2069
2657
|
mimeById = /* @__PURE__ */ new Map();
|
|
@@ -2072,17 +2660,32 @@ var NcArtifactStore = class {
|
|
|
2072
2660
|
this.now = options.now ?? (() => Date.now());
|
|
2073
2661
|
this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
2074
2662
|
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
2663
|
+
this.keptMaxAgeMs = options.keptMaxAgeMs ?? 12096e5;
|
|
2664
|
+
this.keptMaxEntries = options.keptMaxEntries ?? 2e3;
|
|
2665
|
+
this.keptMaxBytes = options.keptMaxBytes ?? 2147483648;
|
|
2666
|
+
this.keptDir = node_path.default.join(options.dir, KEPT_SUBDIR);
|
|
2667
|
+
}
|
|
2668
|
+
/** How long a retained artifact stays fetchable — the index's `expiresAt`
|
|
2669
|
+
* is derived from this, so the two can never disagree. */
|
|
2670
|
+
get retentionMs() {
|
|
2671
|
+
return this.keptMaxAgeMs;
|
|
2075
2672
|
}
|
|
2076
2673
|
/** Create the directory and sweep whatever a previous run left behind. */
|
|
2077
2674
|
async start() {
|
|
2078
2675
|
await node_fs.promises.mkdir(this.options.dir, { recursive: true });
|
|
2079
2676
|
await this.sweep();
|
|
2080
2677
|
}
|
|
2081
|
-
/**
|
|
2082
|
-
|
|
2678
|
+
/**
|
|
2679
|
+
* Persist bytes and return the artifact handle (never throws on sweep).
|
|
2680
|
+
*
|
|
2681
|
+
* The retained shelf is created LAZILY: an install that never produces a
|
|
2682
|
+
* browsable artifact keeps a directory that is empty when it is empty.
|
|
2683
|
+
*/
|
|
2684
|
+
async put(bytes, mime, options = {}) {
|
|
2083
2685
|
const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
|
|
2084
|
-
const
|
|
2085
|
-
|
|
2686
|
+
const dir = options.retain === true ? this.keptDir : this.options.dir;
|
|
2687
|
+
const file = node_path.default.join(dir, `${id}.${extensionFor(mime)}`);
|
|
2688
|
+
await node_fs.promises.mkdir(dir, { recursive: true });
|
|
2086
2689
|
await node_fs.promises.writeFile(file, bytes);
|
|
2087
2690
|
this.mimeById.set(id, mime);
|
|
2088
2691
|
await this.sweep();
|
|
@@ -2095,7 +2698,7 @@ var NcArtifactStore = class {
|
|
|
2095
2698
|
/** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
|
|
2096
2699
|
async read(id) {
|
|
2097
2700
|
if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
|
|
2098
|
-
const found = (await this.list()
|
|
2701
|
+
const found = [...await this.list(this.options.dir), ...await this.list(this.keptDir)].find((e) => e.id === id);
|
|
2099
2702
|
if (!found) return null;
|
|
2100
2703
|
try {
|
|
2101
2704
|
return {
|
|
@@ -2106,6 +2709,10 @@ var NcArtifactStore = class {
|
|
|
2106
2709
|
return null;
|
|
2107
2710
|
}
|
|
2108
2711
|
}
|
|
2712
|
+
/** The ids still on the retained shelf — what the index reconciles against. */
|
|
2713
|
+
async keptIds() {
|
|
2714
|
+
return (await this.list(this.keptDir)).map((e) => e.id);
|
|
2715
|
+
}
|
|
2109
2716
|
/** Delete every artifact (operator cleanup / shutdown). */
|
|
2110
2717
|
async clear() {
|
|
2111
2718
|
await node_fs.promises.rm(this.options.dir, {
|
|
@@ -2114,13 +2721,39 @@ var NcArtifactStore = class {
|
|
|
2114
2721
|
}).catch(() => {});
|
|
2115
2722
|
this.mimeById.clear();
|
|
2116
2723
|
}
|
|
2117
|
-
/**
|
|
2724
|
+
/** Prune both shelves under their own bounds. Best-effort: a failed unlink is
|
|
2725
|
+
* logged once and the rest of the sweep still runs. */
|
|
2118
2726
|
async sweep() {
|
|
2119
|
-
|
|
2120
|
-
|
|
2727
|
+
await this.sweepShelf(await this.list(this.options.dir), {
|
|
2728
|
+
maxAgeMs: this.maxAgeMs,
|
|
2729
|
+
maxEntries: this.maxEntries
|
|
2730
|
+
});
|
|
2731
|
+
await this.sweepShelf(await this.list(this.keptDir), {
|
|
2732
|
+
maxAgeMs: this.keptMaxAgeMs,
|
|
2733
|
+
maxEntries: this.keptMaxEntries,
|
|
2734
|
+
maxBytes: this.keptMaxBytes
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Age first, then count, then total bytes — newest always survives.
|
|
2739
|
+
*
|
|
2740
|
+
* The newest entry is never swept by the byte bound even when it alone
|
|
2741
|
+
* exceeds it: a shelf that deletes what it has just written can serve
|
|
2742
|
+
* nothing, and the caller has already paid for the render.
|
|
2743
|
+
*/
|
|
2744
|
+
async sweepShelf(entries, bounds) {
|
|
2745
|
+
const cutoff = this.now() - bounds.maxAgeMs;
|
|
2121
2746
|
const doomed = entries.filter((e) => e.mtimeMs < cutoff);
|
|
2122
|
-
const
|
|
2123
|
-
doomed.push(...
|
|
2747
|
+
const fresh = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2748
|
+
doomed.push(...fresh.slice(bounds.maxEntries));
|
|
2749
|
+
const maxBytes = bounds.maxBytes;
|
|
2750
|
+
if (maxBytes !== void 0) {
|
|
2751
|
+
let running = 0;
|
|
2752
|
+
for (const [i, e] of fresh.slice(0, bounds.maxEntries).entries()) {
|
|
2753
|
+
running += e.sizeBytes;
|
|
2754
|
+
if (running > maxBytes && i > 0) doomed.push(e);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2124
2757
|
for (const e of doomed) try {
|
|
2125
2758
|
await node_fs.promises.rm(e.file, { force: true });
|
|
2126
2759
|
this.mimeById.delete(e.id);
|
|
@@ -2131,23 +2764,24 @@ var NcArtifactStore = class {
|
|
|
2131
2764
|
} });
|
|
2132
2765
|
}
|
|
2133
2766
|
}
|
|
2134
|
-
async list() {
|
|
2767
|
+
async list(dir) {
|
|
2135
2768
|
let names;
|
|
2136
2769
|
try {
|
|
2137
|
-
names = await node_fs.promises.readdir(
|
|
2770
|
+
names = await node_fs.promises.readdir(dir);
|
|
2138
2771
|
} catch {
|
|
2139
2772
|
return [];
|
|
2140
2773
|
}
|
|
2141
2774
|
const out = [];
|
|
2142
2775
|
for (const name of names) {
|
|
2143
|
-
const file = node_path.default.join(
|
|
2776
|
+
const file = node_path.default.join(dir, name);
|
|
2144
2777
|
try {
|
|
2145
2778
|
const st = await node_fs.promises.stat(file);
|
|
2146
2779
|
if (!st.isFile()) continue;
|
|
2147
2780
|
out.push({
|
|
2148
2781
|
id: name.slice(0, name.lastIndexOf(".")),
|
|
2149
2782
|
file,
|
|
2150
|
-
mtimeMs: st.mtimeMs
|
|
2783
|
+
mtimeMs: st.mtimeMs,
|
|
2784
|
+
sizeBytes: st.size
|
|
2151
2785
|
});
|
|
2152
2786
|
} catch {}
|
|
2153
2787
|
}
|
|
@@ -2910,6 +3544,17 @@ var NcDeviceDirectory = class {
|
|
|
2910
3544
|
size() {
|
|
2911
3545
|
return this.devices.size;
|
|
2912
3546
|
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Every identity in the current snapshot.
|
|
3549
|
+
*
|
|
3550
|
+
* Exists for the ONE caller that needs the whole directory rather than one
|
|
3551
|
+
* id: the liveness ledger's boot seed, which has to know what every device
|
|
3552
|
+
* was last known to be before the first event arrives. Off the event path by
|
|
3553
|
+
* construction — it is called once, from `start()`.
|
|
3554
|
+
*/
|
|
3555
|
+
all() {
|
|
3556
|
+
return [...this.devices.values()];
|
|
3557
|
+
}
|
|
2913
3558
|
/** Has a refresh ever succeeded? A cold directory notifies about nothing. */
|
|
2914
3559
|
isWarm() {
|
|
2915
3560
|
return this.loadedOnce;
|
|
@@ -3313,6 +3958,43 @@ function displayLabel(state) {
|
|
|
3313
3958
|
return state.subLabel ?? state.label;
|
|
3314
3959
|
}
|
|
3315
3960
|
/**
|
|
3961
|
+
* The tiered columns a row should carry to AGREE with the track, or `undefined`
|
|
3962
|
+
* when the track says nothing yet.
|
|
3963
|
+
*
|
|
3964
|
+
* The projection is total: whichever tiers the track holds are copied, and a
|
|
3965
|
+
* track holding neither yields `undefined` rather than a patch of blanks — the
|
|
3966
|
+
* caller then leaves the row exactly as it was.
|
|
3967
|
+
*/
|
|
3968
|
+
function knownTrackLabelPatch(state) {
|
|
3969
|
+
if (state === void 0) return void 0;
|
|
3970
|
+
if (state.label === void 0 && state.subLabel === void 0) return void 0;
|
|
3971
|
+
return {
|
|
3972
|
+
...state.label !== void 0 ? { label: state.label } : {},
|
|
3973
|
+
...state.labelScore !== void 0 ? { labelScore: state.labelScore } : {},
|
|
3974
|
+
...state.labelMeta !== void 0 ? { labelMeta: state.labelMeta } : {},
|
|
3975
|
+
...state.subLabel !== void 0 ? { subLabel: state.subLabel } : {},
|
|
3976
|
+
...state.subLabelScore !== void 0 ? { subLabelScore: state.subLabelScore } : {},
|
|
3977
|
+
...state.subLabelMeta !== void 0 ? { subLabelMeta: state.subLabelMeta } : {}
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
/**
|
|
3981
|
+
* Does this row already say what the subject IS?
|
|
3982
|
+
*
|
|
3983
|
+
* The gate behind `DetailDispatcher.hasTrackLabel`: a periodic face-detection
|
|
3984
|
+
* refresh on a track that already has an answer runs geometry-only and drops
|
|
3985
|
+
* the ArcFace embedding. It reads {@link displayLabel} and not the `label`
|
|
3986
|
+
* column, and the difference is the whole point — an identity is a TIER-2
|
|
3987
|
+
* value and lands in `subLabel`, so the column read reported "unlabelled" for
|
|
3988
|
+
* every track ArcFace had already named and kept re-embedding it forever,
|
|
3989
|
+
* while suppressing the face chain on tracks that carried only a tier-1
|
|
3990
|
+
* sub-class (`van`). Exactly backwards, in both directions.
|
|
3991
|
+
*/
|
|
3992
|
+
function hasResolvedLabel(state) {
|
|
3993
|
+
if (state === null || state === void 0) return false;
|
|
3994
|
+
const label = displayLabel(state);
|
|
3995
|
+
return label !== void 0 && label.length > 0;
|
|
3996
|
+
}
|
|
3997
|
+
/**
|
|
3316
3998
|
* The display label of a frame-path tracked detection — the same
|
|
3317
3999
|
* `subLabel ?? label` question asked of a patch rather than a row. Used by
|
|
3318
4000
|
* every caller that wants the caption, not the tier (media captions, lifecycle
|
|
@@ -3322,6 +4004,36 @@ function patchDisplayLabel(patch) {
|
|
|
3322
4004
|
if (patch === void 0) return void 0;
|
|
3323
4005
|
return patch.subLabel ?? patch.label;
|
|
3324
4006
|
}
|
|
4007
|
+
/**
|
|
4008
|
+
* WHAT a tier-2 label is about — a recognised PERSON or a read PLATE.
|
|
4009
|
+
*
|
|
4010
|
+
* Derived from the deciding step, never stored a second time: the step that
|
|
4011
|
+
* wrote the value is already on the attribution, and a second column stating
|
|
4012
|
+
* the same thing is a knob that can disagree with the first (D62). Both the
|
|
4013
|
+
* machine step and the operator override of each family are listed, because a
|
|
4014
|
+
* hand-assigned identity is still an identity.
|
|
4015
|
+
*
|
|
4016
|
+
* Callers use it to pick a SENTENCE: "Gianluca è arrivato" and "Veicolo AB123CD"
|
|
4017
|
+
* are different keys in the notification text catalog, and a single
|
|
4018
|
+
* `{{label}}` slot cannot word both.
|
|
4019
|
+
*/
|
|
4020
|
+
var FACE_LABEL_STEP_IDS = new Set([
|
|
4021
|
+
"face-recognizer",
|
|
4022
|
+
"face-embedding",
|
|
4023
|
+
"operator:face-gallery"
|
|
4024
|
+
]);
|
|
4025
|
+
/** Steps whose tier-2 value is a PLATE / vehicle name. See {@link FACE_LABEL_STEP_IDS}. */
|
|
4026
|
+
var PLATE_LABEL_STEP_IDS = new Set(["plate-ocr", "operator:plate-gallery"]);
|
|
4027
|
+
/**
|
|
4028
|
+
* Classify a tier-2 attribution. `undefined` for an unattributed value, a
|
|
4029
|
+
* migrated one, or any step outside the two recognition families (a species, a
|
|
4030
|
+
* sub-class) — the caller then falls back to the generic labelled sentence.
|
|
4031
|
+
*/
|
|
4032
|
+
function labelKindOf(meta) {
|
|
4033
|
+
if (meta === void 0) return void 0;
|
|
4034
|
+
if (FACE_LABEL_STEP_IDS.has(meta.stepId)) return "identity";
|
|
4035
|
+
if (PLATE_LABEL_STEP_IDS.has(meta.stepId)) return "plate";
|
|
4036
|
+
}
|
|
3325
4037
|
/** Whether an attribution came from the 4g migration rather than a real step. */
|
|
3326
4038
|
function isMigrated(meta) {
|
|
3327
4039
|
return meta?.stepId === MIGRATION_STEP_ID;
|
|
@@ -3377,7 +4089,8 @@ function resolveTieredLabelWrite(state, candidate) {
|
|
|
3377
4089
|
const meta = {
|
|
3378
4090
|
stepId: candidate.stepId,
|
|
3379
4091
|
...candidate.modelId !== void 0 ? { modelId: candidate.modelId } : {},
|
|
3380
|
-
decidedAt: candidate.decidedAt
|
|
4092
|
+
decidedAt: candidate.decidedAt,
|
|
4093
|
+
...candidate.identityId !== void 0 ? { identityId: candidate.identityId } : {}
|
|
3381
4094
|
};
|
|
3382
4095
|
const incumbent = candidate.tier === 1 ? state.label : state.subLabel;
|
|
3383
4096
|
const incumbentScore = candidate.tier === 1 ? state.labelScore : state.subLabelScore;
|
|
@@ -3599,6 +4312,7 @@ function subjectFromObjectEvent(ev) {
|
|
|
3599
4312
|
timestamp: ev.timestamp,
|
|
3600
4313
|
classNames: [ev.className],
|
|
3601
4314
|
...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
|
|
4315
|
+
...recognitionPatch(ev.subLabelMeta),
|
|
3602
4316
|
...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
|
|
3603
4317
|
zones: ev.zones ?? [],
|
|
3604
4318
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
@@ -3612,6 +4326,19 @@ function subjectFromObjectEvent(ev) {
|
|
|
3612
4326
|
function bboxPatch(bbox) {
|
|
3613
4327
|
return bbox !== void 0 ? { bbox } : {};
|
|
3614
4328
|
}
|
|
4329
|
+
/**
|
|
4330
|
+
* The recognition half of a subject, read off the tier-2 attribution: WHICH
|
|
4331
|
+
* gallery row and WHAT KIND of thing. Both absent on an unattributed label, so
|
|
4332
|
+
* every condition that depends on them fails closed for a legacy record — which
|
|
4333
|
+
* is why the id/name matcher keeps its name leg.
|
|
4334
|
+
*/
|
|
4335
|
+
function recognitionPatch(meta) {
|
|
4336
|
+
const kind = labelKindOf(meta);
|
|
4337
|
+
return {
|
|
4338
|
+
...meta?.identityId !== void 0 ? { identityId: meta.identityId } : {},
|
|
4339
|
+
...kind !== void 0 ? { labelKind: kind } : {}
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
3615
4342
|
/** Convert a normalized 0..1 track envelope (min/max) into the subject's
|
|
3616
4343
|
* top-left+size `BboxRect`. `undefined` in ⇒ `undefined` out (fail closed). */
|
|
3617
4344
|
function envelopeToBbox(envelope) {
|
|
@@ -3776,6 +4503,7 @@ function subjectFromPackageEvent(ev, phase) {
|
|
|
3776
4503
|
timestamp: ev.timestamp,
|
|
3777
4504
|
classNames: [ev.className],
|
|
3778
4505
|
...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
|
|
4506
|
+
...recognitionPatch(ev.subLabelMeta),
|
|
3779
4507
|
...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
|
|
3780
4508
|
zones: ev.zones ?? [],
|
|
3781
4509
|
...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
|
|
@@ -3794,6 +4522,7 @@ function subjectFromTrack(track, info) {
|
|
|
3794
4522
|
timestamp: track.lastSeen,
|
|
3795
4523
|
classNames: track.classes && track.classes.length > 0 ? track.classes : [track.className],
|
|
3796
4524
|
...displayLabel(track) !== void 0 ? { label: displayLabel(track) } : {},
|
|
4525
|
+
...recognitionPatch(track.subLabelMeta),
|
|
3797
4526
|
...info?.bestConfidence !== void 0 ? { confidence: info.bestConfidence } : {},
|
|
3798
4527
|
zones: track.zonesVisited,
|
|
3799
4528
|
trackId: track.trackId,
|
|
@@ -3829,7 +4558,7 @@ function presentConditionIds(c) {
|
|
|
3829
4558
|
if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
|
|
3830
4559
|
if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
|
|
3831
4560
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
|
|
3832
|
-
if (c.identities !== void 0
|
|
4561
|
+
if (c.identities !== void 0) ids.push("identities");
|
|
3833
4562
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
|
|
3834
4563
|
if (c.minLabelConfidence !== void 0) ids.push("minLabelConfidence");
|
|
3835
4564
|
if (c.plates !== void 0) ids.push("plates");
|
|
@@ -3915,6 +4644,70 @@ function toLowerSet(values) {
|
|
|
3915
4644
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
3916
4645
|
}
|
|
3917
4646
|
/**
|
|
4647
|
+
* Does the subject's identity appear in `scope`?
|
|
4648
|
+
*
|
|
4649
|
+
* TWO LEGS, both load-bearing, and the reason is the migration rather than
|
|
4650
|
+
* indecision. `scope` holds gallery ids after `NcRuleStore.load` has resolved
|
|
4651
|
+
* them; a name it could not resolve is left as it stands (reported, never
|
|
4652
|
+
* dropped), and a record written before ids reached `LabelAttribution` carries
|
|
4653
|
+
* a name and no id. Matching only the id would silence the first case; matching
|
|
4654
|
+
* only the name would re-introduce the rename bug the id exists to fix.
|
|
4655
|
+
*
|
|
4656
|
+
* The id leg is exact (a uuid has no case); the name leg is the historical
|
|
4657
|
+
* trim + lowercase comparison, unchanged, so a rule that matched yesterday
|
|
4658
|
+
* matches today.
|
|
4659
|
+
*/
|
|
4660
|
+
function matchesIdentityScope(subject, scope) {
|
|
4661
|
+
if (subject.identityId !== void 0 && scope.includes(subject.identityId)) return true;
|
|
4662
|
+
const label = subject.label?.trim().toLowerCase();
|
|
4663
|
+
return label !== void 0 && toLowerSet(scope).has(label);
|
|
4664
|
+
}
|
|
4665
|
+
/**
|
|
4666
|
+
* Does this rule's match depend on RECOGNITION — a face identity or a plate
|
|
4667
|
+
* read — rather than on the detection alone?
|
|
4668
|
+
*
|
|
4669
|
+
* Derived from the conditions the rule already carries; nothing stores it. It
|
|
4670
|
+
* is what makes `waitForEnhancement` inert on a rule with nothing to wait for,
|
|
4671
|
+
* and what decides whether a track close owes an "…is Gianluca" follow-up.
|
|
4672
|
+
* `identitiesExclude` is deliberately NOT in the set: a veto is satisfied by
|
|
4673
|
+
* the ABSENCE of a recognition, so waiting for one would delay every
|
|
4674
|
+
* notification the rule exists to send.
|
|
4675
|
+
*/
|
|
4676
|
+
function ruleAwaitsRecognition(rule) {
|
|
4677
|
+
return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
|
|
4678
|
+
}
|
|
4679
|
+
/**
|
|
4680
|
+
* Does this rule stop firing on the object event and fire at TRACK CLOSE?
|
|
4681
|
+
*
|
|
4682
|
+
* `waitForEnhancement` AND something to wait for AND an `immediate` delivery.
|
|
4683
|
+
* A `track-end` rule already fires at close, and a rule with no recognition
|
|
4684
|
+
* scope would be delayed for nothing — in both cases the flag is inert, and
|
|
4685
|
+
* that is deliberate: the operator sets one switch and the rule tells them
|
|
4686
|
+
* whether it applies rather than silently changing when a notification arrives.
|
|
4687
|
+
*
|
|
4688
|
+
* The consequence is stated where the flag is declared and repeated in the
|
|
4689
|
+
* editor: a deferred rule inherits TRACK-CLOSE semantics. Its `zones` condition
|
|
4690
|
+
* tests every zone the track visited, and a `crossing` condition can no longer
|
|
4691
|
+
* be satisfied. Nothing is remembered between the two moments — the closed
|
|
4692
|
+
* track IS the evaluation, so a respawn in the middle costs nothing and the
|
|
4693
|
+
* doorbell-mirror failure (D49) has no surface here.
|
|
4694
|
+
*/
|
|
4695
|
+
function ruleDefersToTrackEnd(rule) {
|
|
4696
|
+
return rule.waitForEnhancement === true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
|
|
4697
|
+
}
|
|
4698
|
+
/**
|
|
4699
|
+
* Does a track close owe this rule an "…and it was Gianluca" SECOND
|
|
4700
|
+
* notification?
|
|
4701
|
+
*
|
|
4702
|
+
* The mirror of {@link ruleDefersToTrackEnd}: an `immediate` recognition rule
|
|
4703
|
+
* that did NOT wait fired seconds before the face was confirmed, so its
|
|
4704
|
+
* notification is true and incomplete. The upgrade is what makes "off" an
|
|
4705
|
+
* honest setting rather than a silent downgrade.
|
|
4706
|
+
*/
|
|
4707
|
+
function ruleUpgradesAtTrackEnd(rule) {
|
|
4708
|
+
return rule.waitForEnhancement !== true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
|
|
4709
|
+
}
|
|
4710
|
+
/**
|
|
3918
4711
|
* Expand each selected class id to itself PLUS its taxonomy leaf subs, so a
|
|
3919
4712
|
* MACRO selection (`vehicle`) matches its sub classNames (`car`/`truck`) — the
|
|
3920
4713
|
* `EVENT_TAXONOMY` tree is the SINGLE source (mirrors the timeline/filter
|
|
@@ -3980,7 +4773,9 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
|
3980
4773
|
if (rule.conditions.occupancy !== void 0) return fail("occupancy");
|
|
3981
4774
|
if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
|
|
3982
4775
|
} else {
|
|
3983
|
-
if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind)
|
|
4776
|
+
if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) {
|
|
4777
|
+
if (!(subject.kind === "track-end" && ruleDefersToTrackEnd(rule))) return fail("delivery");
|
|
4778
|
+
}
|
|
3984
4779
|
if (rule.conditions.occupancy !== void 0) return fail("occupancy");
|
|
3985
4780
|
if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
|
|
3986
4781
|
if (rule.conditions.audio !== void 0) return fail("audio");
|
|
@@ -4050,17 +4845,17 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
|
4050
4845
|
if (c.labelEquals !== void 0 && c.labelEquals.length > 0) {
|
|
4051
4846
|
if (label === void 0 || !toLowerSet(c.labelEquals).has(label)) return fail("labelEquals");
|
|
4052
4847
|
}
|
|
4053
|
-
if (c.identities !== void 0
|
|
4054
|
-
if (
|
|
4848
|
+
if (c.identities !== void 0) {
|
|
4849
|
+
if (!(c.identities.length > 0 ? matchesIdentityScope(subject, c.identities) : subject.labelKind === "identity")) return fail("identities");
|
|
4055
4850
|
}
|
|
4056
4851
|
if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) {
|
|
4057
|
-
if (
|
|
4852
|
+
if (matchesIdentityScope(subject, c.identitiesExclude)) return fail("identitiesExclude");
|
|
4058
4853
|
}
|
|
4059
4854
|
if (c.minLabelConfidence !== void 0) {
|
|
4060
4855
|
if (subject.labelConfidence === void 0 || subject.labelConfidence < c.minLabelConfidence) return fail("minLabelConfidence");
|
|
4061
4856
|
}
|
|
4062
4857
|
if (c.plates !== void 0) {
|
|
4063
|
-
if (subject.label
|
|
4858
|
+
if (!(c.plates.values.length > 0 ? subject.label !== void 0 && matchesPlate(subject.label, c.plates.values, c.plates.maxDistance) : subject.labelKind === "plate")) return fail("plates");
|
|
4064
4859
|
}
|
|
4065
4860
|
if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) {
|
|
4066
4861
|
if (subject.sensorKind === void 0 || !toLowerSet(c.sensorKinds).has(subject.sensorKind.trim().toLowerCase())) return fail("sensorKinds");
|
|
@@ -4125,6 +4920,27 @@ function keysPerClass(rule, subject) {
|
|
|
4125
4920
|
if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
|
|
4126
4921
|
return rule.throttle.granularity === "per-class";
|
|
4127
4922
|
}
|
|
4923
|
+
/**
|
|
4924
|
+
* The cooldown scope of ONE system event — subject AND direction.
|
|
4925
|
+
*
|
|
4926
|
+
* The `kind` is here because a cooldown is a budget for VOLUME and must never
|
|
4927
|
+
* be a budget for a DIRECTION. `Eventi di sistema` (60 s, `rule-device`)
|
|
4928
|
+
* covers `device-offline` and `device-online` together, so on 2026-08-13 a
|
|
4929
|
+
* camera that came back 13 s after going down had its recovery suppressed:
|
|
4930
|
+
* the operator kept a "camera offline" as the last word while the camera was
|
|
4931
|
+
* up, and — because the liveness ledger HAD advanced on the suppressed flip —
|
|
4932
|
+
* the next outage was a genuine flip again and arrived as what read like a
|
|
4933
|
+
* duplicate. Same split D62 made one level up between `device-switch:<id>` and
|
|
4934
|
+
* `device:<id>`, for the same reason: two facts must not share a slot and
|
|
4935
|
+
* cancel each other out.
|
|
4936
|
+
*
|
|
4937
|
+
* ONE authority: `cooldownKey` and the boot-time cooldown reseed
|
|
4938
|
+
* (`NotificationCenter.seedCooldowns`) both call this. They used to build the
|
|
4939
|
+
* string separately, which is how a key format drifts across a restart.
|
|
4940
|
+
*/
|
|
4941
|
+
function systemEventCooldownScope(systemEvent) {
|
|
4942
|
+
return `s:${systemEvent.subject}:k:${systemEvent.kind}`;
|
|
4943
|
+
}
|
|
4128
4944
|
/** Stable cooldown key per the rule's throttle scope + class granularity. */
|
|
4129
4945
|
/**
|
|
4130
4946
|
* Does the rule's device scope cover this subject?
|
|
@@ -4151,8 +4967,9 @@ function matchesDeviceScope(scope, subject) {
|
|
|
4151
4967
|
function cooldownKey(rule, subject) {
|
|
4152
4968
|
const first = subject.classNames[0];
|
|
4153
4969
|
const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
|
|
4154
|
-
const scopeRef = subject.systemEvent !== void 0 ?
|
|
4155
|
-
|
|
4970
|
+
const scopeRef = subject.systemEvent !== void 0 ? systemEventCooldownScope(subject.systemEvent) : `d:${subject.deviceId}`;
|
|
4971
|
+
const kindKey = subject.systemEvent !== void 0 ? `:k:${subject.systemEvent.kind}` : "";
|
|
4972
|
+
return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
|
|
4156
4973
|
}
|
|
4157
4974
|
/** True when the rule fired within its cooldown window before `now`. */
|
|
4158
4975
|
function isCoolingDown(rule, lastFiredAt, now) {
|
|
@@ -4265,8 +5082,11 @@ var en_default = {
|
|
|
4265
5082
|
"detection.title": "{{rule}}",
|
|
4266
5083
|
"detection.body": "{{subject}} on {{camera}}{{inZones}}",
|
|
4267
5084
|
"detection.body.trackEnd": "{{subject}} on {{camera}}{{inZones}} — visit ended",
|
|
5085
|
+
"detection.body.enhanced": "{{subject}} on {{camera}}{{inZones}} — now recognised",
|
|
4268
5086
|
"detection.subject": "{{classNoun}}",
|
|
4269
5087
|
"detection.subject.labelled": "{{classNoun}} ({{label}})",
|
|
5088
|
+
"detection.subject.identity": "{{label}}",
|
|
5089
|
+
"detection.subject.plate": "{{classNoun}} {{label}}",
|
|
4270
5090
|
"detection.inZones": " in {{zones}}",
|
|
4271
5091
|
"class.person": {
|
|
4272
5092
|
"one": "person",
|
|
@@ -4346,6 +5166,7 @@ var en_default = {
|
|
|
4346
5166
|
"system.camera-disabled.body": "Device {{deviceName}} was switched off",
|
|
4347
5167
|
"system.camera-enabled.title": "Device switched on",
|
|
4348
5168
|
"system.camera-enabled.body": "Device {{deviceName}} was switched back on",
|
|
5169
|
+
"test.prefix": "[TEST] {{body}}",
|
|
4349
5170
|
"timelapse.title": "Timelapse — {{camera}}",
|
|
4350
5171
|
"timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
|
|
4351
5172
|
"timelapse.body.tooManyEvents": "too many events for dense sampling: video at a uniform cadence",
|
|
@@ -4428,8 +5249,11 @@ var it_default = {
|
|
|
4428
5249
|
"detection.title": "{{rule}}",
|
|
4429
5250
|
"detection.body": "{{subject}} su {{camera}}{{inZones}}",
|
|
4430
5251
|
"detection.body.trackEnd": "{{subject}} su {{camera}}{{inZones}} — visita terminata",
|
|
5252
|
+
"detection.body.enhanced": "{{subject}} su {{camera}}{{inZones}} — ora riconosciuto",
|
|
4431
5253
|
"detection.subject": "{{classNoun}}",
|
|
4432
5254
|
"detection.subject.labelled": "{{classNoun}} ({{label}})",
|
|
5255
|
+
"detection.subject.identity": "{{label}}",
|
|
5256
|
+
"detection.subject.plate": "{{classNoun}} {{label}}",
|
|
4433
5257
|
"detection.inZones": " in {{zones}}",
|
|
4434
5258
|
"class.person": {
|
|
4435
5259
|
"one": "persona",
|
|
@@ -4509,6 +5333,7 @@ var it_default = {
|
|
|
4509
5333
|
"system.camera-disabled.body": "Il dispositivo {{deviceName}} è stato spento",
|
|
4510
5334
|
"system.camera-enabled.title": "Dispositivo riacceso",
|
|
4511
5335
|
"system.camera-enabled.body": "Il dispositivo {{deviceName}} è stato riacceso",
|
|
5336
|
+
"test.prefix": "[TEST] {{body}}",
|
|
4512
5337
|
"timelapse.title": "Timelapse — {{camera}}",
|
|
4513
5338
|
"timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
|
|
4514
5339
|
"timelapse.body.tooManyEvents": "troppi eventi per il campionamento denso: video a cadenza uniforme",
|
|
@@ -4783,7 +5608,7 @@ function ncSubjectText(texts, input) {
|
|
|
4783
5608
|
vars: { classNoun }
|
|
4784
5609
|
});
|
|
4785
5610
|
return texts.text({
|
|
4786
|
-
key:
|
|
5611
|
+
key: subjectLabelKey(input.labelKind),
|
|
4787
5612
|
vars: {
|
|
4788
5613
|
classNoun,
|
|
4789
5614
|
label: input.label
|
|
@@ -4791,6 +5616,16 @@ function ncSubjectText(texts, input) {
|
|
|
4791
5616
|
});
|
|
4792
5617
|
}
|
|
4793
5618
|
/**
|
|
5619
|
+
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
5620
|
+
* name falls back to the generic key rather than printing nothing — the same
|
|
5621
|
+
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
5622
|
+
*/
|
|
5623
|
+
function subjectLabelKey(kind) {
|
|
5624
|
+
if (kind === "identity") return "detection.subject.identity";
|
|
5625
|
+
if (kind === "plate") return "detection.subject.plate";
|
|
5626
|
+
return "detection.subject.labelled";
|
|
5627
|
+
}
|
|
5628
|
+
/**
|
|
4794
5629
|
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
4795
5630
|
* zones.
|
|
4796
5631
|
*
|
|
@@ -5289,13 +6124,18 @@ var NcDispatcher = class {
|
|
|
5289
6124
|
...systemCount,
|
|
5290
6125
|
frozen: systemEvent?.title ?? entry.payload.ruleName
|
|
5291
6126
|
});
|
|
5292
|
-
const
|
|
6127
|
+
const rendered = this.renderText({
|
|
5293
6128
|
template: entry.payload.template?.body,
|
|
5294
6129
|
key: bodyKeyFor(entry),
|
|
5295
6130
|
vars,
|
|
5296
6131
|
...systemCount,
|
|
5297
6132
|
...systemEvent?.body !== void 0 ? { frozen: systemEvent.body } : {}
|
|
5298
6133
|
});
|
|
6134
|
+
const body = entry.payload.synthetic === true ? this.renderText({
|
|
6135
|
+
key: "test.prefix",
|
|
6136
|
+
vars: { body: rendered },
|
|
6137
|
+
frozen: rendered
|
|
6138
|
+
}) : rendered;
|
|
5299
6139
|
const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
|
|
5300
6140
|
const params = pickParams(entry.payload.params);
|
|
5301
6141
|
const actions = await this.mintActions(entry, target);
|
|
@@ -5652,7 +6492,8 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
|
|
|
5652
6492
|
...detection.label !== void 0 ? { label: detection.label } : {}
|
|
5653
6493
|
}) : systemEvent?.subject ?? ncSubjectText(texts, {
|
|
5654
6494
|
className: subject.className,
|
|
5655
|
-
...subject.label !== void 0 ? { label: subject.label } : {}
|
|
6495
|
+
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
6496
|
+
...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {}
|
|
5656
6497
|
}),
|
|
5657
6498
|
inZones: ncInZonesText(texts, zoneLabels),
|
|
5658
6499
|
scope: occupancy?.zone ?? deviceName,
|
|
@@ -5684,6 +6525,7 @@ function bodyKeyFor(entry) {
|
|
|
5684
6525
|
const subject = entry.payload.subject;
|
|
5685
6526
|
if (subject.systemEvent !== void 0) return subject.systemEvent.bodyKey ?? "";
|
|
5686
6527
|
if (subject.occupancy !== void 0) return "occupancy.body";
|
|
6528
|
+
if (entry.payload.enhancement === true) return "detection.body.enhanced";
|
|
5687
6529
|
return entry.recordKind === "track-end" ? "detection.body.trackEnd" : "detection.body";
|
|
5688
6530
|
}
|
|
5689
6531
|
/** The default TITLE key — same shape, same pre-catalog reasoning. */
|
|
@@ -6673,6 +7515,73 @@ var NcLivenessLedger = class {
|
|
|
6673
7515
|
async load() {
|
|
6674
7516
|
return (await this.ledger.load()).length;
|
|
6675
7517
|
}
|
|
7518
|
+
/**
|
|
7519
|
+
* Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
|
|
7520
|
+
* the fix for the outage this ledger used to swallow.
|
|
7521
|
+
*
|
|
7522
|
+
* ## The hole
|
|
7523
|
+
*
|
|
7524
|
+
* {@link load} recovers what the ledger itself accepted. A device that has
|
|
7525
|
+
* never produced a liveness event since the ledger was created has no row at
|
|
7526
|
+
* all, so its FIRST observation ever seeds silently — and if that first
|
|
7527
|
+
* observation is the camera DYING, the outage is thrown away. Live cost,
|
|
7528
|
+
* 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
|
|
7529
|
+
* notifications. `device:584` and `device:587` had never spoken; their
|
|
7530
|
+
* unplugging was recorded and never announced. A camera that is quiet until
|
|
7531
|
+
* it dies is a camera whose death is silent, indefinitely.
|
|
7532
|
+
*
|
|
7533
|
+
* ## Why only the ONLINE ones
|
|
7534
|
+
*
|
|
7535
|
+
* The directory is the device manager's own `online` flag (D132 §3: the
|
|
7536
|
+
* authority seeds the mirror at boot and stays the authority). Seeding only
|
|
7537
|
+
* the devices it calls ONLINE is what makes this change one-directional:
|
|
7538
|
+
*
|
|
7539
|
+
* - a row seeded `online` can only ever turn a later `device.offline` into a
|
|
7540
|
+
* notification — a true sentence about a device that just went away;
|
|
7541
|
+
* - it can NEVER turn a `device.online` into one, and that is the only shape
|
|
7542
|
+
* the 2026-08-13 notification flood (D130) ever had.
|
|
7543
|
+
*
|
|
7544
|
+
* A device the directory calls offline — which is also what a cold or
|
|
7545
|
+
* still-settling directory would say — gets no row and keeps today's
|
|
7546
|
+
* behaviour exactly. Failing toward discard, unchanged.
|
|
7547
|
+
*
|
|
7548
|
+
* ## D49
|
|
7549
|
+
*
|
|
7550
|
+
* An EXISTING row is never overwritten. The ledger is the authority for what
|
|
7551
|
+
* it accepted; the directory is a mirror refreshed on a tick and may be
|
|
7552
|
+
* stale. A stale `online` overwriting an accepted `offline` would make the
|
|
7553
|
+
* camera's real recovery a no-flip and lose it. A failed directory read
|
|
7554
|
+
* simply yields an empty list here and seeds nothing.
|
|
7555
|
+
*
|
|
7556
|
+
* ## Staged, not persisted
|
|
7557
|
+
*
|
|
7558
|
+
* The seed is DERIVED from an authority that is read again on every boot, so
|
|
7559
|
+
* losing it costs nothing — the next boot re-derives it. `stage` therefore
|
|
7560
|
+
* advances the mirror without a write, which matters on this hub: the
|
|
7561
|
+
* pipeline-analytics runner respawns several times an hour, and persisting
|
|
7562
|
+
* ~500 re-derivable rows on each respawn would offer thousands of pointless
|
|
7563
|
+
* commits a day to the checkpoint lottery (D96). The first real
|
|
7564
|
+
* {@link observe} flip persists through the ordinary write-behind path.
|
|
7565
|
+
*
|
|
7566
|
+
* Returns how many rows were seeded, so the caller can say it out loud.
|
|
7567
|
+
*/
|
|
7568
|
+
seedFromDirectory(identities, now) {
|
|
7569
|
+
let seeded = 0;
|
|
7570
|
+
for (const identity of identities) {
|
|
7571
|
+
if (identity.online !== true) continue;
|
|
7572
|
+
const subject = `device:${identity.deviceId}`;
|
|
7573
|
+
if (this.ledger.has(subject)) continue;
|
|
7574
|
+
this.ledger.stage({
|
|
7575
|
+
subject,
|
|
7576
|
+
state: "online",
|
|
7577
|
+
kind: "device-online",
|
|
7578
|
+
deviceId: identity.deviceId,
|
|
7579
|
+
updatedAt: now
|
|
7580
|
+
});
|
|
7581
|
+
seeded += 1;
|
|
7582
|
+
}
|
|
7583
|
+
return seeded;
|
|
7584
|
+
}
|
|
6676
7585
|
/** The in-RAM mirror, subject-ordered so two readers agree. */
|
|
6677
7586
|
snapshot() {
|
|
6678
7587
|
return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
|
|
@@ -7165,7 +8074,7 @@ function probeOccupancyNow(input) {
|
|
|
7165
8074
|
* field for them would be a cross-package change for a sentence.
|
|
7166
8075
|
*/
|
|
7167
8076
|
function occupancyProbeLabel(occupancy, probe, cooldownSec) {
|
|
7168
|
-
const classNote =
|
|
8077
|
+
const classNote = occupancyClassWarning(occupancy.className);
|
|
7169
8078
|
if (probe.kind === "unavailable") {
|
|
7170
8079
|
const reason = probe.reason === "no-snapshot" ? "no ZoneAnalytics snapshot for this camera — the condition fails closed until one arrives" : "that zone is not in the camera’s current snapshot — the condition fails closed (deleted zone?)";
|
|
7171
8080
|
return classNote === null ? reason : `${classNote} · ${reason}`;
|
|
@@ -7190,7 +8099,7 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
|
|
|
7190
8099
|
* pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
|
|
7191
8100
|
* counter is 0 structurally and no scene can change it.
|
|
7192
8101
|
*/
|
|
7193
|
-
function
|
|
8102
|
+
function occupancyClassWarning(className) {
|
|
7194
8103
|
if (className === void 0 || require_dist.isDetectionMacroClass(className)) return null;
|
|
7195
8104
|
return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...require_dist.DETECTION_MACRO_CLASSES].join(" / ")})`;
|
|
7196
8105
|
}
|
|
@@ -7444,7 +8353,7 @@ var DEFAULT_DRAIN_BATCH = 20;
|
|
|
7444
8353
|
var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
|
|
7445
8354
|
/** Stable dedup id — the durable per-track/per-record fire guarantee. */
|
|
7446
8355
|
function outboxEntryId(input) {
|
|
7447
|
-
const dedupRef = input.trackId ?? input.recordId;
|
|
8356
|
+
const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
|
|
7448
8357
|
return `${input.ruleId}:${dedupRef}:${input.targetId}`;
|
|
7449
8358
|
}
|
|
7450
8359
|
var NcOutbox = class {
|
|
@@ -7996,7 +8905,130 @@ var NcRuleActionRunner = class {
|
|
|
7996
8905
|
}
|
|
7997
8906
|
};
|
|
7998
8907
|
//#endregion
|
|
8908
|
+
//#region src/notification-center/rule-status.ts
|
|
8909
|
+
/**
|
|
8910
|
+
* The live occupancy of the scope ONE rule counts.
|
|
8911
|
+
*
|
|
8912
|
+
* `count`/`occupied` come from the SAME probe the dry-run uses
|
|
8913
|
+
* (`probeOccupancyNow`), so the status and the dry-run can never disagree about
|
|
8914
|
+
* a camera. `since` comes from the watcher's CONFIRMED row, which is a
|
|
8915
|
+
* different fact and the one the operator actually asks for: the probe reads a
|
|
8916
|
+
* level, the watcher records when that level was last confirmed to change.
|
|
8917
|
+
*/
|
|
8918
|
+
var NcOccupancyRuleStatusSchema = require_dist.object({
|
|
8919
|
+
kind: require_dist.literal("occupancy"),
|
|
8920
|
+
deviceId: require_dist.number().int(),
|
|
8921
|
+
/** Zone NAME when the snapshot knows one, else the zone id, else absent for
|
|
8922
|
+
* whole-frame — the words a card puts after "in". */
|
|
8923
|
+
scope: require_dist.string().optional(),
|
|
8924
|
+
/** The class counted; absent = every class. */
|
|
8925
|
+
className: require_dist.string().optional(),
|
|
8926
|
+
/** The capacity the predicate compares against (`<=C` keys on C+1). */
|
|
8927
|
+
threshold: require_dist.number().int(),
|
|
8928
|
+
sustainSeconds: require_dist.number().int(),
|
|
8929
|
+
/** The count read for the scope. Absent when the probe was unavailable. */
|
|
8930
|
+
count: require_dist.number().int().optional(),
|
|
8931
|
+
/** `count >= threshold` at the probe. Absent when unavailable. */
|
|
8932
|
+
occupied: require_dist.boolean().optional(),
|
|
8933
|
+
/** Frame time of the snapshot the count came from — how FRESH it is, which
|
|
8934
|
+
* is a different question from "now". */
|
|
8935
|
+
observedAt: require_dist.number().int().optional(),
|
|
8936
|
+
/** When the watcher last CONFIRMED a change of this key's level. Absent when
|
|
8937
|
+
* the watcher holds no confirmed row (a cold boot, or a key nothing
|
|
8938
|
+
* watches). */
|
|
8939
|
+
since: require_dist.number().int().optional(),
|
|
8940
|
+
/** Why there is no count: the two fail-closed shapes of the live path. */
|
|
8941
|
+
unavailable: require_dist._enum(["no-snapshot", "zone-absent"]).optional(),
|
|
8942
|
+
/** A scope the counter can never answer (a sub-type class) — stated, because
|
|
8943
|
+
* its reading of 0 is pixel-identical to an empty zone. */
|
|
8944
|
+
warning: require_dist.string().optional()
|
|
8945
|
+
});
|
|
8946
|
+
/** One camera of a timelapse rule — each renders its own video. */
|
|
8947
|
+
var NcTimelapseDeviceStatusSchema = require_dist.object({
|
|
8948
|
+
deviceId: require_dist.number().int(),
|
|
8949
|
+
/** Last successful generation for THIS camera (the per-device map, with the
|
|
8950
|
+
* rule-wide fallback). Absent = never generated. */
|
|
8951
|
+
lastGeneratedAt: require_dist.number().int().optional(),
|
|
8952
|
+
/** The artefact that generation produced, when it is still on the shelf. */
|
|
8953
|
+
lastArtifact: NcArtifactRecordSchema.optional(),
|
|
8954
|
+
/** A render this process is waiting on right now. */
|
|
8955
|
+
rendering: require_dist.object({
|
|
8956
|
+
startedAt: require_dist.number().int(),
|
|
8957
|
+
windowStartMs: require_dist.number().int(),
|
|
8958
|
+
windowEndMs: require_dist.number().int()
|
|
8959
|
+
}).optional()
|
|
8960
|
+
});
|
|
8961
|
+
var NcTimelapseRuleStatusSchema = require_dist.object({
|
|
8962
|
+
kind: require_dist.literal("timelapse"),
|
|
8963
|
+
devices: require_dist.array(NcTimelapseDeviceStatusSchema),
|
|
8964
|
+
/** The most recent window that has already closed — what the next tick would
|
|
8965
|
+
* render for any camera that has not got it yet. Absent = none in the
|
|
8966
|
+
* lookback (a schedule that never closes, or a rule younger than its window). */
|
|
8967
|
+
lastWindow: require_dist.object({
|
|
8968
|
+
startMs: require_dist.number(),
|
|
8969
|
+
endMs: require_dist.number()
|
|
8970
|
+
}).optional(),
|
|
8971
|
+
/** The next OPENING. `endMs` null = the close is beyond the scan horizon. */
|
|
8972
|
+
nextWindow: require_dist.object({
|
|
8973
|
+
startMs: require_dist.number(),
|
|
8974
|
+
endMs: require_dist.number().nullable()
|
|
8975
|
+
}).optional(),
|
|
8976
|
+
/** How long after a close the producer waits for the footage to settle. */
|
|
8977
|
+
settleMs: require_dist.number().int()
|
|
8978
|
+
});
|
|
8979
|
+
var NcRuleStatusSchema = require_dist.discriminatedUnion("kind", [
|
|
8980
|
+
require_dist.object({
|
|
8981
|
+
kind: require_dist.literal("none"),
|
|
8982
|
+
reason: require_dist.string()
|
|
8983
|
+
}),
|
|
8984
|
+
NcOccupancyRuleStatusSchema,
|
|
8985
|
+
NcTimelapseRuleStatusSchema
|
|
8986
|
+
]);
|
|
8987
|
+
/**
|
|
8988
|
+
* Assemble the occupancy status from the probe and the watcher's confirmed row.
|
|
8989
|
+
*
|
|
8990
|
+
* Pure. The two inputs are deliberately separate rather than one blob: they
|
|
8991
|
+
* answer different questions from different substrates, and merging them at the
|
|
8992
|
+
* source would let a missing snapshot erase a `since` the watcher still knows.
|
|
8993
|
+
*/
|
|
8994
|
+
function buildOccupancyStatus(input) {
|
|
8995
|
+
const { condition, probe, threshold } = input;
|
|
8996
|
+
const key = occupancyKey(input.deviceId, condition.zoneId, condition.className, threshold);
|
|
8997
|
+
const row = input.confirmed.find((r) => r.key === key);
|
|
8998
|
+
const scope = probe.kind === "probed" ? probe.zoneName ?? condition.zoneId : condition.zoneId;
|
|
8999
|
+
return {
|
|
9000
|
+
kind: "occupancy",
|
|
9001
|
+
deviceId: input.deviceId,
|
|
9002
|
+
...scope !== void 0 ? { scope } : {},
|
|
9003
|
+
...condition.className !== void 0 ? { className: condition.className } : {},
|
|
9004
|
+
threshold,
|
|
9005
|
+
sustainSeconds: condition.sustainSeconds,
|
|
9006
|
+
...probe.kind === "probed" ? {
|
|
9007
|
+
count: probe.count,
|
|
9008
|
+
occupied: probe.subject.occupancy?.occupied === true,
|
|
9009
|
+
observedAt: probe.observedAt
|
|
9010
|
+
} : { unavailable: probe.reason },
|
|
9011
|
+
...row !== void 0 ? { since: row.lastChangeAt } : {},
|
|
9012
|
+
...input.warning !== void 0 ? { warning: input.warning } : {}
|
|
9013
|
+
};
|
|
9014
|
+
}
|
|
9015
|
+
//#endregion
|
|
7999
9016
|
//#region src/notification-center/rule-store.ts
|
|
9017
|
+
/**
|
|
9018
|
+
* NcRuleStore — durable Notification Center rule set.
|
|
9019
|
+
*
|
|
9020
|
+
* Follows the `stationary-registry.ts` precedent: a declared SQLite
|
|
9021
|
+
* collection (via the central `SettingsStoreClient`) mirrored into an
|
|
9022
|
+
* in-memory cache. The cache serves the hot read path (per-persist rule
|
|
9023
|
+
* matching) with zero I/O; every mutation writes through to the store
|
|
9024
|
+
* FIRST and only then updates the cache (a failed persist never leaves a
|
|
9025
|
+
* phantom in-RAM rule).
|
|
9026
|
+
*
|
|
9027
|
+
* Cross-node note: the collection lives in the hub's centralized
|
|
9028
|
+
* settings-store, so a CRUD served on one node is visible to the
|
|
9029
|
+
* evaluating (post-processing) node after its periodic `load()` refresh —
|
|
9030
|
+
* bounded staleness, never wrongness (D8 reconcile-over-events).
|
|
9031
|
+
*/
|
|
8000
9032
|
var NC_RULES_COLLECTION = "notification-center:rules";
|
|
8001
9033
|
var NC_RULES_COLUMNS = [
|
|
8002
9034
|
{
|
|
@@ -8038,17 +9070,75 @@ var NC_RULES_INDEXES = [{
|
|
|
8038
9070
|
name: "idx_nc_rules_enabled",
|
|
8039
9071
|
columns: ["enabled"]
|
|
8040
9072
|
}];
|
|
9073
|
+
/**
|
|
9074
|
+
* Resolve display names to gallery ids inside one rule's identity conditions.
|
|
9075
|
+
*
|
|
9076
|
+
* NON-DESTRUCTIVE and IDEMPOTENT, in that order of importance:
|
|
9077
|
+
*
|
|
9078
|
+
* - a value the map answers for becomes the id;
|
|
9079
|
+
* - a value it does not answer for is LEFT AS IT STANDS, not dropped. It is
|
|
9080
|
+
* either already an id (the second load of a migrated rule, and every load
|
|
9081
|
+
* after that), or a name the gallery no longer holds — and a rule whose
|
|
9082
|
+
* scope silently shrank is exactly the failure this repo keeps paying for.
|
|
9083
|
+
* The engine's name leg keeps such a value matching what it always matched;
|
|
9084
|
+
* the caller reports it once.
|
|
9085
|
+
* - nothing is written back. The migration is a READ projection, so a gallery
|
|
9086
|
+
* that failed to load, or loaded empty, cannot rewrite a rule into one that
|
|
9087
|
+
* matches nobody. The stored rule keeps the operator's words forever.
|
|
9088
|
+
*
|
|
9089
|
+
* Returns the rule unchanged (by identity) when nothing resolved, so `load`
|
|
9090
|
+
* can tell a migrated rule from an untouched one without comparing.
|
|
9091
|
+
*/
|
|
9092
|
+
function migrateIdentityNames(rule, idsByName) {
|
|
9093
|
+
const identities = rule.conditions.identities;
|
|
9094
|
+
const excluded = rule.conditions.identitiesExclude;
|
|
9095
|
+
if (identities === void 0 && excluded === void 0) return {
|
|
9096
|
+
rule,
|
|
9097
|
+
unresolved: []
|
|
9098
|
+
};
|
|
9099
|
+
const unresolved = [];
|
|
9100
|
+
const known = new Set(idsByName.values());
|
|
9101
|
+
const resolve = (values) => values.map((value) => {
|
|
9102
|
+
if (known.has(value)) return value;
|
|
9103
|
+
const id = idsByName.get(value.trim().toLowerCase());
|
|
9104
|
+
if (id !== void 0) return id;
|
|
9105
|
+
unresolved.push(value);
|
|
9106
|
+
return value;
|
|
9107
|
+
});
|
|
9108
|
+
const nextIdentities = identities !== void 0 ? resolve(identities) : void 0;
|
|
9109
|
+
const nextExcluded = excluded !== void 0 ? resolve(excluded) : void 0;
|
|
9110
|
+
if (!(nextIdentities !== void 0 && !sameList(identities ?? [], nextIdentities) || nextExcluded !== void 0 && !sameList(excluded ?? [], nextExcluded))) return {
|
|
9111
|
+
rule,
|
|
9112
|
+
unresolved
|
|
9113
|
+
};
|
|
9114
|
+
return {
|
|
9115
|
+
rule: {
|
|
9116
|
+
...rule,
|
|
9117
|
+
conditions: {
|
|
9118
|
+
...rule.conditions,
|
|
9119
|
+
...nextIdentities !== void 0 ? { identities: nextIdentities } : {},
|
|
9120
|
+
...nextExcluded !== void 0 ? { identitiesExclude: nextExcluded } : {}
|
|
9121
|
+
}
|
|
9122
|
+
},
|
|
9123
|
+
unresolved
|
|
9124
|
+
};
|
|
9125
|
+
}
|
|
9126
|
+
function sameList(a, b) {
|
|
9127
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
9128
|
+
}
|
|
8041
9129
|
var NcRuleStore = class {
|
|
8042
9130
|
byId = /* @__PURE__ */ new Map();
|
|
8043
9131
|
store;
|
|
8044
9132
|
logger;
|
|
8045
9133
|
now;
|
|
8046
9134
|
newId;
|
|
9135
|
+
identityIdsByName;
|
|
8047
9136
|
constructor(deps) {
|
|
8048
9137
|
this.store = deps.store;
|
|
8049
9138
|
this.logger = deps.logger;
|
|
8050
9139
|
this.now = deps.now ?? (() => Date.now());
|
|
8051
9140
|
this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
|
|
9141
|
+
if (deps.identityIdsByName !== void 0) this.identityIdsByName = deps.identityIdsByName;
|
|
8052
9142
|
}
|
|
8053
9143
|
static async declare(store) {
|
|
8054
9144
|
await store.declareCollection.mutate({
|
|
@@ -8069,24 +9159,48 @@ var NcRuleStore = class {
|
|
|
8069
9159
|
collection: NC_RULES_COLLECTION,
|
|
8070
9160
|
filter: { limit: 1e4 }
|
|
8071
9161
|
});
|
|
9162
|
+
const idsByName = await this.readIdentityIds();
|
|
8072
9163
|
this.byId.clear();
|
|
8073
9164
|
let skipped = 0;
|
|
9165
|
+
let migrated = 0;
|
|
9166
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
8074
9167
|
for (const row of rows) {
|
|
8075
9168
|
const parsed = require_dist.NcRuleSchema.safeParse(row.data["rule"]);
|
|
8076
9169
|
if (!parsed.success) {
|
|
8077
9170
|
skipped += 1;
|
|
8078
9171
|
continue;
|
|
8079
9172
|
}
|
|
8080
|
-
|
|
9173
|
+
const result = migrateIdentityNames(parsed.data, idsByName);
|
|
9174
|
+
if (result.rule !== parsed.data) migrated += 1;
|
|
9175
|
+
for (const name of result.unresolved) unresolved.add(name);
|
|
9176
|
+
this.byId.set(result.rule.id, result.rule);
|
|
8081
9177
|
}
|
|
8082
9178
|
this.logger.debug("notification rules loaded", { meta: {
|
|
8083
9179
|
rules: this.byId.size,
|
|
8084
|
-
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
9180
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {},
|
|
9181
|
+
...migrated > 0 ? { identitiesResolvedToIds: migrated } : {}
|
|
8085
9182
|
} });
|
|
9183
|
+
if (unresolved.size > 0) this.logger.info("notification rules name identities the gallery does not know", { meta: { names: [...unresolved].join(", ") } });
|
|
8086
9184
|
} catch (err) {
|
|
8087
9185
|
this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
|
|
8088
9186
|
}
|
|
8089
9187
|
}
|
|
9188
|
+
/**
|
|
9189
|
+
* The name→id map, lower-cased, or EMPTY when there is none to be had.
|
|
9190
|
+
*
|
|
9191
|
+
* Empty is the safe answer and the only one: `migrateIdentityNames` resolves
|
|
9192
|
+
* nothing from an empty map and leaves every rule as the operator wrote it.
|
|
9193
|
+
* A gallery read that throws must never be able to change what a rule matches.
|
|
9194
|
+
*/
|
|
9195
|
+
async readIdentityIds() {
|
|
9196
|
+
if (this.identityIdsByName === void 0) return /* @__PURE__ */ new Map();
|
|
9197
|
+
try {
|
|
9198
|
+
return await this.identityIdsByName();
|
|
9199
|
+
} catch (err) {
|
|
9200
|
+
this.logger.debug("identity gallery read failed — rules left unmigrated", { meta: { error: String(err) } });
|
|
9201
|
+
return /* @__PURE__ */ new Map();
|
|
9202
|
+
}
|
|
9203
|
+
}
|
|
8090
9204
|
list() {
|
|
8091
9205
|
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
8092
9206
|
}
|
|
@@ -8990,6 +10104,47 @@ function findLastClosedWindow(input) {
|
|
|
8990
10104
|
};
|
|
8991
10105
|
return null;
|
|
8992
10106
|
}
|
|
10107
|
+
/**
|
|
10108
|
+
* The next window that will OPEN after `nowMs`, and when it closes.
|
|
10109
|
+
*
|
|
10110
|
+
* The forward twin of {@link findLastClosedWindow}, and the answer to the only
|
|
10111
|
+
* question an operator has about a timelapse rule that produced nothing yet:
|
|
10112
|
+
* "when is it going to run?". Standing INSIDE a window reports the NEXT one,
|
|
10113
|
+
* not the current one — the current one's boundaries are already answerable
|
|
10114
|
+
* from the last close, and reporting "next: now" would read as a render that is
|
|
10115
|
+
* owed.
|
|
10116
|
+
*
|
|
10117
|
+
* Derived by scanning `isScheduleActive` for the same reason the backwards scan
|
|
10118
|
+
* is: that function is the authority on what a window means (midnight crossing,
|
|
10119
|
+
* `invert`, IANA timezone), and a second implementation of it is drift this
|
|
10120
|
+
* repo has already paid for.
|
|
10121
|
+
*
|
|
10122
|
+
* @returns `null` for an always-active schedule (it never opens — it never
|
|
10123
|
+
* closed either), and for one with no opening inside the lookahead. A window
|
|
10124
|
+
* whose CLOSE falls beyond the horizon is reported with `endMs: null`: the
|
|
10125
|
+
* opening is the fact the operator asked for, and clamping the close to the
|
|
10126
|
+
* scan floor would report a window that ends when the scan gave up.
|
|
10127
|
+
*/
|
|
10128
|
+
function findNextWindowOpen(input) {
|
|
10129
|
+
const step = Math.max(1e3, input.stepMs ?? 6e4);
|
|
10130
|
+
const from = Math.floor(input.nowMs / step) * step;
|
|
10131
|
+
const ceiling = input.nowMs + input.lookaheadMs;
|
|
10132
|
+
const active = (t) => require_dist.isScheduleActive(input.schedule, t);
|
|
10133
|
+
let startMs = null;
|
|
10134
|
+
for (let t = from; t + step <= ceiling; t += step) if (!active(t) && active(t + step)) {
|
|
10135
|
+
startMs = t + step;
|
|
10136
|
+
break;
|
|
10137
|
+
}
|
|
10138
|
+
if (startMs === null) return null;
|
|
10139
|
+
for (let t = startMs; t + step <= ceiling; t += step) if (!active(t + step)) return {
|
|
10140
|
+
startMs,
|
|
10141
|
+
endMs: t + step
|
|
10142
|
+
};
|
|
10143
|
+
return {
|
|
10144
|
+
startMs,
|
|
10145
|
+
endMs: null
|
|
10146
|
+
};
|
|
10147
|
+
}
|
|
8993
10148
|
/** The canvas behind a tile whose aspect does not fill its cell. */
|
|
8994
10149
|
var MOSAIC_BACKGROUND = {
|
|
8995
10150
|
r: 17,
|
|
@@ -9621,11 +10776,23 @@ var NcSummaryProducer = class {
|
|
|
9621
10776
|
this.tickMs = deps.tickMs ?? 6e4;
|
|
9622
10777
|
this.texts = deps.texts ?? new NcTextCatalog();
|
|
9623
10778
|
}
|
|
10779
|
+
/**
|
|
10780
|
+
* Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
|
|
10781
|
+
*
|
|
10782
|
+
* Same contract as the timelapse producer, and for the same reason: a digest
|
|
10783
|
+
* tick renders a mosaic and publishes it, so awaiting it here would hold
|
|
10784
|
+
* `NotificationCenter.start()` → the addon's `initialize()` → the runner's
|
|
10785
|
+
* capability manifest. `start()` resolving means the producer is running,
|
|
10786
|
+
* never that the back-fill has finished. See `timelapse-scheduler.start()`
|
|
10787
|
+
* for the incident this pins.
|
|
10788
|
+
*/
|
|
9624
10789
|
async start() {
|
|
9625
|
-
await this.tick();
|
|
9626
10790
|
this.timer = setInterval(() => {
|
|
9627
10791
|
this.tick();
|
|
9628
10792
|
}, this.tickMs);
|
|
10793
|
+
this.tick().catch((err) => {
|
|
10794
|
+
this.deps.logger.warn("summary back-fill tick failed", { meta: { error: String(err) } });
|
|
10795
|
+
});
|
|
9629
10796
|
}
|
|
9630
10797
|
stop() {
|
|
9631
10798
|
if (this.timer !== null) {
|
|
@@ -10011,7 +11178,15 @@ var NcSummaryProducer = class {
|
|
|
10011
11178
|
}
|
|
10012
11179
|
const bytes = new Uint8Array(rendered.jpeg.byteLength);
|
|
10013
11180
|
bytes.set(rendered.jpeg);
|
|
10014
|
-
const url = await this.deps.publish(bytes, "image/jpeg"
|
|
11181
|
+
const { url } = await this.deps.publish(bytes, "image/jpeg", {
|
|
11182
|
+
kind: "summary-mosaic",
|
|
11183
|
+
deviceId: rule.deviceIds[0] ?? null,
|
|
11184
|
+
deviceIds: [...new Set(tiles.map((t) => t.candidate.deviceId))],
|
|
11185
|
+
capturedAt: window.endMs,
|
|
11186
|
+
ruleId: rule.id,
|
|
11187
|
+
ruleName: rule.name,
|
|
11188
|
+
...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
|
|
11189
|
+
});
|
|
10015
11190
|
if (url === null) {
|
|
10016
11191
|
this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
|
|
10017
11192
|
return {
|
|
@@ -10537,6 +11712,13 @@ var BboxSchema = require_dist.object({
|
|
|
10537
11712
|
* all — picking the wrong one is the single most common reason an operator's
|
|
10538
11713
|
* rule "does not fire", and making it an explicit choice is half the point of
|
|
10539
11714
|
* the tester.
|
|
11715
|
+
*
|
|
11716
|
+
* It is now EVERY kind, without exception, because the per-rule Test button
|
|
11717
|
+
* (`nc.testRule`) plans its injection from the rule and must be able to test
|
|
11718
|
+
* any rule an operator can author. The two that arrived last are the two whose
|
|
11719
|
+
* rules were previously untestable end-to-end: a SOUND rule fires only on a
|
|
11720
|
+
* confirmed sampling window (`audio-window`, not the single classified sample
|
|
11721
|
+
* `audio-event` stands for), and a SYSTEM rule fires on no record at all.
|
|
10540
11722
|
*/
|
|
10541
11723
|
var TriggerSchema = require_dist._enum([
|
|
10542
11724
|
"object-event",
|
|
@@ -10544,7 +11726,9 @@ var TriggerSchema = require_dist._enum([
|
|
|
10544
11726
|
"device-event",
|
|
10545
11727
|
"package-event",
|
|
10546
11728
|
"audio-event",
|
|
10547
|
-
"
|
|
11729
|
+
"audio-window",
|
|
11730
|
+
"occupancy-event",
|
|
11731
|
+
"system-event"
|
|
10548
11732
|
]);
|
|
10549
11733
|
/**
|
|
10550
11734
|
* What the operator typed. Every field maps onto exactly one field of
|
|
@@ -10608,6 +11792,47 @@ var NcTestEventInputSchema = require_dist.object({
|
|
|
10608
11792
|
occupied: require_dist.boolean(),
|
|
10609
11793
|
threshold: require_dist.number().int().min(1)
|
|
10610
11794
|
}).optional(),
|
|
11795
|
+
/**
|
|
11796
|
+
* AUDIO-WINDOW: the confirmed sampling window.
|
|
11797
|
+
*
|
|
11798
|
+
* Every field of the engine's {@link NcAudioWindowSubject}, because
|
|
11799
|
+
* `matchesAudio` pairs a rule to the window that BELONGS to it — the spec
|
|
11800
|
+
* fields (`samplingSeconds`, `dbThreshold`, `specLabels`) are compared for
|
|
11801
|
+
* equality, so a window built from anything but the rule's own condition
|
|
11802
|
+
* fails closed and the test would report a rule that works as broken.
|
|
11803
|
+
*/
|
|
11804
|
+
audioWindow: require_dist.object({
|
|
11805
|
+
hitPercent: require_dist.number().min(0).max(100),
|
|
11806
|
+
samplingSeconds: require_dist.number().int().min(1).max(300),
|
|
11807
|
+
samples: require_dist.number().int().min(0),
|
|
11808
|
+
hits: require_dist.number().int().min(0),
|
|
11809
|
+
dbThreshold: require_dist.number().optional(),
|
|
11810
|
+
peakDbfs: require_dist.number().optional(),
|
|
11811
|
+
specLabels: require_dist.array(require_dist.string().min(1)).optional(),
|
|
11812
|
+
labels: require_dist.array(require_dist.string().min(1)).default([])
|
|
11813
|
+
}).optional(),
|
|
11814
|
+
/**
|
|
11815
|
+
* SYSTEM-EVENT: the normalized infrastructure event.
|
|
11816
|
+
*
|
|
11817
|
+
* The catalog KEYS travel, never the sentence — exactly as the live intake
|
|
11818
|
+
* freezes them (`event-intake.ts`), so a tested system notification is
|
|
11819
|
+
* worded by the same text catalog, in the same language, as the real one. A
|
|
11820
|
+
* tester that froze English prose here would be testing its own prose.
|
|
11821
|
+
*/
|
|
11822
|
+
systemEvent: require_dist.object({
|
|
11823
|
+
kind: require_dist.NcSystemEventKindSchema,
|
|
11824
|
+
/** The dedup subject (`device:617`, `node:agent-1`, …). */
|
|
11825
|
+
subject: require_dist.string().min(1),
|
|
11826
|
+
titleKey: require_dist.string().min(1),
|
|
11827
|
+
bodyKey: require_dist.string().min(1),
|
|
11828
|
+
textParams: require_dist.record(require_dist.string(), require_dist.string()).default({}),
|
|
11829
|
+
/** The device the event is ABOUT; absent = an event with no device. */
|
|
11830
|
+
deviceId: require_dist.number().int().optional(),
|
|
11831
|
+
/** `DeviceType` value — what a `deviceTypes` condition is judged against. */
|
|
11832
|
+
deviceType: require_dist.string().min(1).optional(),
|
|
11833
|
+
nodeId: require_dist.string().min(1).optional(),
|
|
11834
|
+
packageName: require_dist.string().min(1).optional()
|
|
11835
|
+
}).optional(),
|
|
10611
11836
|
/** The instant the event happened. Absent = now. Set it to test a schedule
|
|
10612
11837
|
* condition without waiting for the hour. */
|
|
10613
11838
|
timestamp: require_dist.number().int().optional(),
|
|
@@ -10695,6 +11920,12 @@ function isSyntheticId(id) {
|
|
|
10695
11920
|
function triggerCanCarryStill(kind) {
|
|
10696
11921
|
return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
|
|
10697
11922
|
}
|
|
11923
|
+
/** The detection provenance production stamps on a subject of this kind. */
|
|
11924
|
+
function syntheticSource(kind) {
|
|
11925
|
+
if (kind === "device-event") return "sensor";
|
|
11926
|
+
if (kind === "audio-window") return "audio";
|
|
11927
|
+
return "pipeline";
|
|
11928
|
+
}
|
|
10698
11929
|
/**
|
|
10699
11930
|
* Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
|
|
10700
11931
|
* event, so the red-green test can assert that this subject and a
|
|
@@ -10708,7 +11939,7 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
10708
11939
|
subject: {
|
|
10709
11940
|
kind,
|
|
10710
11941
|
recordId,
|
|
10711
|
-
deviceId: input.deviceId,
|
|
11942
|
+
deviceId: kind === "system-event" ? 0 : input.deviceId,
|
|
10712
11943
|
...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
|
|
10713
11944
|
timestamp,
|
|
10714
11945
|
classNames: input.classNames,
|
|
@@ -10716,7 +11947,7 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
10716
11947
|
...input.confidence !== void 0 ? { confidence: input.confidence } : {},
|
|
10717
11948
|
zones: input.zones,
|
|
10718
11949
|
...carriesTrackId ? { trackId: recordId } : {},
|
|
10719
|
-
source: input.source ?? (kind
|
|
11950
|
+
source: input.source ?? syntheticSource(kind),
|
|
10720
11951
|
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
10721
11952
|
...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
|
|
10722
11953
|
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
@@ -10725,6 +11956,28 @@ function buildSyntheticEvent(input, recordId, now) {
|
|
|
10725
11956
|
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
10726
11957
|
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
10727
11958
|
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
11959
|
+
...input.audioWindow !== void 0 ? { audioWindow: {
|
|
11960
|
+
hitPercent: input.audioWindow.hitPercent,
|
|
11961
|
+
samplingSeconds: input.audioWindow.samplingSeconds,
|
|
11962
|
+
samples: input.audioWindow.samples,
|
|
11963
|
+
hits: input.audioWindow.hits,
|
|
11964
|
+
...input.audioWindow.dbThreshold !== void 0 ? { dbThreshold: input.audioWindow.dbThreshold } : {},
|
|
11965
|
+
...input.audioWindow.peakDbfs !== void 0 ? { peakDbfs: input.audioWindow.peakDbfs } : {},
|
|
11966
|
+
...input.audioWindow.specLabels !== void 0 ? { specLabels: input.audioWindow.specLabels } : {},
|
|
11967
|
+
labels: input.audioWindow.labels
|
|
11968
|
+
} } : {},
|
|
11969
|
+
...input.systemEvent !== void 0 ? { systemEvent: {
|
|
11970
|
+
kind: input.systemEvent.kind,
|
|
11971
|
+
subject: input.systemEvent.subject,
|
|
11972
|
+
titleKey: input.systemEvent.titleKey,
|
|
11973
|
+
bodyKey: input.systemEvent.bodyKey,
|
|
11974
|
+
textParams: input.systemEvent.textParams,
|
|
11975
|
+
...input.systemEvent.deviceId !== void 0 ? { deviceId: input.systemEvent.deviceId } : {},
|
|
11976
|
+
...input.systemEvent.deviceType !== void 0 ? { deviceType: input.systemEvent.deviceType } : {},
|
|
11977
|
+
...input.systemEvent.nodeId !== void 0 ? { nodeId: input.systemEvent.nodeId } : {},
|
|
11978
|
+
...input.systemEvent.packageName !== void 0 ? { packageName: input.systemEvent.packageName } : {},
|
|
11979
|
+
data: { category: "synthetic.test" }
|
|
11980
|
+
} } : {},
|
|
10728
11981
|
...input.occupancy !== void 0 ? { occupancy: {
|
|
10729
11982
|
...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
|
|
10730
11983
|
...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
|
|
@@ -11013,7 +12266,7 @@ function unknownNcTextKeys(keys, bundles = NC_TEXT_BUNDLES) {
|
|
|
11013
12266
|
* durable document stays the sparse map its schema describes.
|
|
11014
12267
|
*/
|
|
11015
12268
|
function applyNcTextOverridePatch(current, language, patch) {
|
|
11016
|
-
const next = { ...current[language]
|
|
12269
|
+
const next = { ...current[language] };
|
|
11017
12270
|
for (const [key, value] of Object.entries(patch)) {
|
|
11018
12271
|
if (value === null || value.trim().length === 0) {
|
|
11019
12272
|
delete next[key];
|
|
@@ -11383,6 +12636,84 @@ function planDenseRanges(input) {
|
|
|
11383
12636
|
denseSec
|
|
11384
12637
|
};
|
|
11385
12638
|
}
|
|
12639
|
+
function newGroup(event) {
|
|
12640
|
+
const classCounts = /* @__PURE__ */ new Map();
|
|
12641
|
+
if (event.className !== void 0) classCounts.set(event.className, 1);
|
|
12642
|
+
return {
|
|
12643
|
+
...event.trackId !== void 0 ? { trackId: event.trackId } : {},
|
|
12644
|
+
firstSeen: event.timestamp,
|
|
12645
|
+
lastSeen: event.timestamp,
|
|
12646
|
+
classCounts
|
|
12647
|
+
};
|
|
12648
|
+
}
|
|
12649
|
+
function absorb(group, event) {
|
|
12650
|
+
if (event.timestamp < group.firstSeen) group.firstSeen = event.timestamp;
|
|
12651
|
+
if (event.timestamp > group.lastSeen) group.lastSeen = event.timestamp;
|
|
12652
|
+
if (event.className === void 0) return;
|
|
12653
|
+
group.classCounts.set(event.className, (group.classCounts.get(event.className) ?? 0) + 1);
|
|
12654
|
+
}
|
|
12655
|
+
/** The class most rows agreed on; ties go to whichever appeared first, so the
|
|
12656
|
+
* same window always produces the same sentence. */
|
|
12657
|
+
function dominantClass(group) {
|
|
12658
|
+
let best;
|
|
12659
|
+
let bestCount = 0;
|
|
12660
|
+
for (const [className, count] of group.classCounts) if (count > bestCount) {
|
|
12661
|
+
best = className;
|
|
12662
|
+
bestCount = count;
|
|
12663
|
+
}
|
|
12664
|
+
return best;
|
|
12665
|
+
}
|
|
12666
|
+
function toInterval(group) {
|
|
12667
|
+
const className = dominantClass(group);
|
|
12668
|
+
return {
|
|
12669
|
+
...group.trackId !== void 0 ? { trackId: group.trackId } : {},
|
|
12670
|
+
firstSeen: group.firstSeen,
|
|
12671
|
+
lastSeen: group.lastSeen,
|
|
12672
|
+
...className !== void 0 ? { className } : {}
|
|
12673
|
+
};
|
|
12674
|
+
}
|
|
12675
|
+
/**
|
|
12676
|
+
* Fold persisted tracks and object events into one subject-per-entry array.
|
|
12677
|
+
*
|
|
12678
|
+
* @returns intervals sorted by `firstSeen` ascending. A persisted track is
|
|
12679
|
+
* returned unchanged and its own events are discarded: the store knows when
|
|
12680
|
+
* the track really began, the events only know when the emitter chose to
|
|
12681
|
+
* write one, and widening a persisted interval from event timestamps would
|
|
12682
|
+
* move a range boundary for no operator-visible gain.
|
|
12683
|
+
*/
|
|
12684
|
+
function mergeDetectionIntervals(input) {
|
|
12685
|
+
const gapMs = Math.max(0, input.tracklessGapMs ?? 1e4);
|
|
12686
|
+
const persistedIds = /* @__PURE__ */ new Set();
|
|
12687
|
+
for (const track of input.tracks) if (track.trackId !== void 0) persistedIds.add(track.trackId);
|
|
12688
|
+
const ordered = input.events.toSorted((a, b) => a.timestamp - b.timestamp);
|
|
12689
|
+
const byTrackId = /* @__PURE__ */ new Map();
|
|
12690
|
+
const trackless = [];
|
|
12691
|
+
const openByClass = /* @__PURE__ */ new Map();
|
|
12692
|
+
for (const event of ordered) {
|
|
12693
|
+
const trackId = event.trackId;
|
|
12694
|
+
if (trackId !== void 0) {
|
|
12695
|
+
if (persistedIds.has(trackId)) continue;
|
|
12696
|
+
const existing = byTrackId.get(trackId);
|
|
12697
|
+
if (existing === void 0) byTrackId.set(trackId, newGroup(event));
|
|
12698
|
+
else absorb(existing, event);
|
|
12699
|
+
continue;
|
|
12700
|
+
}
|
|
12701
|
+
const key = event.className ?? "";
|
|
12702
|
+
const open = openByClass.get(key);
|
|
12703
|
+
if (open !== void 0 && event.timestamp - open.lastSeen <= gapMs) {
|
|
12704
|
+
absorb(open, event);
|
|
12705
|
+
continue;
|
|
12706
|
+
}
|
|
12707
|
+
const group = newGroup(event);
|
|
12708
|
+
trackless.push(group);
|
|
12709
|
+
openByClass.set(key, group);
|
|
12710
|
+
}
|
|
12711
|
+
return [
|
|
12712
|
+
...input.tracks,
|
|
12713
|
+
...[...byTrackId.values()].map(toInterval),
|
|
12714
|
+
...trackless.map(toInterval)
|
|
12715
|
+
].toSorted((a, b) => a.firstSeen - b.firstSeen);
|
|
12716
|
+
}
|
|
11386
12717
|
//#endregion
|
|
11387
12718
|
//#region src/notification-center/timelapse/footage-coverage.ts
|
|
11388
12719
|
/**
|
|
@@ -11435,8 +12766,6 @@ function footageCoverage(ranges, windowStartMs, windowEndMs) {
|
|
|
11435
12766
|
complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
|
|
11436
12767
|
};
|
|
11437
12768
|
}
|
|
11438
|
-
/** How long a scheduled timelapse's file is kept by the export engine. */
|
|
11439
|
-
var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
|
|
11440
12769
|
var TimelapseRenderError = class extends Error {
|
|
11441
12770
|
reason;
|
|
11442
12771
|
constructor(reason, message) {
|
|
@@ -11464,8 +12793,79 @@ function pickProfile(ranges, window) {
|
|
|
11464
12793
|
}
|
|
11465
12794
|
return best;
|
|
11466
12795
|
}
|
|
11467
|
-
/**
|
|
11468
|
-
|
|
12796
|
+
/**
|
|
12797
|
+
* Is `record` the render this request is about to order?
|
|
12798
|
+
*
|
|
12799
|
+
* The range, the source profile and the BASE cadence must match exactly — those
|
|
12800
|
+
* three decide what the video is. The dense overlay deliberately does NOT: it
|
|
12801
|
+
* is derived from the tracks the window happened to hold, and a retry minutes
|
|
12802
|
+
* later plans it from a slightly longer list. Refusing to adopt over a moved
|
|
12803
|
+
* range boundary would re-order the whole ten hours for a difference nobody can
|
|
12804
|
+
* see, which is precisely the loop this exists to break.
|
|
12805
|
+
*/
|
|
12806
|
+
function coversTheSameRender(record, want) {
|
|
12807
|
+
const timelapse = record.options.timelapse;
|
|
12808
|
+
if (timelapse === void 0) return false;
|
|
12809
|
+
return record.profile === want.profile && record.fromMs === want.fromMs && record.toMs === want.toMs && timelapse.everyMs === want.everyMs && timelapse.outputFps === want.outputFps;
|
|
12810
|
+
}
|
|
12811
|
+
/**
|
|
12812
|
+
* Would adopting `record` LOSE the slowdown this render planned?
|
|
12813
|
+
*
|
|
12814
|
+
* The adoption check deliberately ignores the dense RANGES (a retry plans them
|
|
12815
|
+
* from a slightly longer track list, and re-ordering ten hours over a moved
|
|
12816
|
+
* boundary is the loop adoption exists to break). It must NOT ignore whether a
|
|
12817
|
+
* dense overlay exists at all.
|
|
12818
|
+
*
|
|
12819
|
+
* The failure this closes: attempt one reads the detections while the store is
|
|
12820
|
+
* unreachable, plans nothing, and queues a UNIFORM ten-hour export; every retry
|
|
12821
|
+
* then plans ranges correctly, finds that uniform export alive under the same
|
|
12822
|
+
* (profile, range, base cadence, fps) key, adopts it, and delivers a video that
|
|
12823
|
+
* never slows down — for the whole night, no matter how many retries run. The
|
|
12824
|
+
* notification would even caption it "2 persone". A re-order costs ~20 min of
|
|
12825
|
+
* ffmpeg ONCE; adopting the wrong product costs the night.
|
|
12826
|
+
*
|
|
12827
|
+
* Bounded, and that is why it is safe: the verdict is a function of the plan, so
|
|
12828
|
+
* the export ordered after a refusal carries the dense overlay and the NEXT
|
|
12829
|
+
* retry adopts it. Only a plan that changed direction refuses, never a retry.
|
|
12830
|
+
* A candidate DENSER than what we want is adopted — more slowdown than asked is
|
|
12831
|
+
* not a defect, and refusing it would re-order for nothing.
|
|
12832
|
+
*/
|
|
12833
|
+
function keepsThePlannedSlowdown(record, want) {
|
|
12834
|
+
if (want === void 0) return true;
|
|
12835
|
+
const dense = record.options.timelapse?.dense;
|
|
12836
|
+
if (dense === void 0 || dense.ranges.length === 0) return false;
|
|
12837
|
+
return dense.everyMs <= want.everyMs;
|
|
12838
|
+
}
|
|
12839
|
+
/** Can this row still become — or already be — a file we can fetch? */
|
|
12840
|
+
function isAdoptable(record, nowMs) {
|
|
12841
|
+
if (record.expiresAt <= nowMs) return false;
|
|
12842
|
+
if (record.state === "ready") return record.fileBytes !== null;
|
|
12843
|
+
return record.state === "queued" || record.state === "rendering";
|
|
12844
|
+
}
|
|
12845
|
+
/**
|
|
12846
|
+
* `HH:MM` in the RULE's timezone, for the export title an operator reads.
|
|
12847
|
+
*
|
|
12848
|
+
* The zone is not decoration. The hub container runs on UTC while the operator
|
|
12849
|
+
* and every one of his schedules run on Europe/Rome, so the host-local
|
|
12850
|
+
* derivation this used to do titled a 21:00→07:00 window `19:00→05:00` — two
|
|
12851
|
+
* hours off, on the one string whose whole job is to say which night the file
|
|
12852
|
+
* is. The window boundaries themselves were always correct: `isScheduleActive`
|
|
12853
|
+
* has resolved minutes through `schedule.timezone` from the start, and only the
|
|
12854
|
+
* label lied.
|
|
12855
|
+
*
|
|
12856
|
+
* An absent or unknown IANA name falls back to the host zone, which is exactly
|
|
12857
|
+
* what `localDayMinute` does for the same input — one behaviour for a bad
|
|
12858
|
+
* zone, not two.
|
|
12859
|
+
*/
|
|
12860
|
+
function clockOf$1(atMs, timeZone) {
|
|
12861
|
+
if (timeZone !== void 0) try {
|
|
12862
|
+
return new Intl.DateTimeFormat("en-GB", {
|
|
12863
|
+
timeZone,
|
|
12864
|
+
hour: "2-digit",
|
|
12865
|
+
minute: "2-digit",
|
|
12866
|
+
hourCycle: "h23"
|
|
12867
|
+
}).format(new Date(atMs));
|
|
12868
|
+
} catch {}
|
|
11469
12869
|
const d = new Date(atMs);
|
|
11470
12870
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
11471
12871
|
}
|
|
@@ -11497,12 +12897,25 @@ var TimelapseRenderer = class {
|
|
|
11497
12897
|
});
|
|
11498
12898
|
throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
|
|
11499
12899
|
}
|
|
11500
|
-
const tracks = await this.ports.listTracks({
|
|
12900
|
+
const [tracks, events] = await Promise.all([this.ports.listTracks({
|
|
12901
|
+
deviceId,
|
|
12902
|
+
since: window.startMs,
|
|
12903
|
+
until: window.endMs
|
|
12904
|
+
}).catch((err) => {
|
|
12905
|
+
this.ports.logger.warn("timelapse track read failed — dense plan degraded", {
|
|
12906
|
+
tags,
|
|
12907
|
+
meta: {
|
|
12908
|
+
ruleId: request.ruleId,
|
|
12909
|
+
error: String(err)
|
|
12910
|
+
}
|
|
12911
|
+
});
|
|
12912
|
+
return [];
|
|
12913
|
+
}), this.ports.listDetectionEvents({
|
|
11501
12914
|
deviceId,
|
|
11502
12915
|
since: window.startMs,
|
|
11503
12916
|
until: window.endMs
|
|
11504
12917
|
}).catch((err) => {
|
|
11505
|
-
this.ports.logger.warn("timelapse
|
|
12918
|
+
this.ports.logger.warn("timelapse detection-event read failed — dense plan degraded", {
|
|
11506
12919
|
tags,
|
|
11507
12920
|
meta: {
|
|
11508
12921
|
ruleId: request.ruleId,
|
|
@@ -11510,13 +12923,17 @@ var TimelapseRenderer = class {
|
|
|
11510
12923
|
}
|
|
11511
12924
|
});
|
|
11512
12925
|
return [];
|
|
12926
|
+
})]);
|
|
12927
|
+
const subjects = mergeDetectionIntervals({
|
|
12928
|
+
tracks,
|
|
12929
|
+
events
|
|
11513
12930
|
});
|
|
11514
12931
|
const detections = countDetections({
|
|
11515
|
-
tracks,
|
|
12932
|
+
tracks: subjects,
|
|
11516
12933
|
...request.reportClasses !== void 0 ? { reportClasses: request.reportClasses } : {}
|
|
11517
12934
|
});
|
|
11518
12935
|
const plan = planDenseRanges({
|
|
11519
|
-
tracks,
|
|
12936
|
+
tracks: subjects,
|
|
11520
12937
|
windowStartMs: window.startMs,
|
|
11521
12938
|
windowEndMs: window.endMs,
|
|
11522
12939
|
padSec: 5,
|
|
@@ -11549,16 +12966,25 @@ var TimelapseRenderer = class {
|
|
|
11549
12966
|
mergedRanges: plan.mergedCount
|
|
11550
12967
|
}
|
|
11551
12968
|
});
|
|
11552
|
-
const
|
|
12969
|
+
const adopted = await this.findAdoptable({
|
|
12970
|
+
deviceId,
|
|
12971
|
+
profile: chosen.profile,
|
|
12972
|
+
fromMs: window.startMs,
|
|
12973
|
+
toMs: window.endMs
|
|
12974
|
+
}, {
|
|
12975
|
+
everyMs,
|
|
12976
|
+
outputFps: request.framerate
|
|
12977
|
+
}, dense, tags);
|
|
12978
|
+
const queued = adopted ?? await this.ports.createExport({
|
|
11553
12979
|
deviceId,
|
|
11554
12980
|
profile: chosen.profile,
|
|
11555
12981
|
fromMs: window.startMs,
|
|
11556
12982
|
toMs: window.endMs,
|
|
11557
12983
|
options: {
|
|
11558
12984
|
includeAudio: false,
|
|
11559
|
-
maxLifeMs:
|
|
12985
|
+
maxLifeMs: 6048e5,
|
|
11560
12986
|
deleteAfterDownload: false,
|
|
11561
|
-
title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
|
|
12987
|
+
title: `${request.ruleName} ${clockOf$1(window.startMs, request.timezone)}→${clockOf$1(window.endMs, request.timezone)}`,
|
|
11562
12988
|
timelapse: {
|
|
11563
12989
|
everyMs,
|
|
11564
12990
|
outputFps: request.framerate,
|
|
@@ -11566,7 +12992,7 @@ var TimelapseRenderer = class {
|
|
|
11566
12992
|
}
|
|
11567
12993
|
}
|
|
11568
12994
|
});
|
|
11569
|
-
this.ports.logger.info("timelapse export queued", {
|
|
12995
|
+
this.ports.logger.info(adopted === null ? "timelapse export queued" : "timelapse export adopted", {
|
|
11570
12996
|
tags,
|
|
11571
12997
|
meta: {
|
|
11572
12998
|
ruleId: request.ruleId,
|
|
@@ -11577,11 +13003,15 @@ var TimelapseRenderer = class {
|
|
|
11577
13003
|
denseSource: cadence.source,
|
|
11578
13004
|
denseDwellApplied: cadence.dwellApplied,
|
|
11579
13005
|
denseRanges: plan.ranges.length,
|
|
11580
|
-
|
|
13006
|
+
denseTracks: tracks.length,
|
|
13007
|
+
denseEvents: events.length,
|
|
13008
|
+
denseSubjects: subjects.length,
|
|
13009
|
+
coveragePct: chosen.coverage.percent,
|
|
13010
|
+
...adopted !== null ? { adoptedState: adopted.state } : {}
|
|
11581
13011
|
}
|
|
11582
13012
|
});
|
|
11583
13013
|
const finished = await this.awaitReady(queued, tags);
|
|
11584
|
-
const published = await this.publish(finished.id,
|
|
13014
|
+
const published = await this.publish(finished.id, request, tags);
|
|
11585
13015
|
const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
|
|
11586
13016
|
return {
|
|
11587
13017
|
exportId: finished.id,
|
|
@@ -11595,14 +13025,79 @@ var TimelapseRenderer = class {
|
|
|
11595
13025
|
fileBytes: finished.fileBytes
|
|
11596
13026
|
};
|
|
11597
13027
|
}
|
|
11598
|
-
/**
|
|
13028
|
+
/**
|
|
13029
|
+
* The render this request would order, if the engine already owes it.
|
|
13030
|
+
*
|
|
13031
|
+
* Never throws: an adoption lookup that fails costs one duplicate export,
|
|
13032
|
+
* which is strictly better than costing the window. It says so, though — a
|
|
13033
|
+
* branch that silently drops the protection reads as "there was nothing to
|
|
13034
|
+
* adopt".
|
|
13035
|
+
*/
|
|
13036
|
+
async findAdoptable(want, cadence, wantDense, tags) {
|
|
13037
|
+
const rows = await this.ports.listExports({ deviceId: want.deviceId }).catch((err) => {
|
|
13038
|
+
this.ports.logger.warn("timelapse could not check for an export already in flight", {
|
|
13039
|
+
tags,
|
|
13040
|
+
meta: { error: String(err) }
|
|
13041
|
+
});
|
|
13042
|
+
return null;
|
|
13043
|
+
});
|
|
13044
|
+
if (rows === null) return null;
|
|
13045
|
+
const now = this.ports.now();
|
|
13046
|
+
const sameRender = rows.filter((r) => isAdoptable(r, now) && coversTheSameRender(r, {
|
|
13047
|
+
...want,
|
|
13048
|
+
...cadence
|
|
13049
|
+
}));
|
|
13050
|
+
const usable = sameRender.filter((r) => keepsThePlannedSlowdown(r, wantDense));
|
|
13051
|
+
if (usable.length < sameRender.length) this.ports.logger.info("timelapse refused to adopt an export with no dense overlay", {
|
|
13052
|
+
tags,
|
|
13053
|
+
meta: {
|
|
13054
|
+
candidates: sameRender.length,
|
|
13055
|
+
refused: sameRender.length - usable.length,
|
|
13056
|
+
wantDenseEveryMs: wantDense?.everyMs ?? null,
|
|
13057
|
+
wantDenseRanges: wantDense?.ranges.length ?? 0
|
|
13058
|
+
}
|
|
13059
|
+
});
|
|
13060
|
+
const ready = usable.find((r) => r.state === "ready");
|
|
13061
|
+
if (ready !== void 0) return ready;
|
|
13062
|
+
return usable.toSorted((a, b) => a.createdAt - b.createdAt)[0] ?? null;
|
|
13063
|
+
}
|
|
13064
|
+
/**
|
|
13065
|
+
* Poll to a terminal state, under a bound.
|
|
13066
|
+
*
|
|
13067
|
+
* The bound belongs to the RENDER, not to any single poll of it. A
|
|
13068
|
+
* `getExport` that fails on the transport says nothing at all about the
|
|
13069
|
+
* ffmpeg on the recording node — and treating it as fatal is how a finished
|
|
13070
|
+
* 12.5 MB video was abandoned on 2026-08-14: one `UDS request timed out after
|
|
13071
|
+
* 60000ms` at 05:10:47 threw straight out of the wait for an export that
|
|
13072
|
+
* reached `ready` at 05:23:47. So a failed poll is retried until the
|
|
13073
|
+
* deadline, and only a TERMINAL state the engine actually reported ends the
|
|
13074
|
+
* wait early.
|
|
13075
|
+
*/
|
|
11599
13076
|
async awaitReady(queued, tags) {
|
|
11600
13077
|
if (queued.state === "ready") return queued;
|
|
11601
13078
|
const deadline = this.ports.now() + this.timeoutMs;
|
|
11602
13079
|
let current = queued;
|
|
13080
|
+
let pollFailures = 0;
|
|
13081
|
+
let lastPollError = null;
|
|
11603
13082
|
while (this.ports.now() < deadline) {
|
|
11604
13083
|
await this.ports.sleep(this.pollMs);
|
|
11605
|
-
|
|
13084
|
+
const polled = await this.ports.getExport({ exportId: queued.id }).catch((err) => {
|
|
13085
|
+
pollFailures += 1;
|
|
13086
|
+
lastPollError = String(err);
|
|
13087
|
+
const entry = {
|
|
13088
|
+
tags,
|
|
13089
|
+
meta: {
|
|
13090
|
+
exportId: queued.id,
|
|
13091
|
+
pollFailures,
|
|
13092
|
+
error: lastPollError
|
|
13093
|
+
}
|
|
13094
|
+
};
|
|
13095
|
+
if (pollFailures === 1) this.ports.logger.warn("timelapse export poll failed — the render is still owed", entry);
|
|
13096
|
+
else this.ports.logger.debug("timelapse export poll failed again", entry);
|
|
13097
|
+
return null;
|
|
13098
|
+
});
|
|
13099
|
+
if (polled === null) continue;
|
|
13100
|
+
current = polled;
|
|
11606
13101
|
if (current.state === "ready") return current;
|
|
11607
13102
|
if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
|
|
11608
13103
|
this.ports.logger.warn("timelapse export did not finish", {
|
|
@@ -11621,10 +13116,12 @@ var TimelapseRenderer = class {
|
|
|
11621
13116
|
meta: {
|
|
11622
13117
|
exportId: queued.id,
|
|
11623
13118
|
timeoutMs: this.timeoutMs,
|
|
11624
|
-
lastState: current.state
|
|
13119
|
+
lastState: current.state,
|
|
13120
|
+
pollFailures,
|
|
13121
|
+
...lastPollError !== null ? { lastPollError } : {}
|
|
11625
13122
|
}
|
|
11626
13123
|
});
|
|
11627
|
-
throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
|
|
13124
|
+
throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
|
|
11628
13125
|
}
|
|
11629
13126
|
/**
|
|
11630
13127
|
* Mint the public link, or say why there is none.
|
|
@@ -11633,7 +13130,9 @@ var TimelapseRenderer = class {
|
|
|
11633
13130
|
* notification, not a failed window — and failing here would leave the pair
|
|
11634
13131
|
* unstamped and re-render the same eight hours on the next tick.
|
|
11635
13132
|
*/
|
|
11636
|
-
async publish(exportId,
|
|
13133
|
+
async publish(exportId, request, tags) {
|
|
13134
|
+
const { deviceId, previewText } = request;
|
|
13135
|
+
const previewMode = request.previewMode ?? "image";
|
|
11637
13136
|
const publish = this.ports.publishArtifact;
|
|
11638
13137
|
const none = {
|
|
11639
13138
|
video: null,
|
|
@@ -11653,7 +13152,11 @@ var TimelapseRenderer = class {
|
|
|
11653
13152
|
...previewText !== void 0 ? {
|
|
11654
13153
|
previewText,
|
|
11655
13154
|
previewMode
|
|
11656
|
-
} : {}
|
|
13155
|
+
} : {},
|
|
13156
|
+
capturedAt: request.window.endMs,
|
|
13157
|
+
ruleId: request.ruleId,
|
|
13158
|
+
ruleName: request.ruleName,
|
|
13159
|
+
...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
|
|
11657
13160
|
});
|
|
11658
13161
|
if (set.video === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
|
|
11659
13162
|
tags,
|
|
@@ -11827,6 +13330,25 @@ function buildTimelapseOutboxInputs(input) {
|
|
|
11827
13330
|
};
|
|
11828
13331
|
});
|
|
11829
13332
|
}
|
|
13333
|
+
//#endregion
|
|
13334
|
+
//#region src/notification-center/timelapse/timelapse-scheduler.ts
|
|
13335
|
+
/**
|
|
13336
|
+
* How long after a window closes before it is rendered.
|
|
13337
|
+
*
|
|
13338
|
+
* A track is persisted when it CLOSES, and a track that was open at 06:00
|
|
13339
|
+
* lands minutes later. Rendering at the stroke of the boundary would miss
|
|
13340
|
+
* exactly the tracks the dense plan is built from — the ones still moving when
|
|
13341
|
+
* the window ended.
|
|
13342
|
+
*/
|
|
13343
|
+
var TIMELAPSE_SETTLE_MS = 5 * 6e4;
|
|
13344
|
+
/**
|
|
13345
|
+
* How far back a tick may look for a close.
|
|
13346
|
+
*
|
|
13347
|
+
* Eight days, so a hub that was down over a long weekend still finds last
|
|
13348
|
+
* night. The scan short-circuits at the first close it meets, so a rule with a
|
|
13349
|
+
* nightly window costs about a day's worth of minute steps, not eight.
|
|
13350
|
+
*/
|
|
13351
|
+
var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
|
|
11830
13352
|
/**
|
|
11831
13353
|
* The earliest window CLOSE a (rule, device) pair may ever be offered.
|
|
11832
13354
|
*
|
|
@@ -11856,10 +13378,15 @@ function buildTimelapseOutboxInputs(input) {
|
|
|
11856
13378
|
function timelapseEligibilityFloor(rule, deviceId) {
|
|
11857
13379
|
return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
|
|
11858
13380
|
}
|
|
13381
|
+
/** The in-flight map's key. One render per (rule, camera) pair by construction. */
|
|
13382
|
+
function renderFlightKey(ruleId, deviceId) {
|
|
13383
|
+
return `${ruleId}:${String(deviceId)}`;
|
|
13384
|
+
}
|
|
11859
13385
|
var TimelapseScheduler = class {
|
|
11860
13386
|
deps;
|
|
11861
13387
|
timer = null;
|
|
11862
13388
|
tickInFlight = false;
|
|
13389
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
11863
13390
|
settleMs;
|
|
11864
13391
|
lookbackMs;
|
|
11865
13392
|
tickMs;
|
|
@@ -11872,16 +13399,32 @@ var TimelapseScheduler = class {
|
|
|
11872
13399
|
this.texts = deps.texts ?? new NcTextCatalog();
|
|
11873
13400
|
}
|
|
11874
13401
|
/**
|
|
11875
|
-
* Boot:
|
|
13402
|
+
* Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
|
|
11876
13403
|
*
|
|
11877
13404
|
* The back-fill needs no special path — it IS a tick. A window that closed
|
|
11878
13405
|
* while the process was down is simply a window whose pair has no stamp.
|
|
13406
|
+
*
|
|
13407
|
+
* `start()` resolving must mean "the producer is running", never "the
|
|
13408
|
+
* back-fill has finished". A back-fill tick renders video: one window can sit
|
|
13409
|
+
* in `awaitReady` for the full `RENDER_TIMEOUT_MS` (30 min), once per (rule,
|
|
13410
|
+
* camera) pair. Awaiting it here held `NotificationCenter.start()`, which held
|
|
13411
|
+
* the addon's `initialize()`, which is what publishes the runner's capability
|
|
13412
|
+
* manifest — so on 2026-08-14 a single 10-hour timelapse window took
|
|
13413
|
+
* `notification-rules`, `videoclips`, `face-gallery`, `plate-gallery`,
|
|
13414
|
+
* `zone-analytics`, `audio-metrics` and `pipeline-analytics` off the cluster
|
|
13415
|
+
* entirely, and the admin UI answered "provider not available".
|
|
13416
|
+
*
|
|
13417
|
+
* `tick()` never throws for a render failure — `produce()` swallows those and
|
|
13418
|
+
* leaves the pair unstamped — so the catch here only covers a store read that
|
|
13419
|
+
* fails, and it must not become an unhandled rejection.
|
|
11879
13420
|
*/
|
|
11880
13421
|
async start() {
|
|
11881
|
-
await this.tick();
|
|
11882
13422
|
this.timer = setInterval(() => {
|
|
11883
13423
|
this.tick();
|
|
11884
13424
|
}, this.tickMs);
|
|
13425
|
+
this.tick().catch((err) => {
|
|
13426
|
+
this.deps.logger.warn("timelapse back-fill tick failed", { meta: { error: String(err) } });
|
|
13427
|
+
});
|
|
11885
13428
|
}
|
|
11886
13429
|
stop() {
|
|
11887
13430
|
if (this.timer !== null) {
|
|
@@ -11990,6 +13533,13 @@ var TimelapseScheduler = class {
|
|
|
11990
13533
|
*/
|
|
11991
13534
|
async produce(rule, deviceId, window, opts) {
|
|
11992
13535
|
const tags = { deviceId };
|
|
13536
|
+
const flightKey = renderFlightKey(rule.id, deviceId);
|
|
13537
|
+
this.inFlight.set(flightKey, {
|
|
13538
|
+
ruleId: rule.id,
|
|
13539
|
+
deviceId,
|
|
13540
|
+
window,
|
|
13541
|
+
startedAt: this.deps.now()
|
|
13542
|
+
});
|
|
11993
13543
|
try {
|
|
11994
13544
|
const deviceName = await this.resolveDeviceName(deviceId);
|
|
11995
13545
|
const outcome = await this.deps.render({
|
|
@@ -12001,6 +13551,7 @@ var TimelapseScheduler = class {
|
|
|
12001
13551
|
framerate: rule.framerate,
|
|
12002
13552
|
...rule.denseCadenceSec !== void 0 ? { denseCadenceSec: rule.denseCadenceSec } : {},
|
|
12003
13553
|
...rule.minDwellSec !== void 0 ? { minDwellSec: rule.minDwellSec } : {},
|
|
13554
|
+
...rule.schedule.timezone !== void 0 ? { timezone: rule.schedule.timezone } : {},
|
|
12004
13555
|
previewText: timelapsePreviewText({
|
|
12005
13556
|
deviceId,
|
|
12006
13557
|
...deviceName !== null ? { deviceName } : {},
|
|
@@ -12009,7 +13560,8 @@ var TimelapseScheduler = class {
|
|
|
12009
13560
|
...rule.previewText !== void 0 ? { text: rule.previewText } : {}
|
|
12010
13561
|
}),
|
|
12011
13562
|
previewMode: rule.previewMode,
|
|
12012
|
-
...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {}
|
|
13563
|
+
...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {},
|
|
13564
|
+
...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
|
|
12013
13565
|
});
|
|
12014
13566
|
const rows = buildTimelapseOutboxInputs({
|
|
12015
13567
|
texts: this.texts,
|
|
@@ -12072,8 +13624,15 @@ var TimelapseScheduler = class {
|
|
|
12072
13624
|
window,
|
|
12073
13625
|
error: err instanceof Error ? err.message : String(err)
|
|
12074
13626
|
};
|
|
13627
|
+
} finally {
|
|
13628
|
+
this.inFlight.delete(flightKey);
|
|
12075
13629
|
}
|
|
12076
13630
|
}
|
|
13631
|
+
/** Renders this process currently owes, newest last. Read by the rule-status
|
|
13632
|
+
* surface; never a decision input for the scheduler itself. */
|
|
13633
|
+
renderInFlight() {
|
|
13634
|
+
return [...this.inFlight.values()];
|
|
13635
|
+
}
|
|
12077
13636
|
async resolveDeviceName(deviceId) {
|
|
12078
13637
|
const get = this.deps.getDeviceName;
|
|
12079
13638
|
if (get === void 0) return null;
|
|
@@ -12485,6 +14044,14 @@ var NcZoneOwnershipCache = class {
|
|
|
12485
14044
|
/** The one cap whose `trigger` the combined alarm notification joins on. */
|
|
12486
14045
|
var ALARM_PANEL_CAP = require_dist.alarmPanelCapability.name;
|
|
12487
14046
|
/**
|
|
14047
|
+
* How far ahead a status may scan for the next window opening.
|
|
14048
|
+
*
|
|
14049
|
+
* Nine days rather than "as far as it takes": a weekly schedule opens within
|
|
14050
|
+
* seven, and a schedule that opens less often than that is one whose next run
|
|
14051
|
+
* is not the fact an operator is looking at a card for.
|
|
14052
|
+
*/
|
|
14053
|
+
var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
|
|
14054
|
+
/**
|
|
12488
14055
|
* The synthetic rule id an alarm ANNOUNCEMENT is enqueued under.
|
|
12489
14056
|
*
|
|
12490
14057
|
* The arm announcement is addressed to the panel's own `announceTargets`, not
|
|
@@ -12668,6 +14235,44 @@ function historyRecordKind(kind) {
|
|
|
12668
14235
|
function contactReadingOf(state) {
|
|
12669
14236
|
if (state === "open" || state === "closed") return state;
|
|
12670
14237
|
}
|
|
14238
|
+
/**
|
|
14239
|
+
* The subject kind a persisted record kind maps to.
|
|
14240
|
+
*
|
|
14241
|
+
* `NcOutboxRecordKind` carries two kinds a rule subject never has —
|
|
14242
|
+
* `timelapse` and `ai-summary` are SCHEDULE boundaries, not records. They are
|
|
14243
|
+
* mapped to `device-event`, which is class-agnostic in `cooldownKey`, so their
|
|
14244
|
+
* key is the one the seeder already built for them. A switch rather than a
|
|
14245
|
+
* cast: a kind added to either union has to be answered for here.
|
|
14246
|
+
*/
|
|
14247
|
+
function cooldownSubjectKind(kind) {
|
|
14248
|
+
switch (kind) {
|
|
14249
|
+
case "timelapse":
|
|
14250
|
+
case "ai-summary": return "device-event";
|
|
14251
|
+
default: return kind;
|
|
14252
|
+
}
|
|
14253
|
+
}
|
|
14254
|
+
/**
|
|
14255
|
+
* A persisted outbox row, reduced to exactly what `cooldownKey` reads.
|
|
14256
|
+
*
|
|
14257
|
+
* The point is that it reads NOTHING else: the boot-time cooldown reseed calls
|
|
14258
|
+
* the same builder the live path does, so the two can never address different
|
|
14259
|
+
* windows. Only the four fields the key is a function of are reconstructed —
|
|
14260
|
+
* the class (for `per-class` and audio), the subject kind (which decides
|
|
14261
|
+
* whether audio is keyed per class at all), the system event (subject +
|
|
14262
|
+
* direction) and the device.
|
|
14263
|
+
*/
|
|
14264
|
+
function cooldownSubjectFromOutbox(entry) {
|
|
14265
|
+
const subject = entry.payload.subject;
|
|
14266
|
+
return {
|
|
14267
|
+
kind: cooldownSubjectKind(entry.recordKind),
|
|
14268
|
+
recordId: entry.recordId,
|
|
14269
|
+
deviceId: entry.deviceId,
|
|
14270
|
+
timestamp: subject.timestamp,
|
|
14271
|
+
classNames: subject.className.length > 0 ? [subject.className] : [],
|
|
14272
|
+
zones: [...subject.zones],
|
|
14273
|
+
...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
|
|
14274
|
+
};
|
|
14275
|
+
}
|
|
12671
14276
|
function historySystemEventKind(kind) {
|
|
12672
14277
|
if (kind === "alarm-arming") return "alarm-armed";
|
|
12673
14278
|
if (kind === "alarm-arm-refused") return "alarm-disarmed";
|
|
@@ -13131,7 +14736,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
13131
14736
|
this.now = deps.now ?? (() => Date.now());
|
|
13132
14737
|
this.rules = new NcRuleStore({
|
|
13133
14738
|
store: deps.store,
|
|
13134
|
-
logger: this.logger.child("rules")
|
|
14739
|
+
logger: this.logger.child("rules"),
|
|
14740
|
+
...deps.identityIdsByName !== void 0 ? { identityIdsByName: deps.identityIdsByName } : {}
|
|
13135
14741
|
});
|
|
13136
14742
|
this.textStore = new NcTextCatalogStore({
|
|
13137
14743
|
store: deps.store,
|
|
@@ -13325,6 +14931,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
13325
14931
|
await TimelapseStore.declare(store);
|
|
13326
14932
|
await NcSummaryStore.declare(store);
|
|
13327
14933
|
await NcTextCatalogStore.declare(store);
|
|
14934
|
+
await NcArtifactIndex.declare(store);
|
|
13328
14935
|
}
|
|
13329
14936
|
/**
|
|
13330
14937
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
@@ -13504,6 +15111,120 @@ var NotificationCenter = class NotificationCenter {
|
|
|
13504
15111
|
...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
|
|
13505
15112
|
};
|
|
13506
15113
|
}
|
|
15114
|
+
/**
|
|
15115
|
+
* The live status of ONE detection rule — see `rule-status.ts`.
|
|
15116
|
+
*
|
|
15117
|
+
* `none` is a real answer, not a failure: most rule kinds have no live state
|
|
15118
|
+
* a card could show, and returning an empty object would read as a read that
|
|
15119
|
+
* failed. Only occupancy has one today, and it is the kind whose entire
|
|
15120
|
+
* subject is a number nobody can see.
|
|
15121
|
+
*/
|
|
15122
|
+
async ruleStatusFor(rule) {
|
|
15123
|
+
const occupancy = rule.conditions.occupancy;
|
|
15124
|
+
if (occupancy === void 0) return {
|
|
15125
|
+
kind: "none",
|
|
15126
|
+
reason: "this rule has no live state to report"
|
|
15127
|
+
};
|
|
15128
|
+
const deviceId = rule.conditions.devices?.[0];
|
|
15129
|
+
const read = this.deps.readOccupancySnapshots;
|
|
15130
|
+
const threshold = occupancyThreshold(occupancy);
|
|
15131
|
+
if (deviceId === void 0 || read === void 0) return buildOccupancyStatus({
|
|
15132
|
+
deviceId: deviceId ?? 0,
|
|
15133
|
+
condition: occupancy,
|
|
15134
|
+
probe: {
|
|
15135
|
+
kind: "unavailable",
|
|
15136
|
+
reason: "no-snapshot"
|
|
15137
|
+
},
|
|
15138
|
+
confirmed: this.occupancyWatcher.snapshotState(),
|
|
15139
|
+
threshold
|
|
15140
|
+
});
|
|
15141
|
+
let snapshot = null;
|
|
15142
|
+
try {
|
|
15143
|
+
snapshot = (await read([deviceId])).get(deviceId) ?? null;
|
|
15144
|
+
} catch (err) {
|
|
15145
|
+
this.logger.warn("rule status could not read the live occupancy snapshot", {
|
|
15146
|
+
tags: { deviceId },
|
|
15147
|
+
meta: {
|
|
15148
|
+
ruleId: rule.id,
|
|
15149
|
+
error: String(err)
|
|
15150
|
+
}
|
|
15151
|
+
});
|
|
15152
|
+
}
|
|
15153
|
+
const probe = probeOccupancyNow({
|
|
15154
|
+
occupancy,
|
|
15155
|
+
deviceId,
|
|
15156
|
+
snapshot,
|
|
15157
|
+
now: this.now()
|
|
15158
|
+
});
|
|
15159
|
+
const warning = occupancyClassWarning(occupancy.className);
|
|
15160
|
+
return buildOccupancyStatus({
|
|
15161
|
+
deviceId,
|
|
15162
|
+
condition: occupancy,
|
|
15163
|
+
probe,
|
|
15164
|
+
confirmed: this.occupancyWatcher.snapshotState(),
|
|
15165
|
+
threshold,
|
|
15166
|
+
...warning !== null ? { warning } : {}
|
|
15167
|
+
});
|
|
15168
|
+
}
|
|
15169
|
+
/**
|
|
15170
|
+
* The live status of one TIMELAPSE rule.
|
|
15171
|
+
*
|
|
15172
|
+
* Three facts, and each answers a question the rule itself cannot: when each
|
|
15173
|
+
* camera last produced (and the artefact it produced, while the shelf still
|
|
15174
|
+
* holds it), whether a render is in flight RIGHT NOW, and when the next
|
|
15175
|
+
* window opens. The window boundaries are scanned through `isScheduleActive`,
|
|
15176
|
+
* the same authority the producer uses — a second reading of a schedule is
|
|
15177
|
+
* how a status starts promising a run that is not coming.
|
|
15178
|
+
*/
|
|
15179
|
+
async timelapseStatusFor(rule, lookup) {
|
|
15180
|
+
const now = this.now();
|
|
15181
|
+
const flights = this.timelapseScheduler?.renderInFlight() ?? [];
|
|
15182
|
+
const lastWindow = findLastClosedWindow({
|
|
15183
|
+
schedule: rule.schedule,
|
|
15184
|
+
nowMs: now,
|
|
15185
|
+
lookbackMs: TIMELAPSE_LOOKBACK_MS
|
|
15186
|
+
});
|
|
15187
|
+
const nextWindow = findNextWindowOpen({
|
|
15188
|
+
schedule: rule.schedule,
|
|
15189
|
+
nowMs: now,
|
|
15190
|
+
lookaheadMs: TIMELAPSE_LOOKAHEAD_MS
|
|
15191
|
+
});
|
|
15192
|
+
const devices = [];
|
|
15193
|
+
for (const deviceId of rule.deviceIds) {
|
|
15194
|
+
const generatedAt = require_dist.readTimelapseGeneratedAt(rule, deviceId);
|
|
15195
|
+
const flight = flights.find((f) => f.ruleId === rule.id && f.deviceId === deviceId);
|
|
15196
|
+
const artifact = lookup === void 0 ? null : await lookup({
|
|
15197
|
+
ruleId: rule.id,
|
|
15198
|
+
deviceId
|
|
15199
|
+
}).catch((err) => {
|
|
15200
|
+
this.logger.warn("rule status could not read the timelapse artifact index", {
|
|
15201
|
+
tags: { deviceId },
|
|
15202
|
+
meta: {
|
|
15203
|
+
ruleId: rule.id,
|
|
15204
|
+
error: String(err)
|
|
15205
|
+
}
|
|
15206
|
+
});
|
|
15207
|
+
return null;
|
|
15208
|
+
});
|
|
15209
|
+
devices.push({
|
|
15210
|
+
deviceId,
|
|
15211
|
+
...generatedAt > 0 ? { lastGeneratedAt: generatedAt } : {},
|
|
15212
|
+
...artifact !== null ? { lastArtifact: artifact } : {},
|
|
15213
|
+
...flight !== void 0 ? { rendering: {
|
|
15214
|
+
startedAt: flight.startedAt,
|
|
15215
|
+
windowStartMs: flight.window.startMs,
|
|
15216
|
+
windowEndMs: flight.window.endMs
|
|
15217
|
+
} } : {}
|
|
15218
|
+
});
|
|
15219
|
+
}
|
|
15220
|
+
return {
|
|
15221
|
+
kind: "timelapse",
|
|
15222
|
+
devices,
|
|
15223
|
+
...lastWindow !== null ? { lastWindow } : {},
|
|
15224
|
+
...nextWindow !== null ? { nextWindow } : {},
|
|
15225
|
+
settleMs: TIMELAPSE_SETTLE_MS
|
|
15226
|
+
};
|
|
15227
|
+
}
|
|
13507
15228
|
/** One live frame, or null. Never throws into an injection. */
|
|
13508
15229
|
async captureLiveStill(deviceId, deps) {
|
|
13509
15230
|
const capture = deps.getSnapshot;
|
|
@@ -14278,7 +15999,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14278
15999
|
return report({ droppedBy: "muted" });
|
|
14279
16000
|
}
|
|
14280
16001
|
const delivery = deliveryForKind(kind);
|
|
14281
|
-
const enabled = this.
|
|
16002
|
+
const enabled = this.rulesForTrigger(delivery, kind, subject);
|
|
14282
16003
|
const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
|
|
14283
16004
|
if (candidates.length === 0) {
|
|
14284
16005
|
this.logger.debug("no enabled rule for this trigger", {
|
|
@@ -14290,6 +16011,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14290
16011
|
rulesLoaded: this.rules.list().length
|
|
14291
16012
|
}
|
|
14292
16013
|
});
|
|
16014
|
+
if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
|
|
14293
16015
|
return report({ droppedBy: "no-candidate-rule" });
|
|
14294
16016
|
}
|
|
14295
16017
|
const now = this.now();
|
|
@@ -14390,6 +16112,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14390
16112
|
at: now
|
|
14391
16113
|
});
|
|
14392
16114
|
const inserted = await this.outbox.enqueue(entries);
|
|
16115
|
+
this.noteRecognitionFire(rule, subject, kind, entries, inserted);
|
|
14393
16116
|
if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
|
|
14394
16117
|
outcomes.push({
|
|
14395
16118
|
ruleId: rule.id,
|
|
@@ -14403,9 +16126,124 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14403
16126
|
anyMatched = true;
|
|
14404
16127
|
}
|
|
14405
16128
|
if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
|
|
16129
|
+
if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
|
|
14406
16130
|
return report({ candidates: candidates.length });
|
|
14407
16131
|
}
|
|
14408
16132
|
/**
|
|
16133
|
+
* The rules a trigger may fire, which is `listEnabled(delivery)` plus and
|
|
16134
|
+
* minus exactly one thing: `waitForEnhancement`.
|
|
16135
|
+
*
|
|
16136
|
+
* An `immediate` rule that agreed to wait for a name is REMOVED from the
|
|
16137
|
+
* object-event trigger and ADDED to the track close, because the closed track
|
|
16138
|
+
* is the first record that carries the name. Both halves are needed and the
|
|
16139
|
+
* removal is the load-bearing one — leaving it on the object event would fire
|
|
16140
|
+
* the early, nameless notification, and the dedup key (`ruleId:trackId:target`)
|
|
16141
|
+
* would then silently swallow the complete one as a duplicate. The operator
|
|
16142
|
+
* would have set the switch and received exactly what they set it to avoid.
|
|
16143
|
+
*
|
|
16144
|
+
* The drop is REPORTED. A rule vanishing from a trigger reads as a broken rule
|
|
16145
|
+
* from a log, and this repo has paid for that twice.
|
|
16146
|
+
*/
|
|
16147
|
+
rulesForTrigger(delivery, kind, subject) {
|
|
16148
|
+
const enabled = this.rules.listEnabled(delivery);
|
|
16149
|
+
if (kind === "object-event") {
|
|
16150
|
+
const deferred = enabled.filter((r) => ruleDefersToTrackEnd(r));
|
|
16151
|
+
if (deferred.length === 0) return enabled;
|
|
16152
|
+
this.logger.debug("rules deferred to track close — waiting for recognition", {
|
|
16153
|
+
tags: { deviceId: subject.deviceId },
|
|
16154
|
+
meta: {
|
|
16155
|
+
rules: deferred.map((r) => r.name).join(", "),
|
|
16156
|
+
eventId: subject.recordId,
|
|
16157
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
16158
|
+
}
|
|
16159
|
+
});
|
|
16160
|
+
return enabled.filter((r) => !ruleDefersToTrackEnd(r));
|
|
16161
|
+
}
|
|
16162
|
+
if (kind === "track-end") return [...enabled, ...this.rules.listEnabled("immediate").filter((r) => ruleDefersToTrackEnd(r))];
|
|
16163
|
+
return enabled;
|
|
16164
|
+
}
|
|
16165
|
+
/**
|
|
16166
|
+
* What an `immediate` recognition rule already said about a track, kept only
|
|
16167
|
+
* until that track closes.
|
|
16168
|
+
*
|
|
16169
|
+
* Keyed `ruleIdtrackId`, holding the targets the first push reached and
|
|
16170
|
+
* the LABEL it froze. In RAM and deliberately not durable: losing it costs an
|
|
16171
|
+
* upgrade, which degrades to "the operator got the first notification and not
|
|
16172
|
+
* the second" — the state of the world before this feature. Making it durable
|
|
16173
|
+
* would trade that for a second write on the enqueue path and a table that
|
|
16174
|
+
* outlives the tracks it describes.
|
|
16175
|
+
*/
|
|
16176
|
+
recognitionFires = /* @__PURE__ */ new Map();
|
|
16177
|
+
/** Bound on {@link recognitionFires}. A track that never closes (a crash
|
|
16178
|
+
* between its birth and its close) must not pin a row forever. */
|
|
16179
|
+
static RECOGNITION_FIRE_TTL_MS = 30 * 6e4;
|
|
16180
|
+
recognitionFireKey(ruleId, trackId) {
|
|
16181
|
+
return `${ruleId}${trackId}`;
|
|
16182
|
+
}
|
|
16183
|
+
noteRecognitionFire(rule, subject, kind, entries, inserted) {
|
|
16184
|
+
if (kind !== "object-event" || inserted === 0) return;
|
|
16185
|
+
if (subject.trackId === void 0 || !ruleUpgradesAtTrackEnd(rule)) return;
|
|
16186
|
+
const now = this.now();
|
|
16187
|
+
for (const [key, held] of this.recognitionFires) if (now - held.at > NotificationCenter.RECOGNITION_FIRE_TTL_MS) this.recognitionFires.delete(key);
|
|
16188
|
+
this.recognitionFires.set(this.recognitionFireKey(rule.id, subject.trackId), {
|
|
16189
|
+
targetIds: entries.map((e) => e.targetId),
|
|
16190
|
+
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
16191
|
+
at: now
|
|
16192
|
+
});
|
|
16193
|
+
}
|
|
16194
|
+
/**
|
|
16195
|
+
* Send the "…and it was Gianluca" follow-up for every rule that fired on this
|
|
16196
|
+
* track WITHOUT a name and has one now.
|
|
16197
|
+
*
|
|
16198
|
+
* Four gates, and each one exists because its absence is a notification the
|
|
16199
|
+
* operator did not ask for:
|
|
16200
|
+
*
|
|
16201
|
+
* - the rule fired for this track at all (`recognitionFires`) — an upgrade
|
|
16202
|
+
* with no original is a notification about a rule that never matched;
|
|
16203
|
+
* - the track ended RECOGNISED (`labelKind`) — a person nobody could name
|
|
16204
|
+
* has nothing to add;
|
|
16205
|
+
* - the name is NEW — plates are frequently read on the first frame (0.0 s
|
|
16206
|
+
* observed), and re-sending the same word is the duplicate this whole
|
|
16207
|
+
* feature exists to avoid;
|
|
16208
|
+
* - one row per (rule, track, target), enforced by the outbox on the
|
|
16209
|
+
* `<trackId>:enhanced` dedup ref, which survives a respawn.
|
|
16210
|
+
*
|
|
16211
|
+
* A rule that ALREADY matched this closed track through the ordinary loop
|
|
16212
|
+
* (`outcomes`) is skipped: it just sent a track-end notification carrying the
|
|
16213
|
+
* name, and an upgrade would be that same sentence twice.
|
|
16214
|
+
*/
|
|
16215
|
+
async enqueueRecognitionUpgrades(subject, outcomes) {
|
|
16216
|
+
const trackId = subject.trackId;
|
|
16217
|
+
if (trackId === void 0 || subject.labelKind === void 0) return;
|
|
16218
|
+
const alreadyFired = new Set(outcomes.filter((o) => o.matched).map((o) => o.ruleId));
|
|
16219
|
+
for (const rule of this.rules.listEnabled("immediate")) {
|
|
16220
|
+
if (!ruleUpgradesAtTrackEnd(rule) || alreadyFired.has(rule.id)) continue;
|
|
16221
|
+
const key = this.recognitionFireKey(rule.id, trackId);
|
|
16222
|
+
const held = this.recognitionFires.get(key);
|
|
16223
|
+
if (held === void 0) continue;
|
|
16224
|
+
this.recognitionFires.delete(key);
|
|
16225
|
+
if (held.label === subject.label) continue;
|
|
16226
|
+
const entries = this.buildEntries(rule, subject, "track-end", ["identities"], [], "pipeline", void 0, {
|
|
16227
|
+
dedupRef: `${trackId}:enhanced`,
|
|
16228
|
+
enhancement: true,
|
|
16229
|
+
targetIds: held.targetIds
|
|
16230
|
+
});
|
|
16231
|
+
const inserted = await this.outbox.enqueue(entries);
|
|
16232
|
+
this.logger.info("recognition upgrade — enqueueing a second notification", {
|
|
16233
|
+
tags: { deviceId: subject.deviceId },
|
|
16234
|
+
meta: {
|
|
16235
|
+
ruleId: rule.id,
|
|
16236
|
+
rule: rule.name,
|
|
16237
|
+
trackId,
|
|
16238
|
+
was: held.label ?? "",
|
|
16239
|
+
now: subject.label ?? "",
|
|
16240
|
+
labelKind: subject.labelKind,
|
|
16241
|
+
enqueued: inserted
|
|
16242
|
+
}
|
|
16243
|
+
});
|
|
16244
|
+
}
|
|
16245
|
+
}
|
|
16246
|
+
/**
|
|
14409
16247
|
* Run this rule's `onTrigger` sequences for this match.
|
|
14410
16248
|
*
|
|
14411
16249
|
* Called BEFORE the cooldown gate — see the call site for why. Everything
|
|
@@ -14473,7 +16311,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14473
16311
|
}
|
|
14474
16312
|
});
|
|
14475
16313
|
}
|
|
14476
|
-
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown) {
|
|
16314
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade) {
|
|
14477
16315
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
14478
16316
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
14479
16317
|
const isSystemEvent = kind === "system-event";
|
|
@@ -14484,11 +16322,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14484
16322
|
const renderedZones = notificationZones(rule, subject);
|
|
14485
16323
|
const confirm = frozenConfirmPolicy(rule, isSystemEvent);
|
|
14486
16324
|
const cropZoneIds = rule.conditions.zones !== void 0 ? rule.conditions.zones.ids : rule.conditions.occupancy?.zoneId !== void 0 ? [rule.conditions.occupancy.zoneId] : void 0;
|
|
14487
|
-
|
|
16325
|
+
const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
|
|
16326
|
+
return (upgrade !== void 0 ? allTargets.filter((t) => upgrade.targetIds.includes(t.targetId)) : allTargets).filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
14488
16327
|
const payload = {
|
|
14489
16328
|
ruleName: rule.name,
|
|
14490
16329
|
delivery: rule.delivery,
|
|
14491
16330
|
...origin === "synthetic" ? { synthetic: true } : {},
|
|
16331
|
+
...upgrade !== void 0 ? { enhancement: true } : {},
|
|
14492
16332
|
priority: rule.priority,
|
|
14493
16333
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
14494
16334
|
media: noMedia ? "none" : mediaRule.media.attach,
|
|
@@ -14512,6 +16352,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14512
16352
|
...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : hasEventMedia ? { eventId: subject.recordId } : {},
|
|
14513
16353
|
className: subject.classNames[0] ?? subject.sensorKind ?? "event",
|
|
14514
16354
|
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
16355
|
+
...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {},
|
|
14515
16356
|
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
14516
16357
|
zones: renderedZones,
|
|
14517
16358
|
timestamp: subject.timestamp,
|
|
@@ -14526,6 +16367,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14526
16367
|
recordKind: kind === "occupancy-event" ? "device-event" : kind,
|
|
14527
16368
|
recordId: subject.recordId,
|
|
14528
16369
|
...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
16370
|
+
...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : {},
|
|
14529
16371
|
payload
|
|
14530
16372
|
};
|
|
14531
16373
|
});
|
|
@@ -14618,13 +16460,26 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14618
16460
|
}
|
|
14619
16461
|
});
|
|
14620
16462
|
}
|
|
14621
|
-
/**
|
|
16463
|
+
/**
|
|
16464
|
+
* Rebuild the cooldown map from persisted outbox rows (restart-proof).
|
|
16465
|
+
*
|
|
16466
|
+
* The key comes from `cooldownKey` and NOWHERE else. This method used to
|
|
16467
|
+
* build the string itself, and the copy had drifted: it knew the
|
|
16468
|
+
* `r:<id>` / `r:<id>:d:<device>` shapes and neither the `:c:<class>` tail
|
|
16469
|
+
* (`granularity: 'per-class'`, and unconditional for audio) nor — after
|
|
16470
|
+
* 2026-08-14 — the `:k:<kind>` one. Every window it could not address was a
|
|
16471
|
+
* window that did not survive a restart, so a per-class or audio rule
|
|
16472
|
+
* re-fired immediately after every respawn despite the schema promising
|
|
16473
|
+
* otherwise. `notifications.md` had carried it as a known defect since
|
|
16474
|
+
* 2026-08-02 with the instruction "if you touch `cooldownKey`, fix the seeder
|
|
16475
|
+
* in the same commit"; this is that commit.
|
|
16476
|
+
*/
|
|
14622
16477
|
async seedCooldowns() {
|
|
14623
16478
|
const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
|
|
14624
16479
|
for (const entry of entries) {
|
|
14625
16480
|
const rule = this.rules.get(entry.ruleId);
|
|
14626
16481
|
if (rule === null) continue;
|
|
14627
|
-
const key = rule
|
|
16482
|
+
const key = cooldownKey(rule, cooldownSubjectFromOutbox(entry));
|
|
14628
16483
|
const prev = this.lastFiredAt.get(key) ?? 0;
|
|
14629
16484
|
if (entry.createdAt > prev) this.lastFiredAt.set(key, entry.createdAt);
|
|
14630
16485
|
}
|
|
@@ -14730,7 +16585,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
14730
16585
|
*/
|
|
14731
16586
|
async hydrateLiveness() {
|
|
14732
16587
|
const subjects = await this.liveness.load();
|
|
14733
|
-
this.
|
|
16588
|
+
const seeded = this.liveness.seedFromDirectory(this.deviceDirectory.all(), this.now());
|
|
16589
|
+
this.logger.info("liveness ledger reseeded", { meta: {
|
|
16590
|
+
subjects,
|
|
16591
|
+
seededFromDirectory: seeded,
|
|
16592
|
+
directory: this.deviceDirectory.size()
|
|
16593
|
+
} });
|
|
14734
16594
|
}
|
|
14735
16595
|
/**
|
|
14736
16596
|
* Say that the ledger refused a liveness event, and why.
|
|
@@ -15152,6 +17012,307 @@ var NotificationCenter = class NotificationCenter {
|
|
|
15152
17012
|
}
|
|
15153
17013
|
};
|
|
15154
17014
|
//#endregion
|
|
17015
|
+
//#region src/notification-center/rule-test-plan.ts
|
|
17016
|
+
/**
|
|
17017
|
+
* Which subject kind THIS rule fires on.
|
|
17018
|
+
*
|
|
17019
|
+
* `delivery` alone cannot answer, because two features ride an existing
|
|
17020
|
+
* delivery and are discriminated by a CONDITION — exactly as the engine
|
|
17021
|
+
* discriminates them (`rule-engine.ts`: an audio window fires only for a rule
|
|
17022
|
+
* declaring `conditions.audio`, an occupancy edge only for one declaring
|
|
17023
|
+
* `conditions.occupancy`). A third case is the LEGACY audio-class rule: an
|
|
17024
|
+
* `immediate` rule whose classes are all `audio-*` and which declares no
|
|
17025
|
+
* `audio` condition fires on a classified episode and on nothing else.
|
|
17026
|
+
*/
|
|
17027
|
+
function testTriggerForRule(rule) {
|
|
17028
|
+
if (rule.delivery === "system-event") return "system-event";
|
|
17029
|
+
if (rule.delivery === "track-end") return "track-end";
|
|
17030
|
+
if (rule.delivery === "package-event") return "package-event";
|
|
17031
|
+
if (rule.delivery === "device-event") return rule.conditions.occupancy !== void 0 ? "occupancy-event" : "device-event";
|
|
17032
|
+
if (rule.conditions.audio !== void 0) return "audio-window";
|
|
17033
|
+
const classes = rule.conditions.classes ?? [];
|
|
17034
|
+
if (classes.length > 0 && classes.every((c) => c.trim().toLowerCase().startsWith("audio-"))) return "audio-event";
|
|
17035
|
+
return "object-event";
|
|
17036
|
+
}
|
|
17037
|
+
/** Reserved device id for an event with no camera (mirrors the engine's). */
|
|
17038
|
+
var NC_SYSTEM_DEVICE_ID = 0;
|
|
17039
|
+
/** The grid a schedule is authored on — the same one `window-bounds.ts` scans. */
|
|
17040
|
+
var SCHEDULE_SCAN_STEP_MS = 6e4;
|
|
17041
|
+
/** How far back a schedule scan may look for an active minute (one week + a day). */
|
|
17042
|
+
var SCHEDULE_SCAN_LOOKBACK_MS = 8 * 864e5;
|
|
17043
|
+
/**
|
|
17044
|
+
* Nudge a threshold to a value that PASSES it, and that reads like something a
|
|
17045
|
+
* detector would produce. Clamped to 1 so a rule with a floor of 1 still gets a
|
|
17046
|
+
* legal value.
|
|
17047
|
+
*/
|
|
17048
|
+
function justAbove(floor) {
|
|
17049
|
+
return Math.min(1, Math.round((floor + .05) * 100) / 100);
|
|
17050
|
+
}
|
|
17051
|
+
/**
|
|
17052
|
+
* A timestamp the rule's SCHEDULE is active at — `now` when it already is.
|
|
17053
|
+
*
|
|
17054
|
+
* Scans BACKWARDS on the schedule's own minute grid through `isScheduleActive`,
|
|
17055
|
+
* the single authority on what a window means (midnight crossing, `invert`,
|
|
17056
|
+
* IANA timezone). Backwards rather than forwards because a test must fire NOW:
|
|
17057
|
+
* a future instant would be a notification about something that has not
|
|
17058
|
+
* happened, and the outbox would deliver it immediately anyway.
|
|
17059
|
+
*
|
|
17060
|
+
* `null` when the schedule has no active minute in the lookback — a rule that
|
|
17061
|
+
* is currently unfireable, which is a note rather than a silent failure.
|
|
17062
|
+
*/
|
|
17063
|
+
function scheduleActiveAt(schedule, now) {
|
|
17064
|
+
if (schedule === void 0) return now;
|
|
17065
|
+
if (require_dist.isScheduleActive(schedule, now)) return now;
|
|
17066
|
+
const floor = now - SCHEDULE_SCAN_LOOKBACK_MS;
|
|
17067
|
+
for (let t = now - SCHEDULE_SCAN_STEP_MS; t >= floor; t -= SCHEDULE_SCAN_STEP_MS) if (require_dist.isScheduleActive(schedule, t)) return t;
|
|
17068
|
+
return null;
|
|
17069
|
+
}
|
|
17070
|
+
/**
|
|
17071
|
+
* The bbox that satisfies a `customZones` polygon: the polygon's own bounding
|
|
17072
|
+
* box, clamped to the frame.
|
|
17073
|
+
*
|
|
17074
|
+
* The engine's test is `bboxPolygonOverlap(bbox, points) > 0`, and a polygon's
|
|
17075
|
+
* bounding box always overlaps it (a polygon with area is contained in its own
|
|
17076
|
+
* box). The admin simulator's centred half-frame box does NOT — a polygon drawn
|
|
17077
|
+
* in a corner fails it, and the operator reads a working rule as broken.
|
|
17078
|
+
*/
|
|
17079
|
+
function bboxForPolygon(points) {
|
|
17080
|
+
if (points.length === 0) return void 0;
|
|
17081
|
+
const xs = points.map((p) => p.x);
|
|
17082
|
+
const ys = points.map((p) => p.y);
|
|
17083
|
+
const minX = Math.max(0, Math.min(...xs));
|
|
17084
|
+
const minY = Math.max(0, Math.min(...ys));
|
|
17085
|
+
const maxX = Math.min(1, Math.max(...xs));
|
|
17086
|
+
const maxY = Math.min(1, Math.max(...ys));
|
|
17087
|
+
const w = Math.max(.01, maxX - minX);
|
|
17088
|
+
const h = Math.max(.01, maxY - minY);
|
|
17089
|
+
return {
|
|
17090
|
+
x: minX,
|
|
17091
|
+
y: minY,
|
|
17092
|
+
w: Math.min(1 - minX, w),
|
|
17093
|
+
h: Math.min(1 - minY, h)
|
|
17094
|
+
};
|
|
17095
|
+
}
|
|
17096
|
+
/**
|
|
17097
|
+
* The window that answers an audio condition.
|
|
17098
|
+
*
|
|
17099
|
+
* Every spec field is copied from the CONDITION, because `matchesAudio`
|
|
17100
|
+
* compares them for equality — this is the pairing rule that stops a hub with
|
|
17101
|
+
* two sound rules on one camera answering one rule with the other's evidence.
|
|
17102
|
+
* `labels` are normalized and sorted exactly as `audioSpecFromCondition` does,
|
|
17103
|
+
* or the elementwise comparison fails on ordering alone.
|
|
17104
|
+
*/
|
|
17105
|
+
function audioWindowFor(condition) {
|
|
17106
|
+
const labels = condition.labels !== void 0 && condition.labels.length > 0 ? [...new Set(condition.labels.map(normalizeAudioLabel))].sort() : void 0;
|
|
17107
|
+
const samples = Math.max(2, condition.samplingSeconds);
|
|
17108
|
+
const hits = Math.max(1, Math.ceil(samples * condition.hitPercent / 100));
|
|
17109
|
+
return {
|
|
17110
|
+
hitPercent: Math.min(100, Math.round(hits / samples * 100)),
|
|
17111
|
+
samplingSeconds: condition.samplingSeconds,
|
|
17112
|
+
samples,
|
|
17113
|
+
hits,
|
|
17114
|
+
...condition.dbThreshold !== void 0 ? {
|
|
17115
|
+
dbThreshold: condition.dbThreshold,
|
|
17116
|
+
peakDbfs: Math.min(0, condition.dbThreshold + 6)
|
|
17117
|
+
} : {},
|
|
17118
|
+
...labels !== void 0 ? { specLabels: labels } : {},
|
|
17119
|
+
labels: labels ?? []
|
|
17120
|
+
};
|
|
17121
|
+
}
|
|
17122
|
+
/**
|
|
17123
|
+
* The edge that satisfies an occupancy condition.
|
|
17124
|
+
*
|
|
17125
|
+
* Each op has exactly ONE edge that passes it (see `matchesOccupancy`): the
|
|
17126
|
+
* count arrives AT the threshold from below, falls to zero from it, or falls TO
|
|
17127
|
+
* the threshold from one above.
|
|
17128
|
+
*/
|
|
17129
|
+
function occupancyEdgeFor(condition) {
|
|
17130
|
+
const threshold = Math.max(1, Math.round(condition.count));
|
|
17131
|
+
const shared = {
|
|
17132
|
+
...condition.zoneId !== void 0 ? { zoneId: condition.zoneId } : {},
|
|
17133
|
+
...condition.className !== void 0 ? { className: condition.className } : {}
|
|
17134
|
+
};
|
|
17135
|
+
if (condition.op === "became-free") return {
|
|
17136
|
+
...shared,
|
|
17137
|
+
count: 0,
|
|
17138
|
+
previousCount: threshold,
|
|
17139
|
+
occupied: false,
|
|
17140
|
+
threshold
|
|
17141
|
+
};
|
|
17142
|
+
if (condition.op === "<=") {
|
|
17143
|
+
const count = Math.max(0, Math.round(condition.count));
|
|
17144
|
+
return {
|
|
17145
|
+
...shared,
|
|
17146
|
+
count,
|
|
17147
|
+
previousCount: count + 1,
|
|
17148
|
+
occupied: false,
|
|
17149
|
+
threshold: count + 1
|
|
17150
|
+
};
|
|
17151
|
+
}
|
|
17152
|
+
return {
|
|
17153
|
+
...shared,
|
|
17154
|
+
count: threshold,
|
|
17155
|
+
previousCount: Math.max(0, threshold - 1),
|
|
17156
|
+
occupied: true,
|
|
17157
|
+
threshold
|
|
17158
|
+
};
|
|
17159
|
+
}
|
|
17160
|
+
/**
|
|
17161
|
+
* The catalog keys a synthetic system event of this kind carries.
|
|
17162
|
+
*
|
|
17163
|
+
* MIRRORS `incomingFromSystemEvent`'s key derivation, and the mirror is pinned
|
|
17164
|
+
* by a spec that asserts every key exists in the shipped English bundle — a key
|
|
17165
|
+
* nothing resolves renders an EMPTY notification, which is the one failure a
|
|
17166
|
+
* tester must not be able to produce. The BODY VARIANTS the intake chooses from
|
|
17167
|
+
* the payload (`.profiled`, `.agent`, `.onNode`) are deliberately not chosen
|
|
17168
|
+
* here: the plain variant is the one every kind has.
|
|
17169
|
+
*/
|
|
17170
|
+
function systemEventKeys(kind) {
|
|
17171
|
+
if (kind === "node-online" || kind === "node-offline") return {
|
|
17172
|
+
titleKey: `system.${kind}.title`,
|
|
17173
|
+
bodyKey: `system.${kind}.body.agent`
|
|
17174
|
+
};
|
|
17175
|
+
if (kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-arm-refused") return {
|
|
17176
|
+
titleKey: `system.${kind}.title.${TEST_ALARM_MODE}`,
|
|
17177
|
+
bodyKey: `system.${kind}.body`
|
|
17178
|
+
};
|
|
17179
|
+
return {
|
|
17180
|
+
titleKey: `system.${kind}.title`,
|
|
17181
|
+
bodyKey: `system.${kind}.body`
|
|
17182
|
+
};
|
|
17183
|
+
}
|
|
17184
|
+
/** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
|
|
17185
|
+
var TEST_ALARM_MODE = "away";
|
|
17186
|
+
/** The dedup subject the live intake stamps for this kind — same families. */
|
|
17187
|
+
function systemEventSubject(kind, scope) {
|
|
17188
|
+
if (kind === "device-disabled" || kind === "device-enabled") return `device-switch:${scope.deviceId ?? 0}`;
|
|
17189
|
+
if (kind === "device-online" || kind === "device-offline") return `device:${scope.deviceId ?? 0}`;
|
|
17190
|
+
if (kind === "stream-online" || kind === "stream-offline") return `stream:${scope.deviceId ?? 0}`;
|
|
17191
|
+
if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
|
|
17192
|
+
if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
|
|
17193
|
+
return `alarm:${scope.deviceId ?? 0}`;
|
|
17194
|
+
}
|
|
17195
|
+
/**
|
|
17196
|
+
* The class the subject carries, chosen so it PASSES the rule's class
|
|
17197
|
+
* conditions rather than merely existing.
|
|
17198
|
+
*
|
|
17199
|
+
* A macro selection (`vehicle`) is written verbatim: the engine expands macro →
|
|
17200
|
+
* leaves, so the macro itself is always in its own expansion. The exclude list
|
|
17201
|
+
* is then honoured — a class that is both wanted and vetoed is a rule that can
|
|
17202
|
+
* never fire, and the plan must not paper over it.
|
|
17203
|
+
*/
|
|
17204
|
+
function classNamesFor(conditions) {
|
|
17205
|
+
const excluded = new Set((conditions.classesExclude ?? []).map((c) => c.trim().toLowerCase()));
|
|
17206
|
+
const first = (conditions.classes ?? []).filter((c) => !excluded.has(c.trim().toLowerCase()))[0] ?? conditions.classes?.[0];
|
|
17207
|
+
if (first !== void 0) return [first];
|
|
17208
|
+
return excluded.has("person") ? [] : ["person"];
|
|
17209
|
+
}
|
|
17210
|
+
/** The label that satisfies the rule's identity / plate / label matchers. */
|
|
17211
|
+
function labelFor(conditions) {
|
|
17212
|
+
return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
|
|
17213
|
+
}
|
|
17214
|
+
/**
|
|
17215
|
+
* Plan the injection that fires this rule.
|
|
17216
|
+
*
|
|
17217
|
+
* Every condition the engine reads off the SUBJECT is satisfied here; the two
|
|
17218
|
+
* that read the world are reported in {@link NcRuleTestPlan.notes}.
|
|
17219
|
+
*/
|
|
17220
|
+
function planRuleTest(input) {
|
|
17221
|
+
const { rule, now } = input;
|
|
17222
|
+
const c = rule.conditions;
|
|
17223
|
+
const trigger = testTriggerForRule(rule);
|
|
17224
|
+
const notes = [];
|
|
17225
|
+
const systemScope = {
|
|
17226
|
+
...c.systemEvent?.deviceIds?.[0] !== void 0 ? { deviceId: c.systemEvent.deviceIds[0] } : {},
|
|
17227
|
+
...c.systemEvent?.nodeIds?.[0] !== void 0 ? { nodeId: c.systemEvent.nodeIds[0] } : {},
|
|
17228
|
+
...c.systemEvent?.packageNames?.[0] !== void 0 ? { packageName: c.systemEvent.packageNames[0] } : {}
|
|
17229
|
+
};
|
|
17230
|
+
const scopedDeviceId = trigger === "system-event" ? systemScope.deviceId ?? input.fallbackDeviceId ?? NC_SYSTEM_DEVICE_ID : c.devices?.[0] ?? input.fallbackDeviceId;
|
|
17231
|
+
if (scopedDeviceId === void 0) notes.push({
|
|
17232
|
+
conditionId: "devices",
|
|
17233
|
+
note: "This rule names no camera and none was supplied, so the event is attributed to no device and carries no picture. Scope the rule to a camera, or pick one, to see the notification it would really send."
|
|
17234
|
+
});
|
|
17235
|
+
const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
|
|
17236
|
+
const scheduledAt = scheduleActiveAt(rule.schedule, now);
|
|
17237
|
+
if (scheduledAt === null) notes.push({
|
|
17238
|
+
conditionId: "schedule",
|
|
17239
|
+
note: "This rule’s weekly schedule has no active minute in the last eight days, so the test fires outside it and the schedule condition will refuse the event — exactly as it refuses every real one."
|
|
17240
|
+
});
|
|
17241
|
+
else if (scheduledAt !== now) notes.push({
|
|
17242
|
+
conditionId: "schedule",
|
|
17243
|
+
note: "The rule is outside its schedule right now, so the event is stamped with the most recent instant inside a window. The notification therefore reports a past time."
|
|
17244
|
+
});
|
|
17245
|
+
if (c.deviceState !== void 0) notes.push({
|
|
17246
|
+
conditionId: "deviceState",
|
|
17247
|
+
note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
|
|
17248
|
+
});
|
|
17249
|
+
if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
|
|
17250
|
+
conditionId: "customZones",
|
|
17251
|
+
note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
|
|
17252
|
+
});
|
|
17253
|
+
const zones = c.zones?.ids ?? [];
|
|
17254
|
+
const label = labelFor(c);
|
|
17255
|
+
const bbox = c.customZones !== void 0 && c.customZones.length > 0 ? bboxForPolygon(c.customZones[0]?.points ?? []) : void 0;
|
|
17256
|
+
const audioWindow = trigger === "audio-window" && c.audio !== void 0 ? audioWindowFor(c.audio) : void 0;
|
|
17257
|
+
const classNames = trigger === "audio-window" ? (audioWindow?.labels ?? []).map((l) => `audio-${l}`) : trigger === "occupancy-event" ? [c.occupancy?.className ?? "occupancy"] : trigger === "device-event" || trigger === "system-event" ? [] : classNamesFor(c);
|
|
17258
|
+
const systemEventKind = c.systemEvent?.kinds[0];
|
|
17259
|
+
if (trigger === "system-event" && systemEventKind === void 0) notes.push({
|
|
17260
|
+
conditionId: "systemEvent",
|
|
17261
|
+
note: "This rule names no system event, so nothing can be injected for it — the hub refuses such a rule at save time and the engine matches nothing."
|
|
17262
|
+
});
|
|
17263
|
+
return {
|
|
17264
|
+
trigger,
|
|
17265
|
+
input: {
|
|
17266
|
+
trigger,
|
|
17267
|
+
deviceId,
|
|
17268
|
+
classNames: [...classNames],
|
|
17269
|
+
...label !== void 0 ? { label } : {},
|
|
17270
|
+
...c.minConfidence !== void 0 ? { confidence: justAbove(c.minConfidence) } : {},
|
|
17271
|
+
...c.minLabelConfidence !== void 0 ? { labelConfidence: justAbove(c.minLabelConfidence) } : {},
|
|
17272
|
+
...c.minImportance !== void 0 ? { importance: justAbove(c.minImportance) } : {},
|
|
17273
|
+
...c.minDwellSeconds !== void 0 ? { dwellSeconds: Math.round(c.minDwellSeconds) + 1 } : {},
|
|
17274
|
+
zones: [...zones],
|
|
17275
|
+
...trigger === "object-event" && c.crossing === "exit" ? { crossing: {
|
|
17276
|
+
zoneId: zones[0] ?? "zone",
|
|
17277
|
+
direction: "exit"
|
|
17278
|
+
} } : {},
|
|
17279
|
+
...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
|
|
17280
|
+
...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
|
|
17281
|
+
...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
|
|
17282
|
+
...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
|
|
17283
|
+
...bbox !== void 0 ? { bbox } : {},
|
|
17284
|
+
...trigger === "occupancy-event" && c.occupancy !== void 0 ? { occupancy: occupancyEdgeFor(c.occupancy) } : {},
|
|
17285
|
+
...audioWindow !== void 0 ? { audioWindow } : {},
|
|
17286
|
+
...trigger === "system-event" && systemEventKind !== void 0 ? { systemEvent: {
|
|
17287
|
+
kind: systemEventKind,
|
|
17288
|
+
subject: systemEventSubject(systemEventKind, systemScope),
|
|
17289
|
+
...systemEventKeys(systemEventKind),
|
|
17290
|
+
textParams: {
|
|
17291
|
+
deviceId: String(systemScope.deviceId ?? deviceId),
|
|
17292
|
+
deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
|
|
17293
|
+
nodeId: systemScope.nodeId ?? "hub",
|
|
17294
|
+
packageName: systemScope.packageName ?? "@camstack/server",
|
|
17295
|
+
currentVersion: "0.0.0",
|
|
17296
|
+
latestVersion: "0.0.0",
|
|
17297
|
+
profile: "low",
|
|
17298
|
+
seconds: "30"
|
|
17299
|
+
},
|
|
17300
|
+
...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
|
|
17301
|
+
...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
|
|
17302
|
+
...systemScope.nodeId !== void 0 ? { nodeId: systemScope.nodeId } : {},
|
|
17303
|
+
...systemScope.packageName !== void 0 ? { packageName: systemScope.packageName } : {}
|
|
17304
|
+
} } : {},
|
|
17305
|
+
timestamp: scheduledAt ?? now,
|
|
17306
|
+
onlyRuleId: rule.id,
|
|
17307
|
+
bypassCooldown: true,
|
|
17308
|
+
bypassMute: true,
|
|
17309
|
+
runActions: false,
|
|
17310
|
+
still: "live"
|
|
17311
|
+
},
|
|
17312
|
+
notes
|
|
17313
|
+
};
|
|
17314
|
+
}
|
|
17315
|
+
//#endregion
|
|
15155
17316
|
//#region src/notification-center/nc-actions.ts
|
|
15156
17317
|
/**
|
|
15157
17318
|
* nc-actions — the `pipeline-analytics` rule-bridge action catalog.
|
|
@@ -15251,6 +17412,58 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
15251
17412
|
auth: "admin",
|
|
15252
17413
|
caller: "required"
|
|
15253
17414
|
}),
|
|
17415
|
+
/**
|
|
17416
|
+
* FIRE THIS RULE NOW — the per-rule Test button.
|
|
17417
|
+
*
|
|
17418
|
+
* `nc.injectTestEvent` asks the operator to describe an event; this asks the
|
|
17419
|
+
* RULE. The server plans the synthetic subject from the rule's own conditions
|
|
17420
|
+
* (`rule-test-plan.ts`) and hands it to exactly the same injection, so the
|
|
17421
|
+
* notification travels the whole real path: `evaluateRule` → durable outbox →
|
|
17422
|
+
* the ordinary drain → the ordinary media ladders → the notifier. It arrives
|
|
17423
|
+
* on the phone, which is the entire point.
|
|
17424
|
+
*
|
|
17425
|
+
* It BYPASSES the cooldown and the per-camera mute, deliberately: the
|
|
17426
|
+
* operator pressed a button and must receive the notification, not a report
|
|
17427
|
+
* that a window from ten minutes ago swallowed it. Neither window is
|
|
17428
|
+
* CONSUMED, so the next real fire is unaffected. It never runs the rule's
|
|
17429
|
+
* action sequences — those unlock doors, and "does this notify" is not a
|
|
17430
|
+
* request to open a gate.
|
|
17431
|
+
*
|
|
17432
|
+
* `auth: 'admin'` for the same reason as the injection it wraps: it delivers
|
|
17433
|
+
* to every target the rule addresses, on other people's phones.
|
|
17434
|
+
*/
|
|
17435
|
+
"nc.testRule": require_dist.customAction(require_dist.object({
|
|
17436
|
+
ruleId: require_dist.string(),
|
|
17437
|
+
/** The camera to attribute the event to when the rule scopes none. */
|
|
17438
|
+
deviceId: require_dist.number().int().optional()
|
|
17439
|
+
}), NcTestEventResultSchema.extend({
|
|
17440
|
+
/** The subject kind the rule was tested with — the answer to "why was my
|
|
17441
|
+
* rule not a candidate". */
|
|
17442
|
+
trigger: require_dist.string(),
|
|
17443
|
+
/** What the plan could not reproduce (a device-state gate, a polygon). */
|
|
17444
|
+
planNotes: require_dist.array(require_dist.object({
|
|
17445
|
+
conditionId: require_dist.string(),
|
|
17446
|
+
note: require_dist.string()
|
|
17447
|
+
}))
|
|
17448
|
+
}), {
|
|
17449
|
+
kind: "mutation",
|
|
17450
|
+
auth: "admin",
|
|
17451
|
+
caller: "required"
|
|
17452
|
+
}),
|
|
17453
|
+
/**
|
|
17454
|
+
* What this rule is doing RIGHT NOW — a discriminated union per rule kind.
|
|
17455
|
+
*
|
|
17456
|
+
* Answers the question a rule LIST structurally cannot: an occupancy rule's
|
|
17457
|
+
* subject is a live count, and a timelapse rule's output arrives hours after
|
|
17458
|
+
* the window it is about. Everything returned is DERIVED from the watcher's
|
|
17459
|
+
* mirror, the live snapshot and the stores — nothing is a second copy of
|
|
17460
|
+
* state that could disagree with them.
|
|
17461
|
+
*
|
|
17462
|
+
* `caller: 'required'` and not admin-only: a personal rule's owner may read
|
|
17463
|
+
* the status of their own rule, and the handler refuses a rule they cannot
|
|
17464
|
+
* see rather than returning a redacted one.
|
|
17465
|
+
*/
|
|
17466
|
+
"nc.getRuleStatus": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), NcRuleStatusSchema, { caller: "required" }),
|
|
15254
17467
|
"nc.listTimelapseRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
|
|
15255
17468
|
/**
|
|
15256
17469
|
* ONE action for create AND update, unlike the detection-rule pair.
|
|
@@ -15382,7 +17595,23 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
15382
17595
|
}), NcTextCatalogViewSchema, {
|
|
15383
17596
|
kind: "mutation",
|
|
15384
17597
|
auth: "admin"
|
|
15385
|
-
})
|
|
17598
|
+
}),
|
|
17599
|
+
/**
|
|
17600
|
+
* One page of artefacts, newest `capturedAt` first.
|
|
17601
|
+
*
|
|
17602
|
+
* `deviceIds` filters on the camera an artefact is FILED under. A digest
|
|
17603
|
+
* spans cameras and is filed under its rule's first one (the same camera its
|
|
17604
|
+
* outbox row uses); to ask "everything this digest produced" use `ruleId`.
|
|
17605
|
+
*/
|
|
17606
|
+
"nc.listArtifacts": require_dist.customAction(NcArtifactQuerySchema, require_dist.object({
|
|
17607
|
+
artifacts: require_dist.array(NcArtifactRecordSchema),
|
|
17608
|
+
/** Rows matching the filter, not rows on this page. */
|
|
17609
|
+
total: require_dist.number().int().nonnegative(),
|
|
17610
|
+
hasMore: require_dist.boolean()
|
|
17611
|
+
}), { caller: "required" }),
|
|
17612
|
+
/** One artefact's metadata. `null` for unknown, expired AND not-yours alike —
|
|
17613
|
+
* a surface that distinguishes them reports what other people own. */
|
|
17614
|
+
"nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
|
|
15386
17615
|
});
|
|
15387
17616
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
15388
17617
|
function requireCaller(caller) {
|
|
@@ -15432,6 +17661,12 @@ function makeNcActionHandlers(deps) {
|
|
|
15432
17661
|
if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
|
|
15433
17662
|
return sum;
|
|
15434
17663
|
};
|
|
17664
|
+
/** The artefact index, or an honest refusal. */
|
|
17665
|
+
const requireArtifacts = () => {
|
|
17666
|
+
const index = deps.artifacts;
|
|
17667
|
+
if (index === void 0) throw new Error("this node does not host the notification artifact index — use the hub");
|
|
17668
|
+
return index;
|
|
17669
|
+
};
|
|
15435
17670
|
/** The text catalog editor, or an honest refusal. */
|
|
15436
17671
|
const requireTexts = () => {
|
|
15437
17672
|
const editor = deps.texts;
|
|
@@ -15562,6 +17797,54 @@ function makeNcActionHandlers(deps) {
|
|
|
15562
17797
|
});
|
|
15563
17798
|
return inject(parsed);
|
|
15564
17799
|
},
|
|
17800
|
+
"nc.testRule": async (input, caller) => {
|
|
17801
|
+
const c = requireCaller(caller);
|
|
17802
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to test a rule");
|
|
17803
|
+
const inject = deps.injectTestEvent;
|
|
17804
|
+
if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
|
|
17805
|
+
const rule = assertRuleVisible(input.ruleId, c);
|
|
17806
|
+
const plan = planRuleTest({
|
|
17807
|
+
rule,
|
|
17808
|
+
now: Date.now(),
|
|
17809
|
+
...input.deviceId !== void 0 ? { fallbackDeviceId: input.deviceId } : {}
|
|
17810
|
+
});
|
|
17811
|
+
deps.logger.info("rule test requested", {
|
|
17812
|
+
tags: { deviceId: plan.input.deviceId },
|
|
17813
|
+
meta: {
|
|
17814
|
+
by: c.userId,
|
|
17815
|
+
ruleId: rule.id,
|
|
17816
|
+
ruleName: rule.name,
|
|
17817
|
+
trigger: plan.trigger,
|
|
17818
|
+
bypassCooldown: true,
|
|
17819
|
+
bypassMute: true,
|
|
17820
|
+
planNotes: plan.notes.map((n) => n.conditionId)
|
|
17821
|
+
}
|
|
17822
|
+
});
|
|
17823
|
+
return {
|
|
17824
|
+
...await inject(plan.input),
|
|
17825
|
+
trigger: plan.trigger,
|
|
17826
|
+
planNotes: plan.notes.map((n) => ({
|
|
17827
|
+
conditionId: n.conditionId,
|
|
17828
|
+
note: n.note
|
|
17829
|
+
}))
|
|
17830
|
+
};
|
|
17831
|
+
},
|
|
17832
|
+
"nc.getRuleStatus": async (input, caller) => {
|
|
17833
|
+
const c = requireCaller(caller);
|
|
17834
|
+
const status = deps.ruleStatus;
|
|
17835
|
+
if (status === void 0) throw new Error("this node does not host the notification centre — ask the hub");
|
|
17836
|
+
const detection = deps.ruleStore.get(input.ruleId);
|
|
17837
|
+
if (detection !== null) {
|
|
17838
|
+
assertRuleVisible(input.ruleId, c);
|
|
17839
|
+
return status.forRule(detection);
|
|
17840
|
+
}
|
|
17841
|
+
const timelapse = deps.timelapse?.store.get(input.ruleId) ?? null;
|
|
17842
|
+
if (timelapse !== null) {
|
|
17843
|
+
if (!c.isAdmin && timelapse.ownerUserId !== c.userId) throw new Error(`forbidden: timelapse rule not owned: ${input.ruleId}`);
|
|
17844
|
+
return status.forTimelapseRule(timelapse);
|
|
17845
|
+
}
|
|
17846
|
+
throw new Error(`rule not found: ${input.ruleId}`);
|
|
17847
|
+
},
|
|
15565
17848
|
"nc.listTimelapseRules": async (_input, caller) => {
|
|
15566
17849
|
const c = requireCaller(caller);
|
|
15567
17850
|
const tl = requireTimelapse();
|
|
@@ -15743,7 +18026,26 @@ function makeNcActionHandlers(deps) {
|
|
|
15743
18026
|
return input.language === void 0 ? texts.view() : texts.view(input.language);
|
|
15744
18027
|
},
|
|
15745
18028
|
"nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
|
|
15746
|
-
"nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides)
|
|
18029
|
+
"nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
|
|
18030
|
+
"nc.listArtifacts": async (input, caller) => {
|
|
18031
|
+
const c = requireCaller(caller);
|
|
18032
|
+
const page = await requireArtifacts().list(input, {
|
|
18033
|
+
userId: c.userId,
|
|
18034
|
+
isAdmin: c.isAdmin
|
|
18035
|
+
});
|
|
18036
|
+
return {
|
|
18037
|
+
artifacts: [...page.artifacts],
|
|
18038
|
+
total: page.total,
|
|
18039
|
+
hasMore: page.hasMore
|
|
18040
|
+
};
|
|
18041
|
+
},
|
|
18042
|
+
"nc.getArtifact": async (input, caller) => {
|
|
18043
|
+
const c = requireCaller(caller);
|
|
18044
|
+
return { artifact: await requireArtifacts().get(input.artifactId, {
|
|
18045
|
+
userId: c.userId,
|
|
18046
|
+
isAdmin: c.isAdmin
|
|
18047
|
+
}) };
|
|
18048
|
+
}
|
|
15747
18049
|
};
|
|
15748
18050
|
}
|
|
15749
18051
|
//#endregion
|
|
@@ -15797,7 +18099,8 @@ var PREVIEW_FALLBACK_NAME = "timelapse-preview.jpg";
|
|
|
15797
18099
|
* member has a logged reason. It never rejects.
|
|
15798
18100
|
*/
|
|
15799
18101
|
function buildTimelapseArtifactPublisher(ports) {
|
|
15800
|
-
return async (
|
|
18102
|
+
return async (request) => {
|
|
18103
|
+
const { exportId, deviceId, previewText, previewMode } = request;
|
|
15801
18104
|
const tags = { deviceId };
|
|
15802
18105
|
const none = {
|
|
15803
18106
|
video: null,
|
|
@@ -15828,15 +18131,32 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15828
18131
|
return none;
|
|
15829
18132
|
}
|
|
15830
18133
|
const mp4 = node_buffer.Buffer.from(read.base64, "base64");
|
|
18134
|
+
const capturedAt = request.capturedAt ?? Date.now();
|
|
18135
|
+
const common = {
|
|
18136
|
+
deviceId,
|
|
18137
|
+
deviceIds: [deviceId],
|
|
18138
|
+
capturedAt,
|
|
18139
|
+
...request.ruleId !== void 0 ? { ruleId: request.ruleId } : {},
|
|
18140
|
+
...request.ruleName !== void 0 ? { ruleName: request.ruleName } : {},
|
|
18141
|
+
...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
|
|
18142
|
+
};
|
|
15831
18143
|
const preview = await publishPreview(ports, {
|
|
15832
18144
|
mp4,
|
|
15833
18145
|
deviceId,
|
|
15834
18146
|
tags,
|
|
15835
18147
|
previewText,
|
|
15836
|
-
previewMode: previewMode ?? "image"
|
|
18148
|
+
previewMode: previewMode ?? "image",
|
|
18149
|
+
meta: {
|
|
18150
|
+
...common,
|
|
18151
|
+
kind: "timelapse-preview"
|
|
18152
|
+
}
|
|
15837
18153
|
});
|
|
15838
18154
|
try {
|
|
15839
|
-
const url = await ports.publish(mp4, read.contentType
|
|
18155
|
+
const { url } = await ports.publish(mp4, read.contentType, {
|
|
18156
|
+
...common,
|
|
18157
|
+
kind: "timelapse",
|
|
18158
|
+
...preview.artifactId !== null ? { posterArtifactId: preview.artifactId } : {}
|
|
18159
|
+
});
|
|
15840
18160
|
if (url === null) {
|
|
15841
18161
|
ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
|
|
15842
18162
|
tags,
|
|
@@ -15844,7 +18164,7 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15844
18164
|
});
|
|
15845
18165
|
return {
|
|
15846
18166
|
video: null,
|
|
15847
|
-
preview
|
|
18167
|
+
preview: preview.ref
|
|
15848
18168
|
};
|
|
15849
18169
|
}
|
|
15850
18170
|
ports.logger.info("timelapse artifact published", {
|
|
@@ -15853,7 +18173,7 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15853
18173
|
exportId,
|
|
15854
18174
|
bytes: read.bytes,
|
|
15855
18175
|
name: read.name,
|
|
15856
|
-
preview: preview !== null
|
|
18176
|
+
preview: preview.ref !== null
|
|
15857
18177
|
}
|
|
15858
18178
|
});
|
|
15859
18179
|
return {
|
|
@@ -15862,7 +18182,7 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15862
18182
|
mime: read.contentType,
|
|
15863
18183
|
name: read.name
|
|
15864
18184
|
},
|
|
15865
|
-
preview
|
|
18185
|
+
preview: preview.ref
|
|
15866
18186
|
};
|
|
15867
18187
|
} catch (err) {
|
|
15868
18188
|
ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
|
|
@@ -15874,18 +18194,22 @@ function buildTimelapseArtifactPublisher(ports) {
|
|
|
15874
18194
|
});
|
|
15875
18195
|
return {
|
|
15876
18196
|
video: null,
|
|
15877
|
-
preview
|
|
18197
|
+
preview: preview.ref
|
|
15878
18198
|
};
|
|
15879
18199
|
}
|
|
15880
18200
|
};
|
|
15881
18201
|
}
|
|
18202
|
+
var NO_PREVIEW = {
|
|
18203
|
+
ref: null,
|
|
18204
|
+
artifactId: null
|
|
18205
|
+
};
|
|
15882
18206
|
/**
|
|
15883
18207
|
* The preview half. Best-effort at every step and never throws: the picture is
|
|
15884
18208
|
* an enhancement, the video is the notification.
|
|
15885
18209
|
*/
|
|
15886
18210
|
async function publishPreview(ports, input) {
|
|
15887
18211
|
const render = ports.renderPreview;
|
|
15888
|
-
if (render === void 0 || input.previewText === void 0) return
|
|
18212
|
+
if (render === void 0 || input.previewText === void 0) return NO_PREVIEW;
|
|
15889
18213
|
try {
|
|
15890
18214
|
const rendered = await render({
|
|
15891
18215
|
mp4: input.mp4,
|
|
@@ -15893,23 +18217,29 @@ async function publishPreview(ports, input) {
|
|
|
15893
18217
|
deviceId: input.deviceId,
|
|
15894
18218
|
mode: input.previewMode
|
|
15895
18219
|
});
|
|
15896
|
-
if (rendered === null) return
|
|
15897
|
-
const url = await ports.publish(rendered.bytes, rendered.mime);
|
|
18220
|
+
if (rendered === null) return NO_PREVIEW;
|
|
18221
|
+
const { url, artifactId } = await ports.publish(rendered.bytes, rendered.mime, input.meta);
|
|
15898
18222
|
if (url === null) {
|
|
15899
18223
|
ports.logger.warn("timelapse preview not published: no reachable public endpoint", { tags: input.tags });
|
|
15900
|
-
return
|
|
18224
|
+
return {
|
|
18225
|
+
ref: null,
|
|
18226
|
+
artifactId
|
|
18227
|
+
};
|
|
15901
18228
|
}
|
|
15902
18229
|
return {
|
|
15903
|
-
|
|
15904
|
-
|
|
15905
|
-
|
|
18230
|
+
ref: {
|
|
18231
|
+
url,
|
|
18232
|
+
mime: rendered.mime,
|
|
18233
|
+
name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
|
|
18234
|
+
},
|
|
18235
|
+
artifactId
|
|
15906
18236
|
};
|
|
15907
18237
|
} catch (err) {
|
|
15908
18238
|
ports.logger.warn("timelapse preview not published: publish threw", {
|
|
15909
18239
|
tags: input.tags,
|
|
15910
18240
|
meta: { error: String(err) }
|
|
15911
18241
|
});
|
|
15912
|
-
return
|
|
18242
|
+
return NO_PREVIEW;
|
|
15913
18243
|
}
|
|
15914
18244
|
}
|
|
15915
18245
|
/** Title size as a fraction of the frame WIDTH — so it reads the same on a
|
|
@@ -16396,6 +18726,10 @@ function startAlarmPanelConvergence(ports) {
|
|
|
16396
18726
|
* `data-plane-access.spec.ts` asserts they still match the constants their
|
|
16397
18727
|
* handlers register with — a mirror nothing checks is a lie waiting to happen.
|
|
16398
18728
|
*/
|
|
18729
|
+
/** The artefact read route's prefix, imported rather than mirrored: it is
|
|
18730
|
+
* ALSO the prefix `artifactDownloadPath` writes into every index row, and two
|
|
18731
|
+
* copies of that string is a 404 nobody would find. */
|
|
18732
|
+
var NC_ARTIFACT_READ_PREFIX_REF = NC_ARTIFACT_READ_PREFIX;
|
|
16399
18733
|
var TRAINING_EXPORT_PREFIX_REF = "training-export";
|
|
16400
18734
|
var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
|
|
16401
18735
|
var DEBUG_EXPORT_PREFIX_REF = "debug-export";
|
|
@@ -16411,6 +18745,14 @@ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
|
|
|
16411
18745
|
"nc-action": "public",
|
|
16412
18746
|
/** Self-authenticating single-use artifact token (D47). */
|
|
16413
18747
|
"nc-artifact": "public",
|
|
18748
|
+
/**
|
|
18749
|
+
* The VIEWER's read of the same bytes (task #36). One indexed artefact by an
|
|
18750
|
+
* unguessable id the caller was handed by `nc.listArtifacts` — the rule above,
|
|
18751
|
+
* applied unchanged. It is deliberately NOT the `public` signed plane: the
|
|
18752
|
+
* viewer holds a session, and pre-minting a 24 h signed link for every
|
|
18753
|
+
* artefact a user might scroll to is a link-leak surface for no gain.
|
|
18754
|
+
*/
|
|
18755
|
+
[NC_ARTIFACT_READ_PREFIX_REF]: "authenticated",
|
|
16414
18756
|
/** Every frame behind a marked selection — a corpus, not an item. */
|
|
16415
18757
|
[TRAINING_EXPORT_PREFIX_REF]: "admin",
|
|
16416
18758
|
[RETRAIN_EXPORT_PREFIX_REF]: "admin",
|
|
@@ -17533,9 +19875,14 @@ async function recomputeTrackImportance(deps, trackId) {
|
|
|
17533
19875
|
*/
|
|
17534
19876
|
async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
|
|
17535
19877
|
let trackWritten = false;
|
|
19878
|
+
/** The patch the TRACK accepted — the ONE decision the events copy. */
|
|
19879
|
+
let accepted;
|
|
17536
19880
|
try {
|
|
17537
19881
|
const decision = await deps.trackStore?.applyLabelCandidate(trackId, candidate);
|
|
17538
|
-
if (decision?.kind === "write")
|
|
19882
|
+
if (decision?.kind === "write") {
|
|
19883
|
+
trackWritten = true;
|
|
19884
|
+
accepted = decision.patch;
|
|
19885
|
+
}
|
|
17539
19886
|
if (decision?.kind === "refused") {
|
|
17540
19887
|
const message = decision.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
|
|
17541
19888
|
deps.logger.info(message, {
|
|
@@ -17557,7 +19904,7 @@ async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
|
|
|
17557
19904
|
}
|
|
17558
19905
|
let eventsUpdated = 0;
|
|
17559
19906
|
try {
|
|
17560
|
-
eventsUpdated = await deps.eventStore?.
|
|
19907
|
+
eventsUpdated = accepted !== void 0 ? await deps.eventStore?.projectTrackLabel(trackId, accepted) ?? 0 : 0;
|
|
17561
19908
|
} catch (err) {
|
|
17562
19909
|
deps.logger.warn("event label back-patch failed", {
|
|
17563
19910
|
tags: { deviceId },
|
|
@@ -17930,7 +20277,8 @@ var FaceGalleryProvider = class {
|
|
|
17930
20277
|
score: 1,
|
|
17931
20278
|
stepId: OPERATOR_FACE_STEP_ID,
|
|
17932
20279
|
decidedAt: Date.now(),
|
|
17933
|
-
operatorAuthority: true
|
|
20280
|
+
operatorAuthority: true,
|
|
20281
|
+
identityId
|
|
17934
20282
|
});
|
|
17935
20283
|
this.refreshGallery();
|
|
17936
20284
|
this.safeEmitFaceGalleryChanged({
|
|
@@ -19455,6 +21803,12 @@ var FrameProcessor = class {
|
|
|
19455
21803
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
19456
21804
|
* the addon wires it via {@link setStationaryGate}. */
|
|
19457
21805
|
stationaryGate = null;
|
|
21806
|
+
/**
|
|
21807
|
+
* The label the TRACK currently carries, read (never mirrored) from the
|
|
21808
|
+
* active-track cache. See {@link setTrackLabelState} — and the use site in
|
|
21809
|
+
* `toObjectEvent`, which is the reason it exists.
|
|
21810
|
+
*/
|
|
21811
|
+
trackLabelState = null;
|
|
19458
21812
|
constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
|
|
19459
21813
|
this.deviceId = deviceId;
|
|
19460
21814
|
this.source = source;
|
|
@@ -19471,6 +21825,19 @@ var FrameProcessor = class {
|
|
|
19471
21825
|
this.zones = zones;
|
|
19472
21826
|
}
|
|
19473
21827
|
/**
|
|
21828
|
+
* Wire the reader that answers "what does this track already say it is".
|
|
21829
|
+
*
|
|
21830
|
+
* A READ, not a mirror the late-label path has to remember to feed: the
|
|
21831
|
+
* active-track cache is in-RAM and already the authority every other surface
|
|
21832
|
+
* resolves against, and a mirror that can be forgotten is how a stamped row
|
|
21833
|
+
* and an unstamped one end up in the same track. Absent (tests, and any
|
|
21834
|
+
* caller that has not wired it) ⇒ the emitter behaves exactly as it did
|
|
21835
|
+
* before, stamping only what the current frame decided.
|
|
21836
|
+
*/
|
|
21837
|
+
setTrackLabelState(read) {
|
|
21838
|
+
this.trackLabelState = read;
|
|
21839
|
+
}
|
|
21840
|
+
/**
|
|
19474
21841
|
* How much of a detection's box must lie inside a zone for the zone to be
|
|
19475
21842
|
* stamped onto it (0–1 fraction of the box's own area).
|
|
19476
21843
|
*
|
|
@@ -19799,7 +22166,7 @@ var FrameProcessor = class {
|
|
|
19799
22166
|
const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
|
|
19800
22167
|
const zones = zonesByTrack.get(e.detection.trackId) ?? [];
|
|
19801
22168
|
const crossing = crossingOf(e);
|
|
19802
|
-
const labelPatch = labelPatchByTrack.get(e.detection.trackId);
|
|
22169
|
+
const labelPatch = labelPatchByTrack.get(e.detection.trackId) ?? knownTrackLabelPatch(this.trackLabelState?.(e.detection.trackId));
|
|
19803
22170
|
return {
|
|
19804
22171
|
id: (0, node_crypto.randomUUID)(),
|
|
19805
22172
|
deviceId: this.deviceId,
|
|
@@ -21470,7 +23837,8 @@ var PlateGalleryProvider = class {
|
|
|
21470
23837
|
score: 1,
|
|
21471
23838
|
stepId: OPERATOR_PLATE_STEP_ID,
|
|
21472
23839
|
decidedAt: Date.now(),
|
|
21473
|
-
operatorAuthority: true
|
|
23840
|
+
operatorAuthority: true,
|
|
23841
|
+
identityId: vehicle.id
|
|
21474
23842
|
});
|
|
21475
23843
|
this.refreshGallery();
|
|
21476
23844
|
this.safeEmitPlateGalleryChanged({
|
|
@@ -24845,10 +27213,13 @@ var FaceRecognizer = class {
|
|
|
24845
27213
|
tier: 2,
|
|
24846
27214
|
score: work.matchScore ?? 0,
|
|
24847
27215
|
stepId: FACE_RECOGNIZER_STEP_ID,
|
|
24848
|
-
decidedAt: Date.now()
|
|
27216
|
+
decidedAt: Date.now(),
|
|
27217
|
+
identityId: work.assignedIdentityId
|
|
24849
27218
|
};
|
|
27219
|
+
let accepted;
|
|
24850
27220
|
try {
|
|
24851
|
-
await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
|
|
27221
|
+
const decision = await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
|
|
27222
|
+
if (decision?.kind === "write") accepted = decision.patch;
|
|
24852
27223
|
} catch (err) {
|
|
24853
27224
|
this.deps.logger.warn("track identity label failed", {
|
|
24854
27225
|
tags: { deviceId: input.deviceId },
|
|
@@ -24858,8 +27229,8 @@ var FaceRecognizer = class {
|
|
|
24858
27229
|
}
|
|
24859
27230
|
});
|
|
24860
27231
|
}
|
|
24861
|
-
try {
|
|
24862
|
-
await this.deps.eventStore.
|
|
27232
|
+
if (accepted !== void 0) try {
|
|
27233
|
+
await this.deps.eventStore.projectTrackLabel(work.trackId, accepted);
|
|
24863
27234
|
} catch (err) {
|
|
24864
27235
|
this.deps.logger.warn("event identity label failed", {
|
|
24865
27236
|
tags: { deviceId: input.deviceId },
|
|
@@ -30060,8 +32431,25 @@ var PlateRecognizer = class {
|
|
|
30060
32431
|
* for an implausible read (junk OCR off a distant/oblique plate) — the
|
|
30061
32432
|
* caller must NOT stamp a label then. */
|
|
30062
32433
|
resolveLabel(text, score) {
|
|
32434
|
+
return this.resolveLabelWithVehicle(text, score)?.text ?? null;
|
|
32435
|
+
}
|
|
32436
|
+
/**
|
|
32437
|
+
* {@link resolveLabel} plus the GALLERY ROW it named, when a vehicle claimed
|
|
32438
|
+
* the read.
|
|
32439
|
+
*
|
|
32440
|
+
* The id is what a notification rule matches on — a display name is
|
|
32441
|
+
* renameable and a rule authored on the old spelling goes silently dark
|
|
32442
|
+
* (`LabelAttribution.identityId`). An unclaimed but plausible read still
|
|
32443
|
+
* returns its normalized text with NO `vehicleId`: it is a plate, it is just
|
|
32444
|
+
* nobody's plate.
|
|
32445
|
+
*/
|
|
32446
|
+
resolveLabelWithVehicle(text, score) {
|
|
30063
32447
|
if (!isPlausiblePlateRead(text, score)) return null;
|
|
30064
|
-
|
|
32448
|
+
const matched = this.matchVehicle(text, score);
|
|
32449
|
+
return matched !== null ? {
|
|
32450
|
+
text: matched.name,
|
|
32451
|
+
vehicleId: matched.vehicleId
|
|
32452
|
+
} : { text: normalizePlate(text) };
|
|
30065
32453
|
}
|
|
30066
32454
|
async processFrame(input) {
|
|
30067
32455
|
const minConfidence = input.minConfidence ?? 0;
|
|
@@ -31620,48 +34008,49 @@ var EventStore = class {
|
|
|
31620
34008
|
}
|
|
31621
34009
|
}
|
|
31622
34010
|
/**
|
|
31623
|
-
*
|
|
31624
|
-
*
|
|
31625
|
-
* which is NOT the number of rows, because the write rule is applied PER ROW
|
|
31626
|
-
* against that row's own tiers.
|
|
34011
|
+
* Project the TRACK's label decision onto every object event of that track.
|
|
34012
|
+
* Returns how many rows took it.
|
|
31627
34013
|
*
|
|
31628
|
-
*
|
|
31629
|
-
*
|
|
31630
|
-
*
|
|
31631
|
-
*
|
|
31632
|
-
*
|
|
34014
|
+
* ONE DECISION, EVERY ROW — and that is the correction, not the design it
|
|
34015
|
+
* replaced. This used to re-run `resolveTieredLabelWrite` per row against
|
|
34016
|
+
* that row's own tiers, on the theory that "the events of one track do not
|
|
34017
|
+
* all carry the same label". They do: a track has ONE subject, and every
|
|
34018
|
+
* read surface (the timeline, the event page, a notification body, a rule's
|
|
34019
|
+
* `identities` condition) shows the track's answer. Re-deciding per row meant
|
|
34020
|
+
* a row whose incumbent happened to outscore the track's winner kept a
|
|
34021
|
+
* different word, and the rows drifted apart from the track and from each
|
|
34022
|
+
* other — measured 2026-08-14 as 407 of 437 tracks whose event rows
|
|
34023
|
+
* disagreed with the track they belong to. A notification quoting one row and
|
|
34024
|
+
* a timeline quoting another then told the operator two different names for
|
|
34025
|
+
* the same person.
|
|
34026
|
+
*
|
|
34027
|
+
* The caller passes the patch the TRACK row accepted, so the two can never
|
|
34028
|
+
* disagree by construction. A refused track write projects nothing: nothing
|
|
34029
|
+
* changed, so there is nothing to copy.
|
|
31633
34030
|
*
|
|
31634
34031
|
* Used when face recognition, plate matching or a classifier answers after
|
|
31635
34032
|
* the events were emitted.
|
|
31636
34033
|
*/
|
|
31637
|
-
async
|
|
34034
|
+
async projectTrackLabel(trackId, patch) {
|
|
31638
34035
|
const rows = await this.store.query.query({
|
|
31639
34036
|
collection: OBJECT_EVENTS_COLLECTION,
|
|
31640
34037
|
filter: { where: { trackId } }
|
|
31641
34038
|
});
|
|
34039
|
+
const data = tieredLabelColumnData(patch);
|
|
31642
34040
|
let updated = 0;
|
|
31643
|
-
for (const row of rows) {
|
|
31644
|
-
|
|
31645
|
-
|
|
31646
|
-
|
|
31647
|
-
|
|
31648
|
-
|
|
31649
|
-
|
|
31650
|
-
|
|
31651
|
-
|
|
31652
|
-
|
|
31653
|
-
|
|
31654
|
-
|
|
31655
|
-
|
|
31656
|
-
});
|
|
31657
|
-
updated++;
|
|
31658
|
-
} catch (err) {
|
|
31659
|
-
this.logger.warn("applyLabelCandidateForTrack update failed", { meta: {
|
|
31660
|
-
trackId,
|
|
31661
|
-
eventId: row.id,
|
|
31662
|
-
error: String(err)
|
|
31663
|
-
} });
|
|
31664
|
-
}
|
|
34041
|
+
for (const row of rows) try {
|
|
34042
|
+
await this.store.update.mutate({
|
|
34043
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
34044
|
+
id: row.id,
|
|
34045
|
+
data
|
|
34046
|
+
});
|
|
34047
|
+
updated++;
|
|
34048
|
+
} catch (err) {
|
|
34049
|
+
this.logger.warn("projectTrackLabel update failed", { meta: {
|
|
34050
|
+
trackId,
|
|
34051
|
+
eventId: row.id,
|
|
34052
|
+
error: String(err)
|
|
34053
|
+
} });
|
|
31665
34054
|
}
|
|
31666
34055
|
return updated;
|
|
31667
34056
|
}
|
|
@@ -36076,138 +38465,6 @@ var BindingCache = class {
|
|
|
36076
38465
|
}
|
|
36077
38466
|
};
|
|
36078
38467
|
//#endregion
|
|
36079
|
-
//#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
|
|
36080
|
-
/**
|
|
36081
|
-
* DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
|
|
36082
|
-
* devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
|
|
36083
|
-
* coverage authority ("Away armed, 12 cameras" is false if three of them are
|
|
36084
|
-
* offline or no longer wrapped), read by the notification centre's device
|
|
36085
|
-
* directory (`listDeviceIdentities`).
|
|
36086
|
-
*
|
|
36087
|
-
* WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
|
|
36088
|
-
* rule-reload tick. Answering "who still has detection?" with a fleet-wide
|
|
36089
|
-
* `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
|
|
36090
|
-
* default wrappers skipped` log lines on the live hub (524 devices), forever —
|
|
36091
|
-
* ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
|
|
36092
|
-
* and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
|
|
36093
|
-
* then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
|
|
36094
|
-
* toggle emits that event carrying the device's id and `capName`
|
|
36095
|
-
* (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
|
|
36096
|
-
* keeps the mirror current without ever touching the fleet again.
|
|
36097
|
-
*
|
|
36098
|
-
* ── Destructive-direction discipline (D49) ──────────────────────────────────
|
|
36099
|
-
*
|
|
36100
|
-
* `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
|
|
36101
|
-
* the destructive answer, and the one an operator reads as "my alarm is not
|
|
36102
|
-
* watching that door". A transient `getBindings` blip must never flip a
|
|
36103
|
-
* known-covered camera to uncovered:
|
|
36104
|
-
* • a failed refresh KEEPS the previous value (a read that throws changes
|
|
36105
|
-
* nothing), and
|
|
36106
|
-
* • a single read that would flip a known `true → false` is confirmed by a
|
|
36107
|
-
* concordant SECOND read before it lands — an empty binding list is not
|
|
36108
|
-
* proof the wrapper is gone.
|
|
36109
|
-
* A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
|
|
36110
|
-
* for every id — and unknown never reports a camera as excluded from coverage.
|
|
36111
|
-
*/
|
|
36112
|
-
var DetectionCoverageCache = class {
|
|
36113
|
-
api;
|
|
36114
|
-
logger;
|
|
36115
|
-
/** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
|
|
36116
|
-
state = /* @__PURE__ */ new Map();
|
|
36117
|
-
warm = false;
|
|
36118
|
-
seeding = null;
|
|
36119
|
-
constructor(deps) {
|
|
36120
|
-
this.api = deps.api;
|
|
36121
|
-
this.logger = deps.logger;
|
|
36122
|
-
}
|
|
36123
|
-
/** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
|
|
36124
|
-
isWarm() {
|
|
36125
|
-
return this.warm;
|
|
36126
|
-
}
|
|
36127
|
-
/** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
|
|
36128
|
-
has(deviceId) {
|
|
36129
|
-
return this.state.has(deviceId);
|
|
36130
|
-
}
|
|
36131
|
-
/** The coverage answer, or `undefined` when this id is unknown. No I/O. */
|
|
36132
|
-
get(deviceId) {
|
|
36133
|
-
return this.state.get(deviceId);
|
|
36134
|
-
}
|
|
36135
|
-
/**
|
|
36136
|
-
* Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
|
|
36137
|
-
* warm cache is a no-op, and concurrent callers coalesce onto one read. A
|
|
36138
|
-
* failed seed leaves the mirror COLD so the next tick retries — during that
|
|
36139
|
-
* window every id answers unknown, which never reports a camera as excluded.
|
|
36140
|
-
*/
|
|
36141
|
-
async ensureWarm() {
|
|
36142
|
-
if (this.warm) return;
|
|
36143
|
-
if (this.seeding) return this.seeding;
|
|
36144
|
-
this.seeding = this.seed();
|
|
36145
|
-
try {
|
|
36146
|
-
await this.seeding;
|
|
36147
|
-
} finally {
|
|
36148
|
-
this.seeding = null;
|
|
36149
|
-
}
|
|
36150
|
-
}
|
|
36151
|
-
async seed() {
|
|
36152
|
-
try {
|
|
36153
|
-
const rows = await this.api.deviceManager.getAllBindings.query({});
|
|
36154
|
-
for (const row of rows) {
|
|
36155
|
-
if (this.state.has(row.deviceId)) continue;
|
|
36156
|
-
this.state.set(row.deviceId, hasWrappedDetection(row.entries));
|
|
36157
|
-
}
|
|
36158
|
-
this.warm = true;
|
|
36159
|
-
this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
|
|
36160
|
-
} catch (err) {
|
|
36161
|
-
this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
|
|
36162
|
-
}
|
|
36163
|
-
}
|
|
36164
|
-
/**
|
|
36165
|
-
* A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
|
|
36166
|
-
* (never the fleet). Fire-and-forget from the synchronous event handler.
|
|
36167
|
-
*/
|
|
36168
|
-
onBindingsChanged(event) {
|
|
36169
|
-
if (event.capName !== "detection-pipeline") return Promise.resolve();
|
|
36170
|
-
return this.refreshDevice(event.deviceId);
|
|
36171
|
-
}
|
|
36172
|
-
/** Drop a device the fleet no longer has (`device.unregistered`). */
|
|
36173
|
-
forget(deviceId) {
|
|
36174
|
-
this.state.delete(deviceId);
|
|
36175
|
-
}
|
|
36176
|
-
/** Return to cold — used on addon shutdown. */
|
|
36177
|
-
clearAll() {
|
|
36178
|
-
this.state.clear();
|
|
36179
|
-
this.warm = false;
|
|
36180
|
-
this.seeding = null;
|
|
36181
|
-
}
|
|
36182
|
-
async refreshDevice(deviceId) {
|
|
36183
|
-
try {
|
|
36184
|
-
const active = await this.readWrappedDetection(deviceId);
|
|
36185
|
-
const prev = this.state.get(deviceId);
|
|
36186
|
-
if (!active && prev === true) {
|
|
36187
|
-
if (await this.readWrappedDetection(deviceId)) {
|
|
36188
|
-
this.state.set(deviceId, true);
|
|
36189
|
-
return;
|
|
36190
|
-
}
|
|
36191
|
-
}
|
|
36192
|
-
this.state.set(deviceId, active);
|
|
36193
|
-
} catch (err) {
|
|
36194
|
-
this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
|
|
36195
|
-
tags: { deviceId },
|
|
36196
|
-
meta: { error: String(err) }
|
|
36197
|
-
});
|
|
36198
|
-
}
|
|
36199
|
-
}
|
|
36200
|
-
async readWrappedDetection(deviceId) {
|
|
36201
|
-
return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
|
|
36202
|
-
}
|
|
36203
|
-
};
|
|
36204
|
-
/** The one authority: a WRAPPED `detection-pipeline` binding is present. A
|
|
36205
|
-
* native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
|
|
36206
|
-
* binding is how a deactivated wrapper is represented). */
|
|
36207
|
-
function hasWrappedDetection(entries) {
|
|
36208
|
-
return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
|
|
36209
|
-
}
|
|
36210
|
-
//#endregion
|
|
36211
38468
|
//#region src/pipeline-analytics/runtime/capture-scheduler.ts
|
|
36212
38469
|
/**
|
|
36213
38470
|
* Kind-derived default priorities (0 = highest). Explicit `priority` wins.
|
|
@@ -36480,6 +38737,138 @@ var CaptureScheduler = class {
|
|
|
36480
38737
|
}
|
|
36481
38738
|
};
|
|
36482
38739
|
//#endregion
|
|
38740
|
+
//#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
|
|
38741
|
+
/**
|
|
38742
|
+
* DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
|
|
38743
|
+
* devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
|
|
38744
|
+
* coverage authority ("Away armed, 12 cameras" is false if three of them are
|
|
38745
|
+
* offline or no longer wrapped), read by the notification centre's device
|
|
38746
|
+
* directory (`listDeviceIdentities`).
|
|
38747
|
+
*
|
|
38748
|
+
* WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
|
|
38749
|
+
* rule-reload tick. Answering "who still has detection?" with a fleet-wide
|
|
38750
|
+
* `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
|
|
38751
|
+
* default wrappers skipped` log lines on the live hub (524 devices), forever —
|
|
38752
|
+
* ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
|
|
38753
|
+
* and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
|
|
38754
|
+
* then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
|
|
38755
|
+
* toggle emits that event carrying the device's id and `capName`
|
|
38756
|
+
* (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
|
|
38757
|
+
* keeps the mirror current without ever touching the fleet again.
|
|
38758
|
+
*
|
|
38759
|
+
* ── Destructive-direction discipline (D49) ──────────────────────────────────
|
|
38760
|
+
*
|
|
38761
|
+
* `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
|
|
38762
|
+
* the destructive answer, and the one an operator reads as "my alarm is not
|
|
38763
|
+
* watching that door". A transient `getBindings` blip must never flip a
|
|
38764
|
+
* known-covered camera to uncovered:
|
|
38765
|
+
* • a failed refresh KEEPS the previous value (a read that throws changes
|
|
38766
|
+
* nothing), and
|
|
38767
|
+
* • a single read that would flip a known `true → false` is confirmed by a
|
|
38768
|
+
* concordant SECOND read before it lands — an empty binding list is not
|
|
38769
|
+
* proof the wrapper is gone.
|
|
38770
|
+
* A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
|
|
38771
|
+
* for every id — and unknown never reports a camera as excluded from coverage.
|
|
38772
|
+
*/
|
|
38773
|
+
var DetectionCoverageCache = class {
|
|
38774
|
+
api;
|
|
38775
|
+
logger;
|
|
38776
|
+
/** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
|
|
38777
|
+
state = /* @__PURE__ */ new Map();
|
|
38778
|
+
warm = false;
|
|
38779
|
+
seeding = null;
|
|
38780
|
+
constructor(deps) {
|
|
38781
|
+
this.api = deps.api;
|
|
38782
|
+
this.logger = deps.logger;
|
|
38783
|
+
}
|
|
38784
|
+
/** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
|
|
38785
|
+
isWarm() {
|
|
38786
|
+
return this.warm;
|
|
38787
|
+
}
|
|
38788
|
+
/** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
|
|
38789
|
+
has(deviceId) {
|
|
38790
|
+
return this.state.has(deviceId);
|
|
38791
|
+
}
|
|
38792
|
+
/** The coverage answer, or `undefined` when this id is unknown. No I/O. */
|
|
38793
|
+
get(deviceId) {
|
|
38794
|
+
return this.state.get(deviceId);
|
|
38795
|
+
}
|
|
38796
|
+
/**
|
|
38797
|
+
* Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
|
|
38798
|
+
* warm cache is a no-op, and concurrent callers coalesce onto one read. A
|
|
38799
|
+
* failed seed leaves the mirror COLD so the next tick retries — during that
|
|
38800
|
+
* window every id answers unknown, which never reports a camera as excluded.
|
|
38801
|
+
*/
|
|
38802
|
+
async ensureWarm() {
|
|
38803
|
+
if (this.warm) return;
|
|
38804
|
+
if (this.seeding) return this.seeding;
|
|
38805
|
+
this.seeding = this.seed();
|
|
38806
|
+
try {
|
|
38807
|
+
await this.seeding;
|
|
38808
|
+
} finally {
|
|
38809
|
+
this.seeding = null;
|
|
38810
|
+
}
|
|
38811
|
+
}
|
|
38812
|
+
async seed() {
|
|
38813
|
+
try {
|
|
38814
|
+
const rows = await this.api.deviceManager.getAllBindings.query({});
|
|
38815
|
+
for (const row of rows) {
|
|
38816
|
+
if (this.state.has(row.deviceId)) continue;
|
|
38817
|
+
this.state.set(row.deviceId, hasWrappedDetection(row.entries));
|
|
38818
|
+
}
|
|
38819
|
+
this.warm = true;
|
|
38820
|
+
this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
|
|
38821
|
+
} catch (err) {
|
|
38822
|
+
this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
|
|
38823
|
+
}
|
|
38824
|
+
}
|
|
38825
|
+
/**
|
|
38826
|
+
* A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
|
|
38827
|
+
* (never the fleet). Fire-and-forget from the synchronous event handler.
|
|
38828
|
+
*/
|
|
38829
|
+
onBindingsChanged(event) {
|
|
38830
|
+
if (event.capName !== "detection-pipeline") return Promise.resolve();
|
|
38831
|
+
return this.refreshDevice(event.deviceId);
|
|
38832
|
+
}
|
|
38833
|
+
/** Drop a device the fleet no longer has (`device.unregistered`). */
|
|
38834
|
+
forget(deviceId) {
|
|
38835
|
+
this.state.delete(deviceId);
|
|
38836
|
+
}
|
|
38837
|
+
/** Return to cold — used on addon shutdown. */
|
|
38838
|
+
clearAll() {
|
|
38839
|
+
this.state.clear();
|
|
38840
|
+
this.warm = false;
|
|
38841
|
+
this.seeding = null;
|
|
38842
|
+
}
|
|
38843
|
+
async refreshDevice(deviceId) {
|
|
38844
|
+
try {
|
|
38845
|
+
const active = await this.readWrappedDetection(deviceId);
|
|
38846
|
+
const prev = this.state.get(deviceId);
|
|
38847
|
+
if (!active && prev === true) {
|
|
38848
|
+
if (await this.readWrappedDetection(deviceId)) {
|
|
38849
|
+
this.state.set(deviceId, true);
|
|
38850
|
+
return;
|
|
38851
|
+
}
|
|
38852
|
+
}
|
|
38853
|
+
this.state.set(deviceId, active);
|
|
38854
|
+
} catch (err) {
|
|
38855
|
+
this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
|
|
38856
|
+
tags: { deviceId },
|
|
38857
|
+
meta: { error: String(err) }
|
|
38858
|
+
});
|
|
38859
|
+
}
|
|
38860
|
+
}
|
|
38861
|
+
async readWrappedDetection(deviceId) {
|
|
38862
|
+
return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
|
|
38863
|
+
}
|
|
38864
|
+
};
|
|
38865
|
+
/** The one authority: a WRAPPED `detection-pipeline` binding is present. A
|
|
38866
|
+
* native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
|
|
38867
|
+
* binding is how a deactivated wrapper is represented). */
|
|
38868
|
+
function hasWrappedDetection(entries) {
|
|
38869
|
+
return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
|
|
38870
|
+
}
|
|
38871
|
+
//#endregion
|
|
36483
38872
|
//#region src/pipeline-analytics/pipeline/capture-crop.ts
|
|
36484
38873
|
function createCaptureCrop(deps) {
|
|
36485
38874
|
return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
|
|
@@ -41352,7 +43741,27 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
|
|
|
41352
43741
|
* count. Two thousand is far above any real night and still a bounded read.
|
|
41353
43742
|
*/
|
|
41354
43743
|
var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
|
|
43744
|
+
/**
|
|
43745
|
+
* How many object-event rows ONE timelapse window may read.
|
|
43746
|
+
*
|
|
43747
|
+
* Higher than the track limit because these are per-FRAME rows, not per-subject:
|
|
43748
|
+
* one track routinely writes a handful (`entered`, some `moving`, `left`), and
|
|
43749
|
+
* the merge in `detection-intervals.ts` folds them back to one subject. Ten
|
|
43750
|
+
* thousand covers every night this deployment has produced and still bounds the
|
|
43751
|
+
* read; the store returns the NEWEST rows, so an overflow loses the start of the
|
|
43752
|
+
* window rather than a random slice of it.
|
|
43753
|
+
*/
|
|
43754
|
+
var TIMELAPSE_EVENT_QUERY_LIMIT = 1e4;
|
|
41355
43755
|
var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
|
|
43756
|
+
/** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
|
|
43757
|
+
* bounds' own timescale (14 days / 2000 entries / 2 GiB). */
|
|
43758
|
+
var NC_ARTIFACT_PRUNE_INTERVAL_MS = 36e5;
|
|
43759
|
+
/** What a publish answers on a node whose artifact plane never came up: no
|
|
43760
|
+
* link, no index row — and, exactly as before, bytes-only delivery. */
|
|
43761
|
+
var NO_PUBLICATION = {
|
|
43762
|
+
url: null,
|
|
43763
|
+
artifactId: null
|
|
43764
|
+
};
|
|
41356
43765
|
/**
|
|
41357
43766
|
* The FULL action catalog for this addon.
|
|
41358
43767
|
*
|
|
@@ -41634,6 +44043,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41634
44043
|
* data-plane handle. Null until served / when the facility is absent. */
|
|
41635
44044
|
ncArtifactPlane = null;
|
|
41636
44045
|
ncArtifactDataPlane = null;
|
|
44046
|
+
/** The viewer's artefact library (task #36): the index, its authenticated
|
|
44047
|
+
* bytes route, and the store the two share. Null until served. */
|
|
44048
|
+
ncArtifactIndex = null;
|
|
44049
|
+
ncArtifactStore = null;
|
|
44050
|
+
ncArtifactReadDataPlane = null;
|
|
44051
|
+
ncArtifactPruneTimer = null;
|
|
41637
44052
|
/**
|
|
41638
44053
|
* Roadmap 4b: ONE cut of an event, in every container the rule asked for.
|
|
41639
44054
|
*
|
|
@@ -41820,6 +44235,31 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41820
44235
|
* Best-effort at every step: no facility, no secret or no reachable base URL
|
|
41821
44236
|
* ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
|
|
41822
44237
|
*/
|
|
44238
|
+
/**
|
|
44239
|
+
* Reconcile the artefact index against the shelf that holds the bytes.
|
|
44240
|
+
*
|
|
44241
|
+
* Hourly, because the two drift only on the bounds' timescale: a row expires
|
|
44242
|
+
* on the shelf's own age bound (14 d) or its file is evicted by the count/byte
|
|
44243
|
+
* bound. Without this, a list eventually serves rows whose bytes 404 — and a
|
|
44244
|
+
* broken tile is the one failure an operator DOES report.
|
|
44245
|
+
*/
|
|
44246
|
+
startNcArtifactPrune() {
|
|
44247
|
+
if (this.ncArtifactPruneTimer !== null) return;
|
|
44248
|
+
const run = async () => {
|
|
44249
|
+
const index = this.ncArtifactIndex;
|
|
44250
|
+
const store = this.ncArtifactStore;
|
|
44251
|
+
if (index === null || store === null) return;
|
|
44252
|
+
try {
|
|
44253
|
+
await store.sweep();
|
|
44254
|
+
await index.prune(this.isPostProcessingNode ? await store.keptIds() : null);
|
|
44255
|
+
} catch (err) {
|
|
44256
|
+
this.ctx.logger.warn("nc artifact prune failed", { meta: { error: require_dist.errMsg(err) } });
|
|
44257
|
+
}
|
|
44258
|
+
};
|
|
44259
|
+
run();
|
|
44260
|
+
this.ncArtifactPruneTimer = setInterval(() => void run(), NC_ARTIFACT_PRUNE_INTERVAL_MS);
|
|
44261
|
+
this.ncArtifactPruneTimer.unref?.();
|
|
44262
|
+
}
|
|
41823
44263
|
async serveNcArtifactPlane() {
|
|
41824
44264
|
try {
|
|
41825
44265
|
const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
|
|
@@ -41833,9 +44273,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41833
44273
|
logger: this.ctx.logger.child("nc-artifacts")
|
|
41834
44274
|
});
|
|
41835
44275
|
await store.start();
|
|
44276
|
+
this.ncArtifactStore = store;
|
|
44277
|
+
const index = new NcArtifactIndex({
|
|
44278
|
+
store: this.ctx.api.settingsStore,
|
|
44279
|
+
logger: this.ctx.logger.child("nc-artifacts"),
|
|
44280
|
+
addonId: this.ctx.id,
|
|
44281
|
+
retentionMs: store.retentionMs
|
|
44282
|
+
});
|
|
44283
|
+
this.ncArtifactIndex = index;
|
|
41836
44284
|
const plane = new NcArtifactPlane({
|
|
41837
44285
|
store,
|
|
41838
44286
|
secret,
|
|
44287
|
+
index,
|
|
41839
44288
|
logger: this.ctx.logger.child("nc-artifacts"),
|
|
41840
44289
|
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
41841
44290
|
listEndpoints: () => this.ncEndpoints()
|
|
@@ -41846,7 +44295,23 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41846
44295
|
handler: plane.handler
|
|
41847
44296
|
}) ?? null;
|
|
41848
44297
|
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
41849
|
-
|
|
44298
|
+
const readPlane = new NcArtifactReadPlane({
|
|
44299
|
+
index,
|
|
44300
|
+
store,
|
|
44301
|
+
logger: this.ctx.logger.child("nc-artifacts")
|
|
44302
|
+
});
|
|
44303
|
+
this.ncArtifactReadDataPlane = await this.ctx.dataPlane?.serve({
|
|
44304
|
+
prefix: NC_ARTIFACT_READ_PREFIX_REF,
|
|
44305
|
+
access: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
|
|
44306
|
+
handler: readPlane.handler
|
|
44307
|
+
}) ?? null;
|
|
44308
|
+
this.startNcArtifactPrune();
|
|
44309
|
+
this.ctx.logger.info("nc-artifact data-plane served", { meta: {
|
|
44310
|
+
served: this.ncArtifactPlane !== null,
|
|
44311
|
+
libraryServed: this.ncArtifactReadDataPlane !== null,
|
|
44312
|
+
libraryAccess: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
|
|
44313
|
+
retentionDays: Math.round(store.retentionMs / 864e5)
|
|
44314
|
+
} });
|
|
41850
44315
|
} catch (err) {
|
|
41851
44316
|
this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
41852
44317
|
}
|
|
@@ -42294,7 +44759,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
42294
44759
|
store: center.summaryStore,
|
|
42295
44760
|
runNow: (input) => center.runSummaryNow(input)
|
|
42296
44761
|
},
|
|
42297
|
-
texts: center.textCatalogEditor
|
|
44762
|
+
texts: center.textCatalogEditor,
|
|
44763
|
+
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
|
|
44764
|
+
ruleStatus: {
|
|
44765
|
+
forRule: (rule) => center.ruleStatusFor(rule),
|
|
44766
|
+
forTimelapseRule: (rule) => center.timelapseStatusFor(rule, async ({ ruleId, deviceId }) => {
|
|
44767
|
+
const index = this.ncArtifactIndex;
|
|
44768
|
+
if (index === null) return null;
|
|
44769
|
+
return (await index.list({
|
|
44770
|
+
kinds: ["timelapse"],
|
|
44771
|
+
ruleId,
|
|
44772
|
+
deviceIds: [deviceId],
|
|
44773
|
+
limit: 1,
|
|
44774
|
+
offset: 0
|
|
44775
|
+
}, {
|
|
44776
|
+
userId: "",
|
|
44777
|
+
isAdmin: true
|
|
44778
|
+
})).artifacts[0] ?? null;
|
|
44779
|
+
})
|
|
44780
|
+
}
|
|
42298
44781
|
});
|
|
42299
44782
|
}
|
|
42300
44783
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
@@ -42767,10 +45250,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
42767
45250
|
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
42768
45251
|
return buf ? buf.toString("base64") : null;
|
|
42769
45252
|
},
|
|
42770
|
-
hasTrackLabel: (trackId) =>
|
|
42771
|
-
const label = this.trackStore?.getActiveByTrack(trackId)?.label;
|
|
42772
|
-
return label !== void 0 && label.length > 0;
|
|
42773
|
-
}
|
|
45253
|
+
hasTrackLabel: (trackId) => hasResolvedLabel(this.trackStore?.getActiveByTrack(trackId))
|
|
42774
45254
|
});
|
|
42775
45255
|
}
|
|
42776
45256
|
/** Binding cache + the zone-analytics / audio-metrics cap providers. */
|
|
@@ -42862,6 +45342,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
42862
45342
|
addonId: device.addonId
|
|
42863
45343
|
};
|
|
42864
45344
|
},
|
|
45345
|
+
identityIdsByName: async () => {
|
|
45346
|
+
const byName = /* @__PURE__ */ new Map();
|
|
45347
|
+
const identities = await stores.identityStore.listIdentities();
|
|
45348
|
+
for (const identity of identities) byName.set(identity.name.trim().toLowerCase(), identity.id);
|
|
45349
|
+
const vehicles = await stores.vehicleStore.listVehicles();
|
|
45350
|
+
for (const vehicle of vehicles) byName.set(vehicle.name.trim().toLowerCase(), vehicle.id);
|
|
45351
|
+
return byName;
|
|
45352
|
+
},
|
|
42865
45353
|
listDeviceIdentities: async () => {
|
|
42866
45354
|
const all = await api.deviceManager.listAll.query({ projection: "slim" });
|
|
42867
45355
|
const coverage = this.detectionCoverage;
|
|
@@ -43083,7 +45571,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43083
45571
|
timestamp,
|
|
43084
45572
|
data: jpeg
|
|
43085
45573
|
}),
|
|
43086
|
-
publish: async (bytes, mime) => await this.ncArtifactPlane?.
|
|
45574
|
+
publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION
|
|
43087
45575
|
};
|
|
43088
45576
|
}
|
|
43089
45577
|
/**
|
|
@@ -43124,11 +45612,40 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43124
45612
|
projection: "slim",
|
|
43125
45613
|
limit: TIMELAPSE_TRACK_QUERY_LIMIT
|
|
43126
45614
|
})).map((t) => ({
|
|
45615
|
+
trackId: t.trackId,
|
|
43127
45616
|
firstSeen: t.firstSeen,
|
|
43128
45617
|
lastSeen: t.lastSeen,
|
|
43129
45618
|
className: t.className
|
|
43130
45619
|
}));
|
|
43131
45620
|
},
|
|
45621
|
+
/**
|
|
45622
|
+
* The other half of "did anything happen here": the object-event rows.
|
|
45623
|
+
*
|
|
45624
|
+
* The persisted track store is NOT a complete record of the window. Three
|
|
45625
|
+
* gates destroy a track before it is written — the confirmation gate
|
|
45626
|
+
* retracting a suspected false birth, stationary promotion dropping a
|
|
45627
|
+
* parked object, zero-media suppression cascading a thumbnail-less track
|
|
45628
|
+
* away — and on 2026-08-13 they left camera 615 with zero persisted tracks
|
|
45629
|
+
* across a ten-hour night that held a person pass at 23:31. These rows
|
|
45630
|
+
* survive all three.
|
|
45631
|
+
*
|
|
45632
|
+
* FULL projection, not slim: `slimObject` drops `trackId`, and without it
|
|
45633
|
+
* every event of one subject becomes its own detection. It costs nothing
|
|
45634
|
+
* at the store — the row is read whole either way and slim only strips
|
|
45635
|
+
* fields afterwards.
|
|
45636
|
+
*/
|
|
45637
|
+
listDetectionEvents: async ({ deviceId, since, until }) => {
|
|
45638
|
+
return (await stores.eventStore.queryObject({
|
|
45639
|
+
deviceId,
|
|
45640
|
+
since,
|
|
45641
|
+
until,
|
|
45642
|
+
limit: TIMELAPSE_EVENT_QUERY_LIMIT
|
|
45643
|
+
})).map((e) => ({
|
|
45644
|
+
timestamp: e.timestamp,
|
|
45645
|
+
className: e.className,
|
|
45646
|
+
...e.trackId !== void 0 ? { trackId: e.trackId } : {}
|
|
45647
|
+
}));
|
|
45648
|
+
},
|
|
43132
45649
|
createExport: (input) => api.recordingExport.createExport.mutate({
|
|
43133
45650
|
deviceId: input.deviceId,
|
|
43134
45651
|
profile: input.profile,
|
|
@@ -43153,10 +45670,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43153
45670
|
}
|
|
43154
45671
|
}),
|
|
43155
45672
|
getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
|
|
45673
|
+
listExports: ({ deviceId }) => api.recordingExport.listExports.query({ deviceId }),
|
|
43156
45674
|
publishArtifact: buildTimelapseArtifactPublisher({
|
|
43157
45675
|
logger: this.ctx.logger.child("Timelapse"),
|
|
43158
45676
|
readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
|
|
43159
|
-
publish: async (bytes, mime) => await this.ncArtifactPlane?.
|
|
45677
|
+
publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION,
|
|
43160
45678
|
renderPreview: async ({ mode, ...input }) => {
|
|
43161
45679
|
const previewPorts = {
|
|
43162
45680
|
logger: this.ctx.logger.child("Timelapse"),
|
|
@@ -43823,6 +46341,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
43823
46341
|
this.retrainExportDataPlane = null;
|
|
43824
46342
|
await this.debugExportDataPlane?.dispose();
|
|
43825
46343
|
this.debugExportDataPlane = null;
|
|
46344
|
+
if (this.ncArtifactPruneTimer !== null) {
|
|
46345
|
+
clearInterval(this.ncArtifactPruneTimer);
|
|
46346
|
+
this.ncArtifactPruneTimer = null;
|
|
46347
|
+
}
|
|
46348
|
+
await this.ncArtifactReadDataPlane?.dispose();
|
|
46349
|
+
this.ncArtifactReadDataPlane = null;
|
|
46350
|
+
this.ncArtifactIndex = null;
|
|
46351
|
+
this.ncArtifactStore = null;
|
|
43826
46352
|
}
|
|
43827
46353
|
async handleInferenceResult(data) {
|
|
43828
46354
|
if (this.shuttingDown) return;
|
|
@@ -44786,8 +47312,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
44786
47312
|
});
|
|
44787
47313
|
}
|
|
44788
47314
|
}
|
|
44789
|
-
const
|
|
44790
|
-
if (
|
|
47315
|
+
const resolved = d.className === "plate" ? this.plateRecognizer?.resolveLabelWithVehicle(d.label, d.score) ?? null : { text: d.label };
|
|
47316
|
+
if (resolved !== null && resolved.text !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId);
|
|
44791
47317
|
}
|
|
44792
47318
|
} catch (err) {
|
|
44793
47319
|
this.ctx.logger.warn("detail result route failed", {
|
|
@@ -44875,7 +47401,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
44875
47401
|
* a runner older than the declaration, because the alternative is guessing
|
|
44876
47402
|
* and putting a species in the coarse slot.
|
|
44877
47403
|
*/
|
|
44878
|
-
async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt) {
|
|
47404
|
+
async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt, identityId) {
|
|
44879
47405
|
await applyTrackLabelCandidate({
|
|
44880
47406
|
...this.trackStore ? { trackStore: this.trackStore } : {},
|
|
44881
47407
|
...this.eventStore ? { eventStore: this.eventStore } : {},
|
|
@@ -44886,7 +47412,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
44886
47412
|
score: detail.score,
|
|
44887
47413
|
stepId: detail.stepId,
|
|
44888
47414
|
...detail.labelModelId !== void 0 ? { modelId: detail.labelModelId } : {},
|
|
44889
|
-
decidedAt
|
|
47415
|
+
decidedAt,
|
|
47416
|
+
...identityId !== void 0 ? { identityId } : {}
|
|
44890
47417
|
});
|
|
44891
47418
|
}
|
|
44892
47419
|
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
@@ -45956,6 +48483,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
45956
48483
|
cooldownSec,
|
|
45957
48484
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
45958
48485
|
}, source);
|
|
48486
|
+
p.setTrackLabelState((trackId) => {
|
|
48487
|
+
const active = this.trackStore?.getActiveByTrack(trackId);
|
|
48488
|
+
return active !== null && active !== void 0 ? {
|
|
48489
|
+
className: active.className,
|
|
48490
|
+
...active.label !== void 0 ? { label: active.label } : {},
|
|
48491
|
+
...active.labelScore !== void 0 ? { labelScore: active.labelScore } : {},
|
|
48492
|
+
...active.labelMeta !== void 0 ? { labelMeta: active.labelMeta } : {},
|
|
48493
|
+
...active.subLabel !== void 0 ? { subLabel: active.subLabel } : {},
|
|
48494
|
+
...active.subLabelScore !== void 0 ? { subLabelScore: active.subLabelScore } : {},
|
|
48495
|
+
...active.subLabelMeta !== void 0 ? { subLabelMeta: active.subLabelMeta } : {}
|
|
48496
|
+
} : void 0;
|
|
48497
|
+
});
|
|
45959
48498
|
if (source === "pipeline" && this.stationaryRegistry) {
|
|
45960
48499
|
const registry = this.stationaryRegistry;
|
|
45961
48500
|
p.setStationaryGate({ filter: (input) => {
|