@camstack/addon-provider-dreame 0.2.92 → 0.2.94
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/addon.js +174 -11
- package/dist/addon.mjs +174 -11
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -72605,7 +72605,17 @@ var EventDensityBucketSchema = object({
|
|
|
72605
72605
|
bucketStart: number(),
|
|
72606
72606
|
motion: number().int(),
|
|
72607
72607
|
object: number().int(),
|
|
72608
|
-
audio: number().int()
|
|
72608
|
+
audio: number().int(),
|
|
72609
|
+
/**
|
|
72610
|
+
* Energy-weighted mean dBFS of the audio events in this bucket — the LEVEL,
|
|
72611
|
+
* as opposed to `audio`, which is their COUNT (D431).
|
|
72612
|
+
*
|
|
72613
|
+
* ABSENT when the bucket holds no audio event. Absence means "nothing was
|
|
72614
|
+
* measured", which is not the same claim as silence, and a lane that cannot
|
|
72615
|
+
* tell them apart paints a quiet hour at full scale — which is exactly what
|
|
72616
|
+
* the count-on-a-level-meter did.
|
|
72617
|
+
*/
|
|
72618
|
+
audioDbfs: number().optional()
|
|
72609
72619
|
});
|
|
72610
72620
|
/**
|
|
72611
72621
|
* One camera's row in a `getEventDensityBatch` answer.
|
|
@@ -82793,7 +82803,23 @@ var RecordingRangeSchema = object({
|
|
|
82793
82803
|
});
|
|
82794
82804
|
var RecordingAvailabilitySchema = object({
|
|
82795
82805
|
deviceId: number(),
|
|
82796
|
-
ranges: array(RecordingRangeSchema)
|
|
82806
|
+
ranges: array(RecordingRangeSchema),
|
|
82807
|
+
/**
|
|
82808
|
+
* Every profile this camera has footage in — not only the one `ranges`
|
|
82809
|
+
* describes (D433).
|
|
82810
|
+
*
|
|
82811
|
+
* `ranges` answers for ONE profile by design: the timeline is a single bar,
|
|
82812
|
+
* and enumerating all of them triples the directory reads for a bar that
|
|
82813
|
+
* would look identical. But the answer used to say nothing about that, so a
|
|
82814
|
+
* caller asking "what can I export?" read the single preferred profile as
|
|
82815
|
+
* the only one that exists — on this deployment every camera records `high`
|
|
82816
|
+
* AND `low`, the low tree holding 14 GB for camera 615 alone, and the export
|
|
82817
|
+
* UI could not see it.
|
|
82818
|
+
*
|
|
82819
|
+
* Cheap: it is the same shallow directory read the profile CHOICE already
|
|
82820
|
+
* makes. Ask for a specific profile's ranges with the `profile` input.
|
|
82821
|
+
*/
|
|
82822
|
+
profilesWithFootage: array(string())
|
|
82797
82823
|
});
|
|
82798
82824
|
var RecordingDaysSchema = object({
|
|
82799
82825
|
deviceId: number(),
|
|
@@ -82819,7 +82845,11 @@ var RecordingDaysSchema = object({
|
|
|
82819
82845
|
var RecordingAvailabilityForDeviceSchema = object({
|
|
82820
82846
|
deviceId: number(),
|
|
82821
82847
|
read: _enum(["read", "unreadable"]),
|
|
82822
|
-
ranges: array(RecordingRangeSchema).readonly()
|
|
82848
|
+
ranges: array(RecordingRangeSchema).readonly(),
|
|
82849
|
+
/** Same field, same meaning, as the singular answer (D433). A row that
|
|
82850
|
+
* dropped it would tell a grid caller the archive holds one profile.
|
|
82851
|
+
* Empty on an `'unreadable'` row: nobody looked. */
|
|
82852
|
+
profilesWithFootage: array(string()).readonly()
|
|
82823
82853
|
});
|
|
82824
82854
|
/**
|
|
82825
82855
|
* One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
|
|
@@ -83109,7 +83139,14 @@ var ReadWindowBytesResultSchema = discriminatedUnion("kind", [object({
|
|
|
83109
83139
|
method(object({
|
|
83110
83140
|
deviceId: number(),
|
|
83111
83141
|
fromMs: number(),
|
|
83112
|
-
toMs: number()
|
|
83142
|
+
toMs: number(),
|
|
83143
|
+
/**
|
|
83144
|
+
* Answer for THIS profile instead of the preferred one (D433). Absent
|
|
83145
|
+
* keeps the timeline's behaviour — one bar, one profile, one set of
|
|
83146
|
+
* reads. `profilesWithFootage` on the answer says what may be asked
|
|
83147
|
+
* for.
|
|
83148
|
+
*/
|
|
83149
|
+
profile: string().optional()
|
|
83113
83150
|
}), RecordingAvailabilitySchema, {
|
|
83114
83151
|
kind: "query",
|
|
83115
83152
|
auth: "protected"
|
|
@@ -98475,7 +98512,7 @@ function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_E
|
|
|
98475
98512
|
function resolveNavigationFeatures(overrides) {
|
|
98476
98513
|
return {
|
|
98477
98514
|
...DREAME_NAVIGATION_DEFAULT_FEATURES,
|
|
98478
|
-
...overrides?.features
|
|
98515
|
+
...overrides?.features
|
|
98479
98516
|
};
|
|
98480
98517
|
}
|
|
98481
98518
|
/** The disabled entry ids in force for ONE device: the addon set plus its own. */
|
|
@@ -98585,7 +98622,7 @@ var DreameNetworkLinkMonitor = class {
|
|
|
98585
98622
|
meta: {
|
|
98586
98623
|
reason,
|
|
98587
98624
|
rawBars: bars,
|
|
98588
|
-
...next
|
|
98625
|
+
...next
|
|
98589
98626
|
}
|
|
98590
98627
|
});
|
|
98591
98628
|
this.reported = true;
|
|
@@ -144141,9 +144178,28 @@ var { mulaw, alaw } = resolveAlawmulaw();
|
|
|
144141
144178
|
* name, and WE own the TCP socket — when the broker's reader detaches we know
|
|
144142
144179
|
* the viewer is gone and release the monitor at once, instead of polling the
|
|
144143
144180
|
* broker for consumers or waiting on a duration cap.
|
|
144181
|
+
*
|
|
144182
|
+
* And we own the FEED: the relay dies in three ways nodedreame reports and one
|
|
144183
|
+
* it cannot — the socket errors (`error`; with no listener an EventEmitter
|
|
144184
|
+
* throws, and on 2026-09-09 a `read ETIMEDOUT` took the whole provider down),
|
|
144185
|
+
* the relay closes and the stream stops itself (`stopped`), or the socket goes
|
|
144186
|
+
* silent for minutes before the kernel gives up (nothing at all: 16 min of a
|
|
144187
|
+
* broker dialling a port that accepted and sent nothing). The server reports
|
|
144188
|
+
* every one of them ONCE through `onFeedLost`, and never its own close, so the
|
|
144189
|
+
* device can put the lazy sentinel back and let the next dial be a wake (D424).
|
|
144144
144190
|
*/
|
|
144145
144191
|
var VIDEO_PT = 96;
|
|
144146
144192
|
var AUDIO_PT = 97;
|
|
144193
|
+
/**
|
|
144194
|
+
* How long the feed may go without a video access unit before the relay is
|
|
144195
|
+
* declared dead. The broker gives a source 8 s (`RTP_ACTIVITY_TIMEOUT_MS`);
|
|
144196
|
+
* this sits just past it so a stall is judged by the side that can act on it.
|
|
144197
|
+
*/
|
|
144198
|
+
var FEED_STALL_MS = 1e4;
|
|
144199
|
+
var FEED_STALL_SWEEP_MS = 1e3;
|
|
144200
|
+
function describeLoss(loss) {
|
|
144201
|
+
return loss.error === null ? loss.reason : `${loss.reason}: ${loss.error.message}`;
|
|
144202
|
+
}
|
|
144147
144203
|
function deferred() {
|
|
144148
144204
|
let resolve = () => void 0;
|
|
144149
144205
|
return {
|
|
@@ -144176,11 +144232,37 @@ function consoleInto(logger) {
|
|
|
144176
144232
|
* server calls `onConsumerDisconnect` when the last reader detaches.
|
|
144177
144233
|
*/
|
|
144178
144234
|
async function startRfc4571Server(params) {
|
|
144179
|
-
const { stream, logger, onConsumerDisconnect, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3 } = params;
|
|
144235
|
+
const { stream, logger, onConsumerDisconnect, onFeedLost, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3, stallTimeoutMs = FEED_STALL_MS } = params;
|
|
144180
144236
|
const videoRef = { current: null };
|
|
144181
144237
|
const audioRef = { current: null };
|
|
144182
144238
|
const firstKeyframe = deferred();
|
|
144183
144239
|
const firstAudio = deferred();
|
|
144240
|
+
const firstLoss = deferred();
|
|
144241
|
+
let pendingLoss = null;
|
|
144242
|
+
let toreDown = false;
|
|
144243
|
+
let audioFrames = 0;
|
|
144244
|
+
let videoAccessUnits = 0;
|
|
144245
|
+
let lastAudioAt = 0;
|
|
144246
|
+
let audioStallReported = false;
|
|
144247
|
+
let serving = false;
|
|
144248
|
+
let lastVideoAt = 0;
|
|
144249
|
+
const reportLoss = (loss) => {
|
|
144250
|
+
if (toreDown || pendingLoss !== null) return;
|
|
144251
|
+
pendingLoss = loss;
|
|
144252
|
+
firstLoss.resolve(loss);
|
|
144253
|
+
if (serving) onFeedLost(loss);
|
|
144254
|
+
};
|
|
144255
|
+
stream.on("error", (err) => reportLoss({
|
|
144256
|
+
reason: "error",
|
|
144257
|
+
error: err
|
|
144258
|
+
}));
|
|
144259
|
+
stream.on("stopped", () => reportLoss({
|
|
144260
|
+
reason: "stopped",
|
|
144261
|
+
error: null
|
|
144262
|
+
}));
|
|
144263
|
+
stream.on("videoAccessUnit", () => {
|
|
144264
|
+
lastVideoAt = Date.now();
|
|
144265
|
+
});
|
|
144184
144266
|
stream.on("videoAccessUnit", (e) => {
|
|
144185
144267
|
if (videoRef.current !== null) return;
|
|
144186
144268
|
const p = extractH264ParamSetsFromAccessUnit(e.data);
|
|
@@ -144206,15 +144288,24 @@ async function startRfc4571Server(params) {
|
|
|
144206
144288
|
}
|
|
144207
144289
|
});
|
|
144208
144290
|
await stream.start();
|
|
144291
|
+
const lostBeforeServing = firstLoss.promise.then((loss) => {
|
|
144292
|
+
throw new Error(`relay lost before the first keyframe (${describeLoss(loss)})`);
|
|
144293
|
+
});
|
|
144209
144294
|
try {
|
|
144210
|
-
await withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe");
|
|
144295
|
+
await Promise.race([withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe"), lostBeforeServing]);
|
|
144211
144296
|
} catch (err) {
|
|
144212
144297
|
await stream.stop().catch(() => void 0);
|
|
144213
144298
|
throw err;
|
|
144214
144299
|
}
|
|
144300
|
+
const graceStartedAt = Date.now();
|
|
144215
144301
|
await Promise.race([firstAudio.promise, new Promise((resolve) => {
|
|
144216
144302
|
setTimeout(resolve, audioGraceMs).unref?.();
|
|
144217
144303
|
})]);
|
|
144304
|
+
const waitedMs = Date.now() - graceStartedAt;
|
|
144305
|
+
if (pendingLoss !== null) {
|
|
144306
|
+
await stream.stop().catch(() => void 0);
|
|
144307
|
+
throw new Error(`relay lost before the server was up (${describeLoss(pendingLoss)})`);
|
|
144308
|
+
}
|
|
144218
144309
|
const paramSets = videoRef.current;
|
|
144219
144310
|
if (paramSets === null) throw new Error("no parameter sets after the first keyframe");
|
|
144220
144311
|
const audioFacts = audioRef.current;
|
|
@@ -144230,11 +144321,40 @@ async function startRfc4571Server(params) {
|
|
|
144230
144321
|
configHex: audioFacts.configHex
|
|
144231
144322
|
} : void 0);
|
|
144232
144323
|
const muxer = new Rfc4571Muxer(consoleInto(logger), VIDEO_PT, audioFacts !== null ? AUDIO_PT : void 0);
|
|
144233
|
-
stream.on("videoAccessUnit", (e) =>
|
|
144234
|
-
|
|
144324
|
+
stream.on("videoAccessUnit", (e) => {
|
|
144325
|
+
videoAccessUnits += 1;
|
|
144326
|
+
muxer.sendVideoAccessUnit("H264", e.data, e.isKeyframe, e.microseconds);
|
|
144327
|
+
});
|
|
144328
|
+
if (audioFacts !== null) {
|
|
144329
|
+
logger.info("rfc4571: serving audio", { meta: {
|
|
144330
|
+
codec: "aac",
|
|
144331
|
+
sampleRate: audioFacts.sampleRate,
|
|
144332
|
+
channels: audioFacts.channels,
|
|
144333
|
+
waitedMs
|
|
144334
|
+
} });
|
|
144335
|
+
lastAudioAt = Date.now();
|
|
144336
|
+
stream.on("audioFrame", (adts) => {
|
|
144337
|
+
audioFrames += 1;
|
|
144338
|
+
lastAudioAt = Date.now();
|
|
144339
|
+
muxer.sendAudioAdtsFrame(adts);
|
|
144340
|
+
});
|
|
144341
|
+
} else {
|
|
144342
|
+
logger.info("rfc4571: no audio within the grace — serving video-only", { meta: {
|
|
144343
|
+
waitedMs,
|
|
144344
|
+
audioGraceMs
|
|
144345
|
+
} });
|
|
144346
|
+
let reportedLate = false;
|
|
144347
|
+
stream.on("audioFrame", () => {
|
|
144348
|
+
if (reportedLate) return;
|
|
144349
|
+
reportedLate = true;
|
|
144350
|
+
logger.warn("rfc4571: audio arrived after the grace — this session stays video-only", { meta: {
|
|
144351
|
+
lateByMs: Date.now() - graceStartedAt - waitedMs,
|
|
144352
|
+
audioGraceMs
|
|
144353
|
+
} });
|
|
144354
|
+
});
|
|
144355
|
+
}
|
|
144235
144356
|
let liveClients = 0;
|
|
144236
144357
|
let idleTimer = null;
|
|
144237
|
-
let toreDown = false;
|
|
144238
144358
|
const server = node_net.default.createServer((socket) => {
|
|
144239
144359
|
liveClients += 1;
|
|
144240
144360
|
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
@@ -144261,6 +144381,24 @@ async function startRfc4571Server(params) {
|
|
|
144261
144381
|
});
|
|
144262
144382
|
const addr = server.address();
|
|
144263
144383
|
if (addr === null || typeof addr === "string") throw new Error("failed to bind the RFC4571 server");
|
|
144384
|
+
const stallSweep = setInterval(() => {
|
|
144385
|
+
if (toreDown) return;
|
|
144386
|
+
if (audioFacts !== null && !audioStallReported && Date.now() - lastAudioAt >= stallTimeoutMs) {
|
|
144387
|
+
audioStallReported = true;
|
|
144388
|
+
logger.warn("rfc4571: audio stopped while the video kept flowing", { meta: {
|
|
144389
|
+
audioFrames,
|
|
144390
|
+
videoAccessUnits,
|
|
144391
|
+
silentForMs: Date.now() - lastAudioAt
|
|
144392
|
+
} });
|
|
144393
|
+
}
|
|
144394
|
+
if (Date.now() - lastVideoAt < stallTimeoutMs) return;
|
|
144395
|
+
reportLoss({
|
|
144396
|
+
reason: "stalled",
|
|
144397
|
+
error: null
|
|
144398
|
+
});
|
|
144399
|
+
}, Math.max(5, Math.min(FEED_STALL_SWEEP_MS, Math.floor(stallTimeoutMs / 2))));
|
|
144400
|
+
stallSweep.unref?.();
|
|
144401
|
+
serving = true;
|
|
144264
144402
|
return {
|
|
144265
144403
|
host: bindHost,
|
|
144266
144404
|
port: addr.port,
|
|
@@ -144272,6 +144410,12 @@ async function startRfc4571Server(params) {
|
|
|
144272
144410
|
} : null,
|
|
144273
144411
|
close: async () => {
|
|
144274
144412
|
toreDown = true;
|
|
144413
|
+
logger.info("rfc4571: session ended", { meta: {
|
|
144414
|
+
videoAccessUnits,
|
|
144415
|
+
audioFrames,
|
|
144416
|
+
declaredAudio: audioFacts !== null
|
|
144417
|
+
} });
|
|
144418
|
+
clearInterval(stallSweep);
|
|
144275
144419
|
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
144276
144420
|
try {
|
|
144277
144421
|
muxer.close();
|
|
@@ -144551,6 +144695,9 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
144551
144695
|
keyframeTimeoutMs: WAKE_TIMEOUT_MS,
|
|
144552
144696
|
onConsumerDisconnect: () => {
|
|
144553
144697
|
this.closeStream("idle");
|
|
144698
|
+
},
|
|
144699
|
+
onFeedLost: (loss) => {
|
|
144700
|
+
this.onFeedLost(loss);
|
|
144554
144701
|
}
|
|
144555
144702
|
});
|
|
144556
144703
|
this.live = {
|
|
@@ -144590,6 +144737,22 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
144590
144737
|
});
|
|
144591
144738
|
return this.opening;
|
|
144592
144739
|
}
|
|
144740
|
+
/**
|
|
144741
|
+
* The feed died under an open monitor. The session is closed — the lazy
|
|
144742
|
+
* sentinel goes back on the broker — so the broker's next dial asks for a
|
|
144743
|
+
* materialize, which is the wake. Logged: this branch drops a live stream.
|
|
144744
|
+
*/
|
|
144745
|
+
onFeedLost(loss) {
|
|
144746
|
+
this.ctx.logger.warn("robot camera: relay feed lost — closing the monitor; the next dial re-opens it", {
|
|
144747
|
+
tags: { deviceId: this.id },
|
|
144748
|
+
meta: {
|
|
144749
|
+
reason: loss.reason,
|
|
144750
|
+
error: loss.error === null ? null : errMsg(loss.error),
|
|
144751
|
+
openMs: Date.now() - this.sessionOpenedAt
|
|
144752
|
+
}
|
|
144753
|
+
});
|
|
144754
|
+
this.closeStream("feed-lost");
|
|
144755
|
+
}
|
|
144593
144756
|
armMaxDuration() {
|
|
144594
144757
|
if (this.maxTimer !== null) clearTimeout(this.maxTimer);
|
|
144595
144758
|
this.maxTimer = setTimeout(() => {
|
package/dist/addon.mjs
CHANGED
|
@@ -72602,7 +72602,17 @@ var EventDensityBucketSchema = object({
|
|
|
72602
72602
|
bucketStart: number(),
|
|
72603
72603
|
motion: number().int(),
|
|
72604
72604
|
object: number().int(),
|
|
72605
|
-
audio: number().int()
|
|
72605
|
+
audio: number().int(),
|
|
72606
|
+
/**
|
|
72607
|
+
* Energy-weighted mean dBFS of the audio events in this bucket — the LEVEL,
|
|
72608
|
+
* as opposed to `audio`, which is their COUNT (D431).
|
|
72609
|
+
*
|
|
72610
|
+
* ABSENT when the bucket holds no audio event. Absence means "nothing was
|
|
72611
|
+
* measured", which is not the same claim as silence, and a lane that cannot
|
|
72612
|
+
* tell them apart paints a quiet hour at full scale — which is exactly what
|
|
72613
|
+
* the count-on-a-level-meter did.
|
|
72614
|
+
*/
|
|
72615
|
+
audioDbfs: number().optional()
|
|
72606
72616
|
});
|
|
72607
72617
|
/**
|
|
72608
72618
|
* One camera's row in a `getEventDensityBatch` answer.
|
|
@@ -82790,7 +82800,23 @@ var RecordingRangeSchema = object({
|
|
|
82790
82800
|
});
|
|
82791
82801
|
var RecordingAvailabilitySchema = object({
|
|
82792
82802
|
deviceId: number(),
|
|
82793
|
-
ranges: array(RecordingRangeSchema)
|
|
82803
|
+
ranges: array(RecordingRangeSchema),
|
|
82804
|
+
/**
|
|
82805
|
+
* Every profile this camera has footage in — not only the one `ranges`
|
|
82806
|
+
* describes (D433).
|
|
82807
|
+
*
|
|
82808
|
+
* `ranges` answers for ONE profile by design: the timeline is a single bar,
|
|
82809
|
+
* and enumerating all of them triples the directory reads for a bar that
|
|
82810
|
+
* would look identical. But the answer used to say nothing about that, so a
|
|
82811
|
+
* caller asking "what can I export?" read the single preferred profile as
|
|
82812
|
+
* the only one that exists — on this deployment every camera records `high`
|
|
82813
|
+
* AND `low`, the low tree holding 14 GB for camera 615 alone, and the export
|
|
82814
|
+
* UI could not see it.
|
|
82815
|
+
*
|
|
82816
|
+
* Cheap: it is the same shallow directory read the profile CHOICE already
|
|
82817
|
+
* makes. Ask for a specific profile's ranges with the `profile` input.
|
|
82818
|
+
*/
|
|
82819
|
+
profilesWithFootage: array(string())
|
|
82794
82820
|
});
|
|
82795
82821
|
var RecordingDaysSchema = object({
|
|
82796
82822
|
deviceId: number(),
|
|
@@ -82816,7 +82842,11 @@ var RecordingDaysSchema = object({
|
|
|
82816
82842
|
var RecordingAvailabilityForDeviceSchema = object({
|
|
82817
82843
|
deviceId: number(),
|
|
82818
82844
|
read: _enum(["read", "unreadable"]),
|
|
82819
|
-
ranges: array(RecordingRangeSchema).readonly()
|
|
82845
|
+
ranges: array(RecordingRangeSchema).readonly(),
|
|
82846
|
+
/** Same field, same meaning, as the singular answer (D433). A row that
|
|
82847
|
+
* dropped it would tell a grid caller the archive holds one profile.
|
|
82848
|
+
* Empty on an `'unreadable'` row: nobody looked. */
|
|
82849
|
+
profilesWithFootage: array(string()).readonly()
|
|
82820
82850
|
});
|
|
82821
82851
|
/**
|
|
82822
82852
|
* One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
|
|
@@ -83106,7 +83136,14 @@ var ReadWindowBytesResultSchema = discriminatedUnion("kind", [object({
|
|
|
83106
83136
|
method(object({
|
|
83107
83137
|
deviceId: number(),
|
|
83108
83138
|
fromMs: number(),
|
|
83109
|
-
toMs: number()
|
|
83139
|
+
toMs: number(),
|
|
83140
|
+
/**
|
|
83141
|
+
* Answer for THIS profile instead of the preferred one (D433). Absent
|
|
83142
|
+
* keeps the timeline's behaviour — one bar, one profile, one set of
|
|
83143
|
+
* reads. `profilesWithFootage` on the answer says what may be asked
|
|
83144
|
+
* for.
|
|
83145
|
+
*/
|
|
83146
|
+
profile: string().optional()
|
|
83110
83147
|
}), RecordingAvailabilitySchema, {
|
|
83111
83148
|
kind: "query",
|
|
83112
83149
|
auth: "protected"
|
|
@@ -98472,7 +98509,7 @@ function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_E
|
|
|
98472
98509
|
function resolveNavigationFeatures(overrides) {
|
|
98473
98510
|
return {
|
|
98474
98511
|
...DREAME_NAVIGATION_DEFAULT_FEATURES,
|
|
98475
|
-
...overrides?.features
|
|
98512
|
+
...overrides?.features
|
|
98476
98513
|
};
|
|
98477
98514
|
}
|
|
98478
98515
|
/** The disabled entry ids in force for ONE device: the addon set plus its own. */
|
|
@@ -98582,7 +98619,7 @@ var DreameNetworkLinkMonitor = class {
|
|
|
98582
98619
|
meta: {
|
|
98583
98620
|
reason,
|
|
98584
98621
|
rawBars: bars,
|
|
98585
|
-
...next
|
|
98622
|
+
...next
|
|
98586
98623
|
}
|
|
98587
98624
|
});
|
|
98588
98625
|
this.reported = true;
|
|
@@ -144138,9 +144175,28 @@ var { mulaw, alaw } = resolveAlawmulaw();
|
|
|
144138
144175
|
* name, and WE own the TCP socket — when the broker's reader detaches we know
|
|
144139
144176
|
* the viewer is gone and release the monitor at once, instead of polling the
|
|
144140
144177
|
* broker for consumers or waiting on a duration cap.
|
|
144178
|
+
*
|
|
144179
|
+
* And we own the FEED: the relay dies in three ways nodedreame reports and one
|
|
144180
|
+
* it cannot — the socket errors (`error`; with no listener an EventEmitter
|
|
144181
|
+
* throws, and on 2026-09-09 a `read ETIMEDOUT` took the whole provider down),
|
|
144182
|
+
* the relay closes and the stream stops itself (`stopped`), or the socket goes
|
|
144183
|
+
* silent for minutes before the kernel gives up (nothing at all: 16 min of a
|
|
144184
|
+
* broker dialling a port that accepted and sent nothing). The server reports
|
|
144185
|
+
* every one of them ONCE through `onFeedLost`, and never its own close, so the
|
|
144186
|
+
* device can put the lazy sentinel back and let the next dial be a wake (D424).
|
|
144141
144187
|
*/
|
|
144142
144188
|
var VIDEO_PT = 96;
|
|
144143
144189
|
var AUDIO_PT = 97;
|
|
144190
|
+
/**
|
|
144191
|
+
* How long the feed may go without a video access unit before the relay is
|
|
144192
|
+
* declared dead. The broker gives a source 8 s (`RTP_ACTIVITY_TIMEOUT_MS`);
|
|
144193
|
+
* this sits just past it so a stall is judged by the side that can act on it.
|
|
144194
|
+
*/
|
|
144195
|
+
var FEED_STALL_MS = 1e4;
|
|
144196
|
+
var FEED_STALL_SWEEP_MS = 1e3;
|
|
144197
|
+
function describeLoss(loss) {
|
|
144198
|
+
return loss.error === null ? loss.reason : `${loss.reason}: ${loss.error.message}`;
|
|
144199
|
+
}
|
|
144144
144200
|
function deferred() {
|
|
144145
144201
|
let resolve = () => void 0;
|
|
144146
144202
|
return {
|
|
@@ -144173,11 +144229,37 @@ function consoleInto(logger) {
|
|
|
144173
144229
|
* server calls `onConsumerDisconnect` when the last reader detaches.
|
|
144174
144230
|
*/
|
|
144175
144231
|
async function startRfc4571Server(params) {
|
|
144176
|
-
const { stream, logger, onConsumerDisconnect, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3 } = params;
|
|
144232
|
+
const { stream, logger, onConsumerDisconnect, onFeedLost, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3, stallTimeoutMs = FEED_STALL_MS } = params;
|
|
144177
144233
|
const videoRef = { current: null };
|
|
144178
144234
|
const audioRef = { current: null };
|
|
144179
144235
|
const firstKeyframe = deferred();
|
|
144180
144236
|
const firstAudio = deferred();
|
|
144237
|
+
const firstLoss = deferred();
|
|
144238
|
+
let pendingLoss = null;
|
|
144239
|
+
let toreDown = false;
|
|
144240
|
+
let audioFrames = 0;
|
|
144241
|
+
let videoAccessUnits = 0;
|
|
144242
|
+
let lastAudioAt = 0;
|
|
144243
|
+
let audioStallReported = false;
|
|
144244
|
+
let serving = false;
|
|
144245
|
+
let lastVideoAt = 0;
|
|
144246
|
+
const reportLoss = (loss) => {
|
|
144247
|
+
if (toreDown || pendingLoss !== null) return;
|
|
144248
|
+
pendingLoss = loss;
|
|
144249
|
+
firstLoss.resolve(loss);
|
|
144250
|
+
if (serving) onFeedLost(loss);
|
|
144251
|
+
};
|
|
144252
|
+
stream.on("error", (err) => reportLoss({
|
|
144253
|
+
reason: "error",
|
|
144254
|
+
error: err
|
|
144255
|
+
}));
|
|
144256
|
+
stream.on("stopped", () => reportLoss({
|
|
144257
|
+
reason: "stopped",
|
|
144258
|
+
error: null
|
|
144259
|
+
}));
|
|
144260
|
+
stream.on("videoAccessUnit", () => {
|
|
144261
|
+
lastVideoAt = Date.now();
|
|
144262
|
+
});
|
|
144181
144263
|
stream.on("videoAccessUnit", (e) => {
|
|
144182
144264
|
if (videoRef.current !== null) return;
|
|
144183
144265
|
const p = extractH264ParamSetsFromAccessUnit(e.data);
|
|
@@ -144203,15 +144285,24 @@ async function startRfc4571Server(params) {
|
|
|
144203
144285
|
}
|
|
144204
144286
|
});
|
|
144205
144287
|
await stream.start();
|
|
144288
|
+
const lostBeforeServing = firstLoss.promise.then((loss) => {
|
|
144289
|
+
throw new Error(`relay lost before the first keyframe (${describeLoss(loss)})`);
|
|
144290
|
+
});
|
|
144206
144291
|
try {
|
|
144207
|
-
await withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe");
|
|
144292
|
+
await Promise.race([withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe"), lostBeforeServing]);
|
|
144208
144293
|
} catch (err) {
|
|
144209
144294
|
await stream.stop().catch(() => void 0);
|
|
144210
144295
|
throw err;
|
|
144211
144296
|
}
|
|
144297
|
+
const graceStartedAt = Date.now();
|
|
144212
144298
|
await Promise.race([firstAudio.promise, new Promise((resolve) => {
|
|
144213
144299
|
setTimeout(resolve, audioGraceMs).unref?.();
|
|
144214
144300
|
})]);
|
|
144301
|
+
const waitedMs = Date.now() - graceStartedAt;
|
|
144302
|
+
if (pendingLoss !== null) {
|
|
144303
|
+
await stream.stop().catch(() => void 0);
|
|
144304
|
+
throw new Error(`relay lost before the server was up (${describeLoss(pendingLoss)})`);
|
|
144305
|
+
}
|
|
144215
144306
|
const paramSets = videoRef.current;
|
|
144216
144307
|
if (paramSets === null) throw new Error("no parameter sets after the first keyframe");
|
|
144217
144308
|
const audioFacts = audioRef.current;
|
|
@@ -144227,11 +144318,40 @@ async function startRfc4571Server(params) {
|
|
|
144227
144318
|
configHex: audioFacts.configHex
|
|
144228
144319
|
} : void 0);
|
|
144229
144320
|
const muxer = new Rfc4571Muxer(consoleInto(logger), VIDEO_PT, audioFacts !== null ? AUDIO_PT : void 0);
|
|
144230
|
-
stream.on("videoAccessUnit", (e) =>
|
|
144231
|
-
|
|
144321
|
+
stream.on("videoAccessUnit", (e) => {
|
|
144322
|
+
videoAccessUnits += 1;
|
|
144323
|
+
muxer.sendVideoAccessUnit("H264", e.data, e.isKeyframe, e.microseconds);
|
|
144324
|
+
});
|
|
144325
|
+
if (audioFacts !== null) {
|
|
144326
|
+
logger.info("rfc4571: serving audio", { meta: {
|
|
144327
|
+
codec: "aac",
|
|
144328
|
+
sampleRate: audioFacts.sampleRate,
|
|
144329
|
+
channels: audioFacts.channels,
|
|
144330
|
+
waitedMs
|
|
144331
|
+
} });
|
|
144332
|
+
lastAudioAt = Date.now();
|
|
144333
|
+
stream.on("audioFrame", (adts) => {
|
|
144334
|
+
audioFrames += 1;
|
|
144335
|
+
lastAudioAt = Date.now();
|
|
144336
|
+
muxer.sendAudioAdtsFrame(adts);
|
|
144337
|
+
});
|
|
144338
|
+
} else {
|
|
144339
|
+
logger.info("rfc4571: no audio within the grace — serving video-only", { meta: {
|
|
144340
|
+
waitedMs,
|
|
144341
|
+
audioGraceMs
|
|
144342
|
+
} });
|
|
144343
|
+
let reportedLate = false;
|
|
144344
|
+
stream.on("audioFrame", () => {
|
|
144345
|
+
if (reportedLate) return;
|
|
144346
|
+
reportedLate = true;
|
|
144347
|
+
logger.warn("rfc4571: audio arrived after the grace — this session stays video-only", { meta: {
|
|
144348
|
+
lateByMs: Date.now() - graceStartedAt - waitedMs,
|
|
144349
|
+
audioGraceMs
|
|
144350
|
+
} });
|
|
144351
|
+
});
|
|
144352
|
+
}
|
|
144232
144353
|
let liveClients = 0;
|
|
144233
144354
|
let idleTimer = null;
|
|
144234
|
-
let toreDown = false;
|
|
144235
144355
|
const server = net.createServer((socket) => {
|
|
144236
144356
|
liveClients += 1;
|
|
144237
144357
|
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
@@ -144258,6 +144378,24 @@ async function startRfc4571Server(params) {
|
|
|
144258
144378
|
});
|
|
144259
144379
|
const addr = server.address();
|
|
144260
144380
|
if (addr === null || typeof addr === "string") throw new Error("failed to bind the RFC4571 server");
|
|
144381
|
+
const stallSweep = setInterval(() => {
|
|
144382
|
+
if (toreDown) return;
|
|
144383
|
+
if (audioFacts !== null && !audioStallReported && Date.now() - lastAudioAt >= stallTimeoutMs) {
|
|
144384
|
+
audioStallReported = true;
|
|
144385
|
+
logger.warn("rfc4571: audio stopped while the video kept flowing", { meta: {
|
|
144386
|
+
audioFrames,
|
|
144387
|
+
videoAccessUnits,
|
|
144388
|
+
silentForMs: Date.now() - lastAudioAt
|
|
144389
|
+
} });
|
|
144390
|
+
}
|
|
144391
|
+
if (Date.now() - lastVideoAt < stallTimeoutMs) return;
|
|
144392
|
+
reportLoss({
|
|
144393
|
+
reason: "stalled",
|
|
144394
|
+
error: null
|
|
144395
|
+
});
|
|
144396
|
+
}, Math.max(5, Math.min(FEED_STALL_SWEEP_MS, Math.floor(stallTimeoutMs / 2))));
|
|
144397
|
+
stallSweep.unref?.();
|
|
144398
|
+
serving = true;
|
|
144261
144399
|
return {
|
|
144262
144400
|
host: bindHost,
|
|
144263
144401
|
port: addr.port,
|
|
@@ -144269,6 +144407,12 @@ async function startRfc4571Server(params) {
|
|
|
144269
144407
|
} : null,
|
|
144270
144408
|
close: async () => {
|
|
144271
144409
|
toreDown = true;
|
|
144410
|
+
logger.info("rfc4571: session ended", { meta: {
|
|
144411
|
+
videoAccessUnits,
|
|
144412
|
+
audioFrames,
|
|
144413
|
+
declaredAudio: audioFacts !== null
|
|
144414
|
+
} });
|
|
144415
|
+
clearInterval(stallSweep);
|
|
144272
144416
|
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
144273
144417
|
try {
|
|
144274
144418
|
muxer.close();
|
|
@@ -144548,6 +144692,9 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
144548
144692
|
keyframeTimeoutMs: WAKE_TIMEOUT_MS,
|
|
144549
144693
|
onConsumerDisconnect: () => {
|
|
144550
144694
|
this.closeStream("idle");
|
|
144695
|
+
},
|
|
144696
|
+
onFeedLost: (loss) => {
|
|
144697
|
+
this.onFeedLost(loss);
|
|
144551
144698
|
}
|
|
144552
144699
|
});
|
|
144553
144700
|
this.live = {
|
|
@@ -144587,6 +144734,22 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
144587
144734
|
});
|
|
144588
144735
|
return this.opening;
|
|
144589
144736
|
}
|
|
144737
|
+
/**
|
|
144738
|
+
* The feed died under an open monitor. The session is closed — the lazy
|
|
144739
|
+
* sentinel goes back on the broker — so the broker's next dial asks for a
|
|
144740
|
+
* materialize, which is the wake. Logged: this branch drops a live stream.
|
|
144741
|
+
*/
|
|
144742
|
+
onFeedLost(loss) {
|
|
144743
|
+
this.ctx.logger.warn("robot camera: relay feed lost — closing the monitor; the next dial re-opens it", {
|
|
144744
|
+
tags: { deviceId: this.id },
|
|
144745
|
+
meta: {
|
|
144746
|
+
reason: loss.reason,
|
|
144747
|
+
error: loss.error === null ? null : errMsg(loss.error),
|
|
144748
|
+
openMs: Date.now() - this.sessionOpenedAt
|
|
144749
|
+
}
|
|
144750
|
+
});
|
|
144751
|
+
this.closeStream("feed-lost");
|
|
144752
|
+
}
|
|
144590
144753
|
armMaxDuration() {
|
|
144591
144754
|
if (this.maxTimer !== null) clearTimeout(this.maxTimer);
|
|
144592
144755
|
this.maxTimer = setTimeout(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-provider-dreame",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.94",
|
|
4
4
|
"description": "Dreame robot-vacuum / lawn-mower device-provider addon for CamStack — wraps the @apocaliss92/nodedreame Dreamehome cloud client",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|