@apocaliss92/nodedreame 1.11.10 → 1.12.0

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/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/support/version.ts
2
2
  var LIBRARY_NAME = "nodedreame";
3
- var LIBRARY_VERSION = "1.11.10";
3
+ var LIBRARY_VERSION = "1.12.0";
4
4
 
5
5
  // src/transport/errors.ts
6
6
  var DreameError = class extends Error {
@@ -385,6 +385,34 @@ async function postForToken(ctx, body) {
385
385
  return session;
386
386
  }
387
387
 
388
+ // src/cloud/connectivity.ts
389
+ var str = (v) => typeof v === "string" && v.length > 0 ? v : null;
390
+ var numOrNull = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
391
+ function parseBroker(bindDomain) {
392
+ const s = str(bindDomain);
393
+ if (!s) return null;
394
+ const idx = s.lastIndexOf(":");
395
+ if (idx < 0) return { host: s, port: null };
396
+ const port = Number(s.slice(idx + 1));
397
+ return { host: s.slice(0, idx), port: Number.isFinite(port) ? port : null };
398
+ }
399
+ function parseConnectivity(raw) {
400
+ const deviceInfo = raw["deviceInfo"] ?? {};
401
+ return {
402
+ online: raw["online"] === true,
403
+ connectionType: str(deviceInfo["scType"]),
404
+ mac: str(raw["mac"]),
405
+ broker: parseBroker(raw["bindDomain"]),
406
+ region: str(raw["region"]),
407
+ cloudVendor: str(raw["vendor"]),
408
+ firmwareVersion: str(raw["ver"]),
409
+ serialNumber: str(raw["sn"]),
410
+ subModel: str(raw["subModel"]),
411
+ battery: numOrNull(raw["battery"]),
412
+ statusCode: numOrNull(raw["latestStatus"])
413
+ };
414
+ }
415
+
388
416
  // src/cloud/devices.ts
389
417
  async function listDevices(input) {
390
418
  const ctx = input.ctx ?? RequestContext.from({ ...input, host: input.apiHost });
@@ -429,7 +457,8 @@ function toDevice(rawIn) {
429
457
  name,
430
458
  online,
431
459
  raw,
432
- cloudState: parseCloudState(raw)
460
+ cloudState: parseCloudState(raw),
461
+ connectivity: parseConnectivity(raw)
433
462
  };
434
463
  if (rawIn.mac) {
435
464
  device.mac = String(rawIn.mac);
@@ -629,6 +658,10 @@ var TypedEmitter = class {
629
658
  emit(event, ...args) {
630
659
  return this.#emitter.emit(event, ...args);
631
660
  }
661
+ /** Number of listeners registered for `event`. */
662
+ listenerCount(event) {
663
+ return this.#emitter.listenerCount(event);
664
+ }
632
665
  removeAllListeners() {
633
666
  this.#emitter.removeAllListeners();
634
667
  return this;
@@ -641,6 +674,24 @@ function defaultConnect(url, opts) {
641
674
  const typed = client;
642
675
  return typed;
643
676
  }
677
+ function isAuthRefusedError(err) {
678
+ let cur = err;
679
+ for (let depth = 0; cur != null && depth < 6; depth += 1) {
680
+ if (typeof cur !== "object") {
681
+ break;
682
+ }
683
+ const code = cur.code;
684
+ if (code === 4 || code === 5 || code === 134 || code === 135) {
685
+ return true;
686
+ }
687
+ const message = cur.message;
688
+ if (typeof message === "string" && /not authorized|bad user ?name|bad username or password|bad password/i.test(message)) {
689
+ return true;
690
+ }
691
+ cur = cur.cause;
692
+ }
693
+ return false;
694
+ }
644
695
  var DreamePush = class extends TypedEmitter {
645
696
  #device;
646
697
  #session;
@@ -649,11 +700,15 @@ var DreamePush = class extends TypedEmitter {
649
700
  #backoffMs;
650
701
  #keepaliveSeconds;
651
702
  #rejectUnauthorized;
703
+ #onAuthFailure;
704
+ #maxAuthRetries;
652
705
  #topic;
653
706
  #client = null;
654
707
  #closed = false;
655
708
  #tearingDown = false;
656
709
  #reconnectTimer = null;
710
+ /** Consecutive auth-refused re-auths since the last successful connect. */
711
+ #authFailureCount = 0;
657
712
  /**
658
713
  * Per-instance bound close handler. Stored so we can pass the EXACT same
659
714
  * function reference to both `client.on('close', …)` and
@@ -670,6 +725,8 @@ var DreamePush = class extends TypedEmitter {
670
725
  this.#backoffMs = input.reconnectBackoffMs ?? 5e3;
671
726
  this.#keepaliveSeconds = input.keepaliveSeconds ?? 60;
672
727
  this.#rejectUnauthorized = input.rejectUnauthorized ?? false;
728
+ this.#onAuthFailure = input.onAuthFailure ?? null;
729
+ this.#maxAuthRetries = input.maxAuthRetries ?? 3;
673
730
  this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
674
731
  this.#onClose = () => {
675
732
  if (this.#tearingDown) {
@@ -706,7 +763,11 @@ var DreamePush = class extends TypedEmitter {
706
763
  this.#reconnectTimer = null;
707
764
  }
708
765
  await this.#teardownClient();
709
- await this.#connectAndSubscribe();
766
+ try {
767
+ await this.#connectAndSubscribe();
768
+ } catch {
769
+ this.#scheduleReconnect();
770
+ }
710
771
  }
711
772
  /** Tear down permanently. Closed subscriptions cannot be reopened. */
712
773
  async close() {
@@ -743,6 +804,7 @@ var DreamePush = class extends TypedEmitter {
743
804
  reject(new DreameTransportError(`mqtt subscribe failed: ${err.message}`, err));
744
805
  return;
745
806
  }
807
+ this.#authFailureCount = 0;
746
808
  this.emit("connect");
747
809
  resolve();
748
810
  });
@@ -776,12 +838,55 @@ var DreamePush = class extends TypedEmitter {
776
838
  if (this.#closed || this.#client) {
777
839
  return;
778
840
  }
779
- void this.#connectAndSubscribe().catch((err) => {
780
- this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
781
- this.#scheduleReconnect();
782
- });
841
+ void this.#attemptReconnect();
783
842
  }, this.#backoffMs);
784
843
  }
844
+ /**
845
+ * One reconnect attempt with reactive auth self-heal. If the broker refuses
846
+ * the CONNECT with an auth error (a stale access token) and an
847
+ * `onAuthFailure` provider is wired and the re-auth budget is not exhausted,
848
+ * mint a fresh session and retry with the new token instead of replaying the
849
+ * old one. Any other failure (or an exhausted budget) falls back to the plain
850
+ * backoff reconnect so bad credentials cannot hot-loop re-login.
851
+ */
852
+ async #attemptReconnect() {
853
+ try {
854
+ await this.#connectAndSubscribe();
855
+ } catch (err) {
856
+ if (this.#closed) {
857
+ return;
858
+ }
859
+ if (this.#onAuthFailure && isAuthRefusedError(err) && this.#authFailureCount < this.#maxAuthRetries) {
860
+ await this.#reauthenticateAndReconnect();
861
+ return;
862
+ }
863
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
864
+ this.#scheduleReconnect();
865
+ }
866
+ }
867
+ /** Mint a fresh session via `onAuthFailure`, adopt its token, and reconnect. */
868
+ async #reauthenticateAndReconnect() {
869
+ this.#authFailureCount += 1;
870
+ let session;
871
+ try {
872
+ session = await this.#onAuthFailure();
873
+ } catch (err) {
874
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
875
+ if (!this.#client) {
876
+ this.#scheduleReconnect();
877
+ }
878
+ return;
879
+ }
880
+ if (this.#closed) {
881
+ return;
882
+ }
883
+ this.#session = session;
884
+ this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
885
+ if (this.#client) {
886
+ return;
887
+ }
888
+ void this.#attemptReconnect();
889
+ }
785
890
  async #teardownClient() {
786
891
  const client = this.#client;
787
892
  this.#client = null;
@@ -957,7 +1062,12 @@ function resolveCapabilities(model) {
957
1062
  // src/device/base-device.ts
958
1063
  function defaultBaseDeviceDeps() {
959
1064
  return {
960
- createPush: (device, session, region) => new DreamePush({ device, session, region }),
1065
+ createPush: (device, session, region, onAuthFailure) => new DreamePush({
1066
+ device,
1067
+ session,
1068
+ region,
1069
+ ...onAuthFailure !== void 0 ? { onAuthFailure } : {}
1070
+ }),
961
1071
  getProperties: (base, props) => getProperties(base, props),
962
1072
  getCachedProperties: (base, props) => getCachedProperties(base, props),
963
1073
  setProperties: (base, writes) => setProperties(base, writes),
@@ -969,6 +1079,7 @@ var BaseDevice = class extends TypedEmitter {
969
1079
  #device;
970
1080
  #region;
971
1081
  #sessionRef;
1082
+ #onAuthFailure;
972
1083
  #deps;
973
1084
  #fetchInitial;
974
1085
  #initialProps;
@@ -984,6 +1095,7 @@ var BaseDevice = class extends TypedEmitter {
984
1095
  this.#device = input.device;
985
1096
  this.#region = input.region;
986
1097
  this.#sessionRef = input.sessionRef;
1098
+ this.#onAuthFailure = input.onAuthFailure;
987
1099
  this.#deps = input.deps ?? defaultBaseDeviceDeps();
988
1100
  this.#fetchInitial = input.fetchInitialValues ?? true;
989
1101
  this.#initialProps = input.initialProps ?? [];
@@ -1023,7 +1135,12 @@ var BaseDevice = class extends TypedEmitter {
1023
1135
  }
1024
1136
  /** Open the push, wire events, optionally seed the cache. */
1025
1137
  async start() {
1026
- const push = this.#deps.createPush(this.#device, this.#sessionRef(), this.#region);
1138
+ const push = this.#deps.createPush(
1139
+ this.#device,
1140
+ this.#sessionRef(),
1141
+ this.#region,
1142
+ this.#onAuthFailure
1143
+ );
1027
1144
  this.#push = push;
1028
1145
  push.on("properties", (changes) => this.#onProperties(changes));
1029
1146
  push.on(
@@ -1926,8 +2043,1015 @@ var VacuumCapabilityResolver = class {
1926
2043
  }
1927
2044
  };
1928
2045
 
2046
+ // src/video/schemas.ts
2047
+ import { z as z3 } from "zod";
2048
+ var VideoAccessTokenResponseSchema = z3.object({
2049
+ code: z3.number().optional(),
2050
+ msg: z3.string().nullish(),
2051
+ data: z3.object({
2052
+ data: z3.object({
2053
+ token: z3.string(),
2054
+ userId: z3.union([z3.string(), z3.number()]).optional(),
2055
+ expireAt: z3.number().optional()
2056
+ }).passthrough()
2057
+ }).passthrough()
2058
+ }).passthrough();
2059
+ var AuthCodeResponseSchema = z3.object({
2060
+ code: z3.number().optional(),
2061
+ msg: z3.string().nullish(),
2062
+ data: z3.string()
2063
+ }).passthrough();
2064
+ var FamilyIdResponseSchema = z3.object({
2065
+ code: z3.number().optional(),
2066
+ msg: z3.string().nullish(),
2067
+ data: z3.object({ data: z3.object({ familyId: z3.string() }).passthrough() }).passthrough()
2068
+ }).passthrough();
2069
+ var DeviceVideoInfoSchema = z3.object({
2070
+ model: z3.string().optional(),
2071
+ displayName: z3.string().optional(),
2072
+ permit: z3.string().optional(),
2073
+ videoDynamicVendor: z3.boolean().optional(),
2074
+ defaultVendors: z3.array(z3.string()).optional(),
2075
+ productId: z3.union([z3.string(), z3.number()]).optional()
2076
+ }).passthrough();
2077
+ var DeviceInfoRecordSchema = z3.object({
2078
+ did: z3.union([z3.string(), z3.number()]).optional(),
2079
+ model: z3.string().optional(),
2080
+ customName: z3.string().optional(),
2081
+ vendor: z3.string().optional(),
2082
+ online: z3.boolean().optional(),
2083
+ lwt: z3.number().optional(),
2084
+ property: z3.string().optional(),
2085
+ bindDomain: z3.string().optional(),
2086
+ deviceInfo: DeviceVideoInfoSchema.optional()
2087
+ }).passthrough();
2088
+ var DeviceInfoResponseSchema = z3.object({
2089
+ code: z3.number().optional(),
2090
+ msg: z3.string().nullish(),
2091
+ data: DeviceInfoRecordSchema
2092
+ }).passthrough();
2093
+
2094
+ // src/video/profile.ts
2095
+ var KNOWN_VENDORS = ["tx", "ali"];
2096
+ function asVendor(v) {
2097
+ return v === "tx" || v === "ali" ? v : null;
2098
+ }
2099
+ function parseProperty(property) {
2100
+ if (!property) {
2101
+ return { iotId: null, lwt: null };
2102
+ }
2103
+ try {
2104
+ const p = JSON.parse(property);
2105
+ return {
2106
+ iotId: typeof p.iotId === "string" && p.iotId.length > 0 ? p.iotId : null,
2107
+ lwt: typeof p.lwt === "number" ? p.lwt : null
2108
+ };
2109
+ } catch {
2110
+ return { iotId: null, lwt: null };
2111
+ }
2112
+ }
2113
+ function toVideoProfile(record) {
2114
+ const info = record.deviceInfo ?? {};
2115
+ const supportedVendors = (info.defaultVendors ?? []).filter(
2116
+ (v) => KNOWN_VENDORS.includes(v)
2117
+ );
2118
+ const prop = parseProperty(record.property);
2119
+ const online = record.online === true || record.lwt === 1 || prop.lwt === 1;
2120
+ return {
2121
+ did: String(record.did ?? ""),
2122
+ model: String(record.model ?? info.model ?? ""),
2123
+ displayName: info.displayName ?? null,
2124
+ videoCapable: info.permit === "video" || supportedVendors.length > 0,
2125
+ currentVendor: asVendor(record.vendor),
2126
+ supportedVendors,
2127
+ dynamicVendor: info.videoDynamicVendor === true,
2128
+ iotId: prop.iotId,
2129
+ online
2130
+ };
2131
+ }
2132
+
2133
+ // src/video/client.ts
2134
+ var P_THIRD_VIDEO = "/dreame-third-video";
2135
+ var P_SMARTHOME = "/dreame-smarthome";
2136
+ var P_USER_IOT = "/dreame-user-iot";
2137
+ var DEFAULT_OS = "android";
2138
+ function resolveCtx(input) {
2139
+ return input.ctx ?? RequestContext.from({ ...input, host: input.apiHost });
2140
+ }
2141
+ function passthrough(input) {
2142
+ return {
2143
+ accessToken: input.session.accessToken,
2144
+ ...input.signal !== void 0 ? { signal: input.signal } : {},
2145
+ ...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {}
2146
+ };
2147
+ }
2148
+ async function getVideoAccessToken(input) {
2149
+ const ctx = resolveCtx(input);
2150
+ const raw = await httpPostJsonBody({
2151
+ ctx,
2152
+ path: `${P_THIRD_VIDEO}/tx/user/accesstoken`,
2153
+ body: { os: DEFAULT_OS },
2154
+ context: "video accesstoken",
2155
+ ...passthrough(input)
2156
+ });
2157
+ const parsed = VideoAccessTokenResponseSchema.parse(raw);
2158
+ const d = parsed.data.data;
2159
+ return {
2160
+ token: d.token,
2161
+ userId: d.userId !== void 0 ? String(d.userId) : null,
2162
+ // Cloud reports expiry in epoch-seconds; expose epoch-ms like DreameSession.
2163
+ expiresAt: d.expireAt !== void 0 ? d.expireAt * 1e3 : 0
2164
+ };
2165
+ }
2166
+ async function getAliyunAuthCode(input) {
2167
+ const ctx = resolveCtx(input);
2168
+ const raw = await httpPostJsonBody({
2169
+ ctx,
2170
+ path: `${P_SMARTHOME}/aliIot/getAuthCodeV3`,
2171
+ body: { os: DEFAULT_OS },
2172
+ context: "aliyun authCode",
2173
+ ...passthrough(input)
2174
+ });
2175
+ return AuthCodeResponseSchema.parse(raw).data;
2176
+ }
2177
+ async function getVideoFamilyId(input) {
2178
+ const ctx = resolveCtx(input);
2179
+ const videoToken = input.videoToken ?? (await getVideoAccessToken({ ...input, ctx })).token;
2180
+ const raw = await httpPostJsonBody({
2181
+ ctx,
2182
+ path: `${P_THIRD_VIDEO}/tx/mgr/family/getFamilyId`,
2183
+ body: { accesstoken: videoToken, os: DEFAULT_OS },
2184
+ context: "video familyId",
2185
+ ...passthrough(input)
2186
+ });
2187
+ return FamilyIdResponseSchema.parse(raw).data.data.familyId;
2188
+ }
2189
+ async function getDeviceVideoProfile(input) {
2190
+ const ctx = resolveCtx(input);
2191
+ const raw = await httpPostJsonBody({
2192
+ ctx,
2193
+ path: `${P_USER_IOT}/iotuserbind/device/info`,
2194
+ body: { did: input.did },
2195
+ context: "device video info",
2196
+ ...passthrough(input)
2197
+ });
2198
+ return toVideoProfile(DeviceInfoResponseSchema.parse(raw).data);
2199
+ }
2200
+
2201
+ // src/video/aliyun/constants.ts
2202
+ var ALIYUN_APP_KEY = "33974273";
2203
+ var ALIYUN_APP_SECRET = "65a62f03ae5eda97af962fecc2e3efa6";
2204
+ var ALIYUN_REGION_ID = {
2205
+ eu: "eu-central-1",
2206
+ de: "eu-central-1",
2207
+ us: "us-east-1",
2208
+ cn: "cn-shanghai",
2209
+ ru: "eu-central-1",
2210
+ sg: "ap-southeast-1",
2211
+ in: "ap-southeast-1",
2212
+ tw: "cn-shanghai"
2213
+ };
2214
+ function livingAccountHost(regionId) {
2215
+ return `living-account.${regionId}.aliyuncs.com`;
2216
+ }
2217
+ function apiIotHost(regionId) {
2218
+ return `${regionId}.api-iot.aliyuncs.com`;
2219
+ }
2220
+ var VISION_STREAM_QUERY = "/vision/customer/stream/query";
2221
+
2222
+ // src/video/aliyun/identity.ts
2223
+ import { randomUUID } from "crypto";
2224
+ import { z as z4 } from "zod";
2225
+
2226
+ // src/video/aliyun/signing.ts
2227
+ import { createHash as createHash2, createHmac } from "crypto";
2228
+ var CT_JSON = "application/json; charset=UTF-8";
2229
+ var CT_FORM = "application/x-www-form-urlencoded; charset=UTF-8";
2230
+ function md5Base64(s) {
2231
+ return createHash2("md5").update(s, "utf8").digest("base64");
2232
+ }
2233
+ function signApiGatewayRequest(input) {
2234
+ const accept = CT_JSON;
2235
+ const contentType = input.json !== void 0 ? CT_JSON : CT_FORM;
2236
+ const date = input.date ?? (/* @__PURE__ */ new Date()).toUTCString();
2237
+ let body;
2238
+ let contentMd5 = "";
2239
+ let formParams;
2240
+ if (input.json !== void 0) {
2241
+ body = JSON.stringify(input.json);
2242
+ contentMd5 = md5Base64(body);
2243
+ } else {
2244
+ const form = input.form ?? {};
2245
+ body = Object.entries(form).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
2246
+ formParams = form;
2247
+ }
2248
+ const caHeaders = {
2249
+ "X-Ca-Key": input.appKey,
2250
+ "X-Ca-Nonce": input.nonce ?? crypto.randomUUID().toUpperCase(),
2251
+ "X-Ca-Timestamp": String(input.timestamp ?? Date.now()),
2252
+ "X-Ca-Version": "1",
2253
+ ...input.extraCaHeaders ?? {}
2254
+ };
2255
+ const signedHeaderNames = Object.keys(caHeaders).sort(
2256
+ (a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0
2257
+ );
2258
+ const headerBlock = signedHeaderNames.map((k) => `${k}:${caHeaders[k]}
2259
+ `).join("");
2260
+ let resource = input.path;
2261
+ if (formParams && Object.keys(formParams).length > 0) {
2262
+ const sorted = Object.keys(formParams).sort();
2263
+ resource += "?" + sorted.map((k) => `${k}=${formParams[k]}`).join("&");
2264
+ }
2265
+ const stringToSign = `${input.method}
2266
+ ${accept}
2267
+ ${contentMd5}
2268
+ ${contentType}
2269
+ ${date}
2270
+ ${headerBlock}${resource}`;
2271
+ const signature = createHmac("sha1", input.appSecret).update(stringToSign, "utf8").digest("base64");
2272
+ const headers = {
2273
+ "Content-Type": contentType,
2274
+ Accept: accept,
2275
+ Date: date,
2276
+ "X-Ca-Signature": signature,
2277
+ "X-Ca-Signature-Method": "HmacSHA1",
2278
+ "X-Ca-Signature-Headers": signedHeaderNames.join(","),
2279
+ ...caHeaders
2280
+ };
2281
+ if (contentMd5) {
2282
+ headers["Content-MD5"] = contentMd5;
2283
+ }
2284
+ return { headers, body };
2285
+ }
2286
+
2287
+ // src/video/aliyun/identity.ts
2288
+ var LoginByOauthSchema = z4.object({
2289
+ data: z4.object({
2290
+ data: z4.object({
2291
+ loginSuccessResult: z4.object({
2292
+ sid: z4.string(),
2293
+ refreshToken: z4.string().optional(),
2294
+ token: z4.string().optional(),
2295
+ openAccount: z4.object({ openId: z4.string().optional() }).passthrough().optional()
2296
+ }).passthrough()
2297
+ }).passthrough()
2298
+ }).passthrough()
2299
+ }).passthrough();
2300
+ var CreateSessionSchema = z4.object({
2301
+ code: z4.number().optional(),
2302
+ message: z4.string().nullish(),
2303
+ data: z4.object({
2304
+ iotToken: z4.string(),
2305
+ refreshToken: z4.string().optional(),
2306
+ identityId: z4.string().optional(),
2307
+ iotTokenExpire: z4.number().optional()
2308
+ }).passthrough()
2309
+ }).passthrough();
2310
+ async function postJson(url, headers, body, fetchImpl, ctx) {
2311
+ const res = await fetchImpl(url, { method: "POST", headers, body });
2312
+ const text = await res.text();
2313
+ try {
2314
+ return JSON.parse(text);
2315
+ } catch {
2316
+ throw new DreameApiError(`${ctx} returned non-JSON (status ${res.status})`, res.status);
2317
+ }
2318
+ }
2319
+ async function loginByOauth(input) {
2320
+ const host = livingAccountHost(input.regionId);
2321
+ const riskControlInfo = {
2322
+ createIfNotExist: "true",
2323
+ sdkVersion: "3.4.0.27",
2324
+ openaccountVersion: "3.4.0.27",
2325
+ platformName: "iOS",
2326
+ netType: "wifi",
2327
+ brand: "Apple",
2328
+ USE_H5_NC: "true",
2329
+ deviceId: input.deviceId ?? randomUUID(),
2330
+ locale: "en-US",
2331
+ platformVersion: "26.5.2",
2332
+ platform: "iPhone",
2333
+ appKey: ALIYUN_APP_KEY,
2334
+ USE_OA_PWD_ENCRYPT: "true",
2335
+ model: "iPhone16,2",
2336
+ utdid: input.utdid ?? randomUUID().replace(/-/g, "").slice(0, 20),
2337
+ appVersion: "1895"
2338
+ };
2339
+ const value = JSON.stringify({
2340
+ riskControlInfo,
2341
+ oauthPlateform: 23,
2342
+ oauthAppKey: ALIYUN_APP_KEY,
2343
+ authCode: input.authCode
2344
+ });
2345
+ const signed = signApiGatewayRequest({
2346
+ method: "POST",
2347
+ host,
2348
+ path: "/api/prd/loginbyoauth.json",
2349
+ appKey: ALIYUN_APP_KEY,
2350
+ appSecret: ALIYUN_APP_SECRET,
2351
+ form: { loginByOauthRequest: value }
2352
+ });
2353
+ const json = await postJson(
2354
+ `https://${host}/api/prd/loginbyoauth.json`,
2355
+ signed.headers,
2356
+ signed.body,
2357
+ input.fetchImpl ?? defaultFetch,
2358
+ "loginbyoauth"
2359
+ );
2360
+ const r = LoginByOauthSchema.parse(json).data.data.loginSuccessResult;
2361
+ return {
2362
+ sid: r.sid,
2363
+ refreshToken: r.refreshToken ?? "",
2364
+ openId: r.openAccount?.openId ?? ""
2365
+ };
2366
+ }
2367
+ async function createIotSession(input) {
2368
+ const host = apiIotHost(input.regionId);
2369
+ const path = "/account/createSessionByAuthCode";
2370
+ const payload = {
2371
+ id: randomUUID().toUpperCase(),
2372
+ params: { request: { appKey: ALIYUN_APP_KEY, authCode: input.sid, accountType: "OA_SESSION" } },
2373
+ request: { apiVer: "1.0.4", language: "en-US", appKey: ALIYUN_APP_KEY },
2374
+ version: "1.0.0"
2375
+ };
2376
+ const signed = signApiGatewayRequest({
2377
+ method: "POST",
2378
+ host,
2379
+ path,
2380
+ appKey: ALIYUN_APP_KEY,
2381
+ appSecret: ALIYUN_APP_SECRET,
2382
+ json: payload,
2383
+ extraCaHeaders: { "X-Ca-Stage": "RELEASE" }
2384
+ });
2385
+ const json = await postJson(
2386
+ `https://${host}${path}`,
2387
+ signed.headers,
2388
+ signed.body,
2389
+ input.fetchImpl ?? defaultFetch,
2390
+ "createSessionByAuthCode"
2391
+ );
2392
+ const parsed = CreateSessionSchema.parse(json);
2393
+ if (parsed.code !== 200 && parsed.code !== 0) {
2394
+ throw new DreameApiError(`createSession rejected: code=${parsed.code} msg=${parsed.message ?? "?"}`, 200, parsed);
2395
+ }
2396
+ const d = parsed.data;
2397
+ return {
2398
+ iotToken: d.iotToken,
2399
+ refreshToken: d.refreshToken ?? "",
2400
+ identityId: d.identityId ?? "",
2401
+ expiresAt: Date.now() + (d.iotTokenExpire ?? 72e3) * 1e3
2402
+ };
2403
+ }
2404
+
2405
+ // src/video/aliyun/vision.ts
2406
+ import { z as z5 } from "zod";
2407
+ var VISION_DEVICE_OFFLINE = 9201;
2408
+ var StreamQueryResponseSchema = z5.object({
2409
+ code: z5.number().optional(),
2410
+ message: z5.string().nullish(),
2411
+ data: z5.object({
2412
+ typeList: z5.array(z5.string()).optional(),
2413
+ relayUrl: z5.string().optional(),
2414
+ relayDecryptKey: z5.object({ iv: z5.string().optional(), key: z5.string().optional() }).passthrough().optional(),
2415
+ p2pInfo: z5.object({
2416
+ stunUrl: z5.string().optional(),
2417
+ alterStunUrl: z5.string().optional(),
2418
+ signalUrl: z5.string().optional(),
2419
+ portDetectStunUrlList: z5.array(z5.string()).optional()
2420
+ }).passthrough().optional(),
2421
+ supportVisionPtz: z5.boolean().optional()
2422
+ }).passthrough().optional()
2423
+ }).passthrough();
2424
+ async function streamQuery(input) {
2425
+ const appKey = input.appKey ?? ALIYUN_APP_KEY;
2426
+ const appSecret = input.appSecret ?? ALIYUN_APP_SECRET;
2427
+ const host = apiIotHost(input.regionId);
2428
+ const payload = {
2429
+ id: crypto.randomUUID().toUpperCase(),
2430
+ params: {
2431
+ streamType: input.streamType ?? 0,
2432
+ needDomainName: true,
2433
+ relayEncryptType: 0,
2434
+ clientType: "iOS",
2435
+ iotId: input.iotId,
2436
+ enablePortPredict: true,
2437
+ enableWebSocket: true,
2438
+ forceIFrame: true,
2439
+ cacheDuration: 3e3,
2440
+ relayEncrypted: input.relayEncrypted ?? false
2441
+ },
2442
+ request: { language: "en-US", appKey, iotToken: input.iotToken, apiVer: "2.2.0" },
2443
+ version: "1.0.0"
2444
+ };
2445
+ const signed = signApiGatewayRequest({
2446
+ method: "POST",
2447
+ host,
2448
+ path: VISION_STREAM_QUERY,
2449
+ appKey,
2450
+ appSecret,
2451
+ json: payload,
2452
+ extraCaHeaders: { "X-Ca-Stage": "RELEASE" }
2453
+ });
2454
+ const fetchImpl = input.fetchImpl ?? defaultFetch;
2455
+ const res = await fetchImpl(`https://${host}${VISION_STREAM_QUERY}`, {
2456
+ method: "POST",
2457
+ headers: signed.headers,
2458
+ body: signed.body,
2459
+ ...input.signal ? { signal: input.signal } : {}
2460
+ });
2461
+ const text = await res.text();
2462
+ let json;
2463
+ try {
2464
+ json = JSON.parse(text);
2465
+ } catch {
2466
+ throw new DreameApiError(`stream/query returned non-JSON (status ${res.status})`, res.status);
2467
+ }
2468
+ const parsed = StreamQueryResponseSchema.parse(json);
2469
+ if (parsed.code === VISION_DEVICE_OFFLINE) {
2470
+ throw new DreameDeviceOfflineError(
2471
+ `stream/query: device video agent offline (code ${VISION_DEVICE_OFFLINE}) \u2014 retry to wake`,
2472
+ res.status,
2473
+ parsed
2474
+ );
2475
+ }
2476
+ if (parsed.code !== 200 && parsed.code !== 0) {
2477
+ throw new DreameApiError(
2478
+ `stream/query rejected: code=${parsed.code} msg=${parsed.message ?? "?"}`,
2479
+ res.status,
2480
+ parsed
2481
+ );
2482
+ }
2483
+ const d = parsed.data ?? {};
2484
+ const dk = d.relayDecryptKey;
2485
+ const hasKey = dk && dk.iv && dk.key;
2486
+ const p = d.p2pInfo;
2487
+ const p2pInfo = p ? {
2488
+ ...p.stunUrl !== void 0 ? { stunUrl: p.stunUrl } : {},
2489
+ ...p.alterStunUrl !== void 0 ? { alterStunUrl: p.alterStunUrl } : {},
2490
+ ...p.signalUrl !== void 0 ? { signalUrl: p.signalUrl } : {},
2491
+ ...p.portDetectStunUrlList !== void 0 ? { portDetectStunUrlList: p.portDetectStunUrlList } : {}
2492
+ } : null;
2493
+ return {
2494
+ typeList: d.typeList ?? [],
2495
+ relayUrl: d.relayUrl ?? null,
2496
+ relayDecryptKey: hasKey ? { iv: dk.iv, key: dk.key } : null,
2497
+ p2pInfo,
2498
+ ...d.supportVisionPtz !== void 0 ? { supportVisionPtz: d.supportVisionPtz } : {}
2499
+ };
2500
+ }
2501
+
2502
+ // src/video/aliyun/session.ts
2503
+ var DEFAULT_WAKE_TIMEOUT_MS = 3e4;
2504
+ var DEFAULT_WAKE_POLL_MS = 2e3;
2505
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2506
+ var REFRESH_LEEWAY_MS = 6e4;
2507
+ var DreameVideoSession = class {
2508
+ #session;
2509
+ #region;
2510
+ #regionId;
2511
+ #fetchImpl;
2512
+ #iot = null;
2513
+ #inFlight = null;
2514
+ constructor(input) {
2515
+ this.#session = input.session;
2516
+ this.#region = input.region;
2517
+ this.#regionId = ALIYUN_REGION_ID[input.region];
2518
+ this.#fetchImpl = input.fetchImpl;
2519
+ }
2520
+ /** The resolved Aliyun IoT region id (e.g. `eu-central-1`). */
2521
+ get regionId() {
2522
+ return this.#regionId;
2523
+ }
2524
+ /** Mint (or reuse) a valid Aliyun IoT session, refreshing before expiry. */
2525
+ async ensureIotSession() {
2526
+ const cur = this.#iot;
2527
+ if (cur && Date.now() < cur.expiresAt - REFRESH_LEEWAY_MS) {
2528
+ return cur;
2529
+ }
2530
+ if (this.#inFlight) {
2531
+ return this.#inFlight;
2532
+ }
2533
+ this.#inFlight = this.#mint();
2534
+ try {
2535
+ this.#iot = await this.#inFlight;
2536
+ return this.#iot;
2537
+ } finally {
2538
+ this.#inFlight = null;
2539
+ }
2540
+ }
2541
+ async #mint() {
2542
+ const base = {
2543
+ session: this.#session,
2544
+ region: this.#region,
2545
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
2546
+ };
2547
+ const authCode = await getAliyunAuthCode(base);
2548
+ const oa = await loginByOauth({
2549
+ authCode,
2550
+ regionId: this.#regionId,
2551
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
2552
+ });
2553
+ return createIotSession({
2554
+ sid: oa.sid,
2555
+ regionId: this.#regionId,
2556
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
2557
+ });
2558
+ }
2559
+ /**
2560
+ * Mint a fresh LinkVisual stream descriptor for a device. `encrypted:false`
2561
+ * (default) yields a plain RTMP relay URL directly playable by ffmpeg.
2562
+ *
2563
+ * A camera that has been idle reports "device offline" until the query wakes
2564
+ * its video agent via the LinkVisual cloud. This transparently retries on that
2565
+ * (up to `wakeTimeoutMs`, default 30s) so a single call returns a live stream;
2566
+ * pass `wakeTimeoutMs: 0` to fail fast instead.
2567
+ */
2568
+ async getStreamInfo(iotId, opts) {
2569
+ const deadline = Date.now() + (opts?.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS);
2570
+ const pollMs = opts?.pollMs ?? DEFAULT_WAKE_POLL_MS;
2571
+ for (; ; ) {
2572
+ const iot = await this.ensureIotSession();
2573
+ try {
2574
+ return await streamQuery({
2575
+ regionId: this.#regionId,
2576
+ iotToken: iot.iotToken,
2577
+ iotId,
2578
+ relayEncrypted: opts?.encrypted ?? false,
2579
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
2580
+ });
2581
+ } catch (err) {
2582
+ if (err instanceof DreameDeviceOfflineError && Date.now() + pollMs < deadline) {
2583
+ await sleep(pollMs);
2584
+ continue;
2585
+ }
2586
+ throw err;
2587
+ }
2588
+ }
2589
+ }
2590
+ /**
2591
+ * Convenience: mint a fresh plain RTMP relay URL for a device, or `null` when
2592
+ * the device is not currently streaming (no relay available).
2593
+ */
2594
+ async getRtmpUrl(iotId) {
2595
+ const info = await this.getStreamInfo(iotId, { encrypted: false });
2596
+ return info.relayUrl;
2597
+ }
2598
+ };
2599
+
2600
+ // src/video/monitor/constants.ts
2601
+ var MONITOR_SIID = 10001;
2602
+ var MONITOR_AIID = {
2603
+ /** Stream lifecycle + keep-alive (monitor start/end, keep_alive). */
2604
+ CAMERA_OPERATE: 1,
2605
+ /** Two-way intercom start/end. */
2606
+ VOICE_OPERATE: 2,
2607
+ /** Generic property-scoped operations. */
2608
+ PROPERTY_OPERATE: 3,
2609
+ /** Privacy access-code gate (launch/verify/set/reset). */
2610
+ ACCESS_CODE_OPERATE: 4,
2611
+ /** Video-vendor SDK init / switch. */
2612
+ VIDEO_VENDOR: 7
2613
+ };
2614
+ var MONITOR_PIID = {
2615
+ MONITOR_STATUS: 1,
2616
+ MONITOR_AUDIO_STATUS: 2,
2617
+ MONITOR_RECORD_STATUS: 4,
2618
+ TAKE_PHOTO: 5,
2619
+ KEEP_ALIVE: 6,
2620
+ MONITOR_FAULTS: 7,
2621
+ FILL_LIGHT_SET: 9,
2622
+ CAMERA_LIGHT_SWITCH: 10,
2623
+ VIDEO_VENDOR_STATUS: 11,
2624
+ GET_PROPERTY: 99,
2625
+ PERSON_DATA: 110,
2626
+ VIDEO_OBSTACLE_SWITCH: 111,
2627
+ VIDEO_OBSTACLE_DATA: 112,
2628
+ MONITOR_TASK_STATUS: 103,
2629
+ UPLOAD_STATUS: 1003,
2630
+ GET_ACCESS_CODE: 1100,
2631
+ SET_ACCESS_CODE: 1101,
2632
+ VERIFY_ACCESS_CODE: 1102,
2633
+ MONITOR_REST_MODE: 1103,
2634
+ SET_ACCESS_HOT_STATUS: 1104,
2635
+ MONITOR_SPACE: 2003
2636
+ };
2637
+ var MONITOR_VENDOR_TOKEN = {
2638
+ ali: "alify",
2639
+ tx: "tx"
2640
+ };
2641
+ var VACUUM_SIID = 4;
2642
+ var VACUUM_MOVE = {
2643
+ /** Remote-drive state property (set_properties), sent ~1 Hz while held. */
2644
+ REMOTE_STATE_PIID: 15,
2645
+ /** Work-mode action instance (go-to-point, cruise, person-follow start). */
2646
+ WORK_AIID: 1,
2647
+ /** Work-mode stop action instance (person-follow stop). */
2648
+ STOP_AIID: 2,
2649
+ /** piid carrying the work-mode selector value in the WORK action. */
2650
+ MODE_PIID: 1,
2651
+ /** piid carrying the point-info JSON in the WORK action. */
2652
+ POINT_PIID: 10,
2653
+ /** Work-mode selector values. */
2654
+ MODE_SPOT: 23,
2655
+ // go to point (MonitorSpotMode)
2656
+ MODE_CRUISE: 22,
2657
+ // cruise / patrol / find-pet
2658
+ MODE_PERSON_FOLLOW: 26,
2659
+ MODE_SPOT_CLEAN: 20,
2660
+ // spot-clean the current position (SpotClean)
2661
+ /** Auto-switch setter property (fill-light auto/full toggle lives here). */
2662
+ AUTO_SWITCH_PIID: 50
2663
+ };
2664
+ var VACUUM_CHARGE = { siid: 3, aiid: 1 };
2665
+ var VOICE_SIID = 7;
2666
+ var VOICE_LOCATE_AIID = 1;
2667
+ var VOICE_PLAY_SOUND_AIID = 2;
2668
+ var VOICE_SOUND_PIID = 4;
2669
+ var VACUUM_LOCATE = { siid: VOICE_SIID, aiid: VOICE_LOCATE_AIID };
2670
+ var PET_SOUNDS = {
2671
+ meow: 684,
2672
+ bark: 685,
2673
+ footsteps: 686,
2674
+ purring: 687,
2675
+ tickTock: 688
2676
+ };
2677
+ var VACUUM_ACTIONS = {
2678
+ startClean: { siid: 2, aiid: 1 },
2679
+ pauseClean: { siid: 2, aiid: 2 },
2680
+ stopClean: { siid: 4, aiid: 2 },
2681
+ dockWash: { siid: 4, aiid: 4 },
2682
+ autoEmpty: { siid: 15, aiid: 1 }
2683
+ };
2684
+
2685
+ // src/video/monitor/protocol.ts
2686
+ import { createHash as createHash3 } from "crypto";
2687
+ function makeMonitorSession(accountId, now = Date.now()) {
2688
+ return createHash3("md5").update(`${accountId}_${now}`).digest("hex");
2689
+ }
2690
+ function hashAccessCode(code) {
2691
+ return createHash3("sha256").update(code).digest("hex");
2692
+ }
2693
+ function buildActionInput(piid, params, session) {
2694
+ const value = JSON.stringify({ ...params ?? {}, session });
2695
+ return { piid, value };
2696
+ }
2697
+ function startMonitorParams(iotId, vendor, area) {
2698
+ return {
2699
+ token: MONITOR_VENDOR_TOKEN[vendor],
2700
+ channelId: iotId,
2701
+ area,
2702
+ operType: "monitor",
2703
+ operation: "start"
2704
+ };
2705
+ }
2706
+ function stopMonitorParams() {
2707
+ return { operType: "monitor", operation: "end" };
2708
+ }
2709
+ function keepAliveParams(videoStatus) {
2710
+ return { operType: "keep_alive", videoStatus };
2711
+ }
2712
+ function accessCodeLaunchParams() {
2713
+ return { open: true };
2714
+ }
2715
+ function verifyAccessCodeParams(code, lazymode = 0) {
2716
+ return { oldcode: hashAccessCode(code), lazymode };
2717
+ }
2718
+ function intercomStartParams(opts) {
2719
+ return {
2720
+ operType: "intercom",
2721
+ operation: "start",
2722
+ ...opts?.needRecordSound !== void 0 ? { need_record_sound: opts.needRecordSound } : {},
2723
+ ...opts?.videoCall ? { phone: 1 } : {}
2724
+ };
2725
+ }
2726
+ function intercomStopParams() {
2727
+ return { operType: "intercom", operation: "end" };
2728
+ }
2729
+ function fillLightParams(value) {
2730
+ return { value: String(value) };
2731
+ }
2732
+ function takePhotoParams() {
2733
+ return { operType: "takephoto", operation: "start" };
2734
+ }
2735
+ function remoteDriveValue(spdv, spdw, now = Date.now()) {
2736
+ return JSON.stringify({
2737
+ spdv,
2738
+ spdw,
2739
+ audio: "false",
2740
+ random: Math.floor(Math.random() * 1e3),
2741
+ timestamp: now
2742
+ });
2743
+ }
2744
+ var DRIVE_DIRECTIONS = {
2745
+ forward: [200, 0],
2746
+ left: [0, 45],
2747
+ right: [0, -45],
2748
+ turnAround: [0, 180],
2749
+ stop: [0, 0]
2750
+ };
2751
+ function actionCode(res) {
2752
+ const r = res;
2753
+ return typeof r?.code === "number" ? r.code : null;
2754
+ }
2755
+ function firstOutValue(res) {
2756
+ const r = res;
2757
+ const out = r?.out ?? r?.result?.out;
2758
+ return Array.isArray(out) && out.length > 0 ? out[0]?.value : void 0;
2759
+ }
2760
+
2761
+ // src/video/monitor/controller.ts
2762
+ var DEFAULT_KEEP_ALIVE_MS = 1e4;
2763
+ var DEFAULT_AREA = "4";
2764
+ var DreameCameraController = class {
2765
+ #device;
2766
+ #relay;
2767
+ #iotId;
2768
+ #accountId;
2769
+ #vendor;
2770
+ #accessCode;
2771
+ #area;
2772
+ #keepAliveIntervalMs;
2773
+ #wakeTimeoutMs;
2774
+ #onKeepAliveError;
2775
+ #log;
2776
+ #session = null;
2777
+ #keepAliveTimer = null;
2778
+ #opened = false;
2779
+ constructor(input) {
2780
+ this.#device = input.device;
2781
+ this.#relay = input.relay;
2782
+ this.#iotId = input.iotId;
2783
+ this.#accountId = input.accountId;
2784
+ this.#vendor = input.vendor ?? "ali";
2785
+ this.#accessCode = input.accessCode;
2786
+ this.#area = input.area ?? DEFAULT_AREA;
2787
+ this.#keepAliveIntervalMs = input.keepAliveIntervalMs ?? DEFAULT_KEEP_ALIVE_MS;
2788
+ this.#wakeTimeoutMs = input.wakeTimeoutMs;
2789
+ this.#onKeepAliveError = input.onKeepAliveError;
2790
+ this.#log = input.log ?? (() => {
2791
+ });
2792
+ }
2793
+ #logLine(msg) {
2794
+ this.#log(`[nodedreame:monitor] ${msg}`);
2795
+ }
2796
+ /** True while a monitor session is active (between {@link open} and {@link close}). */
2797
+ get isOpen() {
2798
+ return this.#opened;
2799
+ }
2800
+ /** Send one camera action, resolving to the raw device reply. */
2801
+ async #action(aiid, piid, params) {
2802
+ if (!this.#session) {
2803
+ throw new DreameError("camera controller has no session; call open() first");
2804
+ }
2805
+ const input = buildActionInput(piid, params, this.#session);
2806
+ return this.#device.callAction(MONITOR_SIID, aiid, [input]);
2807
+ }
2808
+ /**
2809
+ * Run the full cold-start sequence and return a live relay URL. Starts the
2810
+ * keep-alive loop; the caller MUST {@link close} when done to release the
2811
+ * device. Throws if the monitor cannot be started or no relay comes up.
2812
+ */
2813
+ async open() {
2814
+ if (this.#opened) {
2815
+ throw new DreameError("camera stream already open");
2816
+ }
2817
+ this.#session = makeMonitorSession(this.#accountId);
2818
+ this.#logLine(`open: vendor=${this.#vendor} iotId=${this.#iotId} area=${this.#area}`);
2819
+ await this.#tryPrime();
2820
+ const startRes = await this.#startMonitorWithGate();
2821
+ const encryptionKey = asString(firstOutValue(startRes));
2822
+ this.#logLine(`startMonitor accepted (encryptionKey=${encryptionKey ? "yes" : "no"})`);
2823
+ this.#opened = true;
2824
+ this.#startKeepAlive();
2825
+ let relayUrl;
2826
+ try {
2827
+ const info = await this.#relay.getStreamInfo(this.#iotId, {
2828
+ encrypted: false,
2829
+ ...this.#wakeTimeoutMs !== void 0 ? { wakeTimeoutMs: this.#wakeTimeoutMs } : {}
2830
+ });
2831
+ relayUrl = info.relayUrl;
2832
+ } catch (err) {
2833
+ this.#logLine(`relay mint failed: ${err instanceof Error ? err.message : String(err)}`);
2834
+ await this.close();
2835
+ throw err;
2836
+ }
2837
+ if (!relayUrl) {
2838
+ await this.close();
2839
+ throw new DreameError("monitor started but no relay URL was returned");
2840
+ }
2841
+ this.#logLine("relay up");
2842
+ return { rtmpUrl: relayUrl, encryptionKey };
2843
+ }
2844
+ async #tryPrime() {
2845
+ try {
2846
+ await this.#action(MONITOR_AIID.ACCESS_CODE_OPERATE, MONITOR_PIID.GET_ACCESS_CODE, accessCodeLaunchParams());
2847
+ } catch {
2848
+ }
2849
+ try {
2850
+ await this.#action(MONITOR_AIID.VIDEO_VENDOR, MONITOR_PIID.VIDEO_VENDOR_STATUS, null);
2851
+ } catch {
2852
+ }
2853
+ }
2854
+ async #startMonitorWithGate() {
2855
+ const params = startMonitorParams(this.#iotId, this.#vendor, this.#area);
2856
+ let res = await this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.MONITOR_STATUS, params);
2857
+ if (actionCode(res) === 0) {
2858
+ return res;
2859
+ }
2860
+ this.#logLine(`startMonitor refused (code ${actionCode(res) ?? "?"}); verifying access code`);
2861
+ if (this.#accessCode) {
2862
+ await this.#action(
2863
+ MONITOR_AIID.ACCESS_CODE_OPERATE,
2864
+ MONITOR_PIID.VERIFY_ACCESS_CODE,
2865
+ verifyAccessCodeParams(this.#accessCode)
2866
+ );
2867
+ res = await this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.MONITOR_STATUS, params);
2868
+ }
2869
+ if (actionCode(res) !== 0) {
2870
+ const code = actionCode(res);
2871
+ throw new DreameError(
2872
+ `startMonitor refused (code ${code ?? "?"})` + (this.#accessCode ? "" : "; device likely requires an accessCode")
2873
+ );
2874
+ }
2875
+ return res;
2876
+ }
2877
+ #startKeepAlive() {
2878
+ this.#stopKeepAlive();
2879
+ this.#keepAliveTimer = setInterval(() => {
2880
+ void this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.KEEP_ALIVE, keepAliveParams("opened")).catch(
2881
+ (err) => this.#onKeepAliveError?.(err)
2882
+ );
2883
+ }, this.#keepAliveIntervalMs);
2884
+ this.#keepAliveTimer.unref?.();
2885
+ }
2886
+ #stopKeepAlive() {
2887
+ if (this.#keepAliveTimer) {
2888
+ clearInterval(this.#keepAliveTimer);
2889
+ this.#keepAliveTimer = null;
2890
+ }
2891
+ }
2892
+ /**
2893
+ * Mint a FRESH single-use RTMP relay URL for the already-open monitor. Each
2894
+ * consumer needs its own relay URL, so call this once per ffmpeg/consumer.
2895
+ * Throws if the stream is not open.
2896
+ */
2897
+ async mintRelayUrl() {
2898
+ if (!this.#opened) {
2899
+ throw new DreameError("camera stream is not open; call open() first");
2900
+ }
2901
+ const info = await this.#relay.getStreamInfo(this.#iotId, {
2902
+ encrypted: false,
2903
+ ...this.#wakeTimeoutMs !== void 0 ? { wakeTimeoutMs: this.#wakeTimeoutMs } : {}
2904
+ });
2905
+ if (!info.relayUrl) {
2906
+ throw new DreameError("no relay URL available for the open monitor");
2907
+ }
2908
+ return info.relayUrl;
2909
+ }
2910
+ /** Start the two-way intercom session (control plane only; see module docs on mic uplink). */
2911
+ async startIntercom(opts) {
2912
+ return this.#action(MONITOR_AIID.VOICE_OPERATE, MONITOR_PIID.MONITOR_AUDIO_STATUS, intercomStartParams(opts));
2913
+ }
2914
+ /** Stop the two-way intercom session. */
2915
+ async stopIntercom() {
2916
+ return this.#action(MONITOR_AIID.VOICE_OPERATE, MONITOR_PIID.MONITOR_AUDIO_STATUS, intercomStopParams());
2917
+ }
2918
+ /** Set the fill-light brightness (40–100 = manual %, below 40 / 101 = auto). */
2919
+ async setFillLight(value) {
2920
+ return this.#action(MONITOR_AIID.PROPERTY_OPERATE, MONITOR_PIID.FILL_LIGHT_SET, fillLightParams(value));
2921
+ }
2922
+ /**
2923
+ * Device-side snapshot: the robot captures a still and uploads it to cloud
2924
+ * storage (read the result back from the `uploadStatus` property). Prefer a
2925
+ * frame-grab from the live stream for an instant image.
2926
+ */
2927
+ async takePhoto() {
2928
+ return this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.TAKE_PHOTO, takePhotoParams());
2929
+ }
2930
+ /**
2931
+ * Remote-drive the robot ("PTZ" substitute — the camera is fixed, the robot
2932
+ * moves). `spdv` = forward speed, `spdw` = turn; send repeatedly (~1 Hz) while
2933
+ * a direction is held, then `stop`. Only valid while the stream is open.
2934
+ */
2935
+ async drive(spdv, spdw) {
2936
+ this.#requireSession();
2937
+ if (!this.#device.setProperty) {
2938
+ throw new DreameError("device does not support setProperty; cannot remote-drive");
2939
+ }
2940
+ return this.#device.setProperty({
2941
+ siid: VACUUM_SIID,
2942
+ piid: VACUUM_MOVE.REMOTE_STATE_PIID,
2943
+ value: remoteDriveValue(spdv, spdw)
2944
+ });
2945
+ }
2946
+ /** Remote-drive by named direction (forward/left/right/turnAround/stop). */
2947
+ async driveDirection(direction) {
2948
+ const [spdv, spdw] = DRIVE_DIRECTIONS[direction];
2949
+ return this.drive(spdv, spdw);
2950
+ }
2951
+ /** Start person-follow mode (robot tracks a detected person). */
2952
+ async startPersonFollow() {
2953
+ this.#requireSession();
2954
+ return this.#device.callAction(VACUUM_SIID, VACUUM_MOVE.WORK_AIID, [
2955
+ { piid: VACUUM_MOVE.MODE_PIID, value: VACUUM_MOVE.MODE_PERSON_FOLLOW }
2956
+ ]);
2957
+ }
2958
+ /**
2959
+ * Universal stop for the current work mode — stops person-follow, spot-clean,
2960
+ * go-to-point, cruise, etc. (siid 4 / aiid 2).
2961
+ */
2962
+ async stopWork() {
2963
+ return this.#device.callAction(VACUUM_SIID, VACUUM_MOVE.STOP_AIID, []);
2964
+ }
2965
+ /** Stop person-follow mode (alias for {@link stopWork}). */
2966
+ async stopPersonFollow() {
2967
+ return this.stopWork();
2968
+ }
2969
+ /** Spot-clean the robot's current position (WorkMode SpotClean). */
2970
+ async spotClean() {
2971
+ return this.#device.callAction(VACUUM_SIID, VACUUM_MOVE.WORK_AIID, [
2972
+ { piid: VACUUM_MOVE.MODE_PIID, value: VACUUM_MOVE.MODE_SPOT_CLEAN }
2973
+ ]);
2974
+ }
2975
+ /** Play one of the robot's sound clips (e.g. pet sounds) by id. */
2976
+ async playSound(soundId) {
2977
+ return this.#device.callAction(VOICE_SIID, VOICE_PLAY_SOUND_AIID, [
2978
+ { piid: VOICE_SOUND_PIID, value: soundId }
2979
+ ]);
2980
+ }
2981
+ /** Play a named pet-teasing sound (meow/bark/footsteps/purring/tickTock). */
2982
+ async playPetSound(sound) {
2983
+ return this.playSound(PET_SOUNDS[sound]);
2984
+ }
2985
+ /** Run a common whole-robot action (startClean/pauseClean/stopClean/dockWash/autoEmpty). */
2986
+ async runVacuumAction(action) {
2987
+ const a = VACUUM_ACTIONS[action];
2988
+ return this.#device.callAction(a.siid, a.aiid, []);
2989
+ }
2990
+ /** Find-pet: cruise the home looking for the pet. */
2991
+ async findPet() {
2992
+ this.#requireSession();
2993
+ return this.#device.callAction(VACUUM_SIID, VACUUM_MOVE.WORK_AIID, [
2994
+ { piid: VACUUM_MOVE.MODE_PIID, value: VACUUM_MOVE.MODE_CRUISE },
2995
+ { piid: VACUUM_MOVE.POINT_PIID, value: JSON.stringify({ findpet: "" }) }
2996
+ ]);
2997
+ }
2998
+ /** Send the robot to a map point (`spoint` current, `tpoint` targets). */
2999
+ async goToPoint(spoint, tpoint) {
3000
+ this.#requireSession();
3001
+ return this.#device.callAction(VACUUM_SIID, VACUUM_MOVE.WORK_AIID, [
3002
+ { piid: VACUUM_MOVE.MODE_PIID, value: VACUUM_MOVE.MODE_SPOT },
3003
+ { piid: VACUUM_MOVE.POINT_PIID, value: JSON.stringify({ spoint, tpoint }) }
3004
+ ]);
3005
+ }
3006
+ /** Toggle the fill light between auto (`full=false`) and full-on (`full=true`). */
3007
+ async setFillLightAuto(full) {
3008
+ if (!this.#device.setProperty) {
3009
+ throw new DreameError("device does not support setProperty; cannot toggle fill light");
3010
+ }
3011
+ return this.#device.setProperty({
3012
+ siid: VACUUM_SIID,
3013
+ piid: VACUUM_MOVE.AUTO_SWITCH_PIID,
3014
+ value: JSON.stringify({ k: "FillinLight", v: full ? 1 : 0 })
3015
+ });
3016
+ }
3017
+ /**
3018
+ * Send the robot back to its dock to charge ("go home"). Works regardless of
3019
+ * stream state — usable as a standalone command or a PTZ "home" preset.
3020
+ */
3021
+ async returnToDock() {
3022
+ return this.#device.callAction(VACUUM_CHARGE.siid, VACUUM_CHARGE.aiid, []);
3023
+ }
3024
+ /** Locate the robot — it beeps. Works regardless of stream state. */
3025
+ async locate() {
3026
+ return this.#device.callAction(VACUUM_LOCATE.siid, VACUUM_LOCATE.aiid, []);
3027
+ }
3028
+ #requireSession() {
3029
+ if (!this.#opened) {
3030
+ throw new DreameError("camera stream is not open; call open() first");
3031
+ }
3032
+ }
3033
+ /** Stop the keep-alive loop and release the monitor on the device. Idempotent. */
3034
+ async close() {
3035
+ this.#stopKeepAlive();
3036
+ if (!this.#opened) {
3037
+ this.#session = null;
3038
+ return;
3039
+ }
3040
+ this.#opened = false;
3041
+ this.#logLine("close: stopping monitor");
3042
+ try {
3043
+ await this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.MONITOR_STATUS, stopMonitorParams());
3044
+ } finally {
3045
+ this.#session = null;
3046
+ }
3047
+ }
3048
+ };
3049
+ function asString(v) {
3050
+ return typeof v === "string" ? v : null;
3051
+ }
3052
+
1929
3053
  // src/models/vacuum/map/envelope.ts
1930
- import { createDecipheriv, createHash as createHash2 } from "crypto";
3054
+ import { createDecipheriv, createHash as createHash4 } from "crypto";
1931
3055
  import * as zlib from "zlib";
1932
3056
  var HEADER_SIZE = 27;
1933
3057
  var ANGLE_ABSENT = 32767;
@@ -2005,7 +3129,7 @@ function looksLikeBase64Zlib(buf) {
2005
3129
  }
2006
3130
  function aesCbcDecrypt(cipher, rawKey, iv) {
2007
3131
  const keyBytes = Buffer.from(
2008
- createHash2("sha256").update(rawKey).digest("hex").slice(0, 32),
3132
+ createHash4("sha256").update(rawKey).digest("hex").slice(0, 32),
2009
3133
  "utf8"
2010
3134
  );
2011
3135
  const ivBytes = Buffer.from(iv, "utf8");
@@ -3346,6 +4470,97 @@ function drawLabel(png, cx, cy, text, color, px) {
3346
4470
  }
3347
4471
  }
3348
4472
 
4473
+ // src/models/vacuum/map/wifi.ts
4474
+ import { PNG as PNG2 } from "pngjs";
4475
+ var NIBBLE_TO_BARS = { 10: 0, 11: 1, 12: 2, 13: 3, 14: 4 };
4476
+ var isWifiNibble = (n) => n >= 10 && n <= 14;
4477
+ var NIBBLE_RGBA = {
4478
+ 2: [176, 182, 190, 255],
4479
+ // map border (context)
4480
+ 3: [242, 244, 247, 255],
4481
+ // mapped floor (context)
4482
+ 10: [229, 234, 238, 255],
4483
+ // unreached
4484
+ 11: [217, 226, 239, 255],
4485
+ // 1 bar
4486
+ 12: [205, 218, 239, 255],
4487
+ // 2 bars
4488
+ 13: [161, 189, 242, 255],
4489
+ // 3 bars
4490
+ 14: [129, 168, 245, 255]
4491
+ // 4 bars
4492
+ };
4493
+ var poseOrNull = (x, y, a) => x === 0 && y === 0 ? null : { x, y, angle: a === ANGLE_ABSENT ? 0 : a };
4494
+ function decodeWifiSignalMap(input, opts = {}) {
4495
+ const inflated = typeof input === "string" ? unwrapEnvelope(input, opts) : looksLikeBase64Zlib(input) ? unwrapEnvelope(input.toString("latin1"), opts) : input;
4496
+ const header = parseMapHeader(inflated);
4497
+ const { width, height, gridSize, left, top } = header;
4498
+ const pixelEnd = HEADER_SIZE + width * height;
4499
+ const grid = inflated.subarray(HEADER_SIZE, pixelEnd);
4500
+ const cells = new Uint8Array(width * height);
4501
+ for (let i = 0; i < cells.length; i++) cells[i] = (grid[i] ?? 0) & 15;
4502
+ const dimensions = { left, top, width, height, gridSize };
4503
+ const robot = poseOrNull(header.robotX, header.robotY, header.robotA);
4504
+ const dock = poseOrNull(header.chargerX, header.chargerY, header.chargerA);
4505
+ const signalAt = (worldX, worldY) => {
4506
+ if (gridSize <= 0) return null;
4507
+ const col = Math.floor((worldX - left) / gridSize);
4508
+ const row = Math.floor((worldY - top) / gridSize);
4509
+ if (col < 0 || col >= width || row < 0 || row >= height) return null;
4510
+ const nibble = cells[row * width + col] ?? 0;
4511
+ return isWifiNibble(nibble) ? NIBBLE_TO_BARS[nibble] : null;
4512
+ };
4513
+ return {
4514
+ dimensions,
4515
+ robot,
4516
+ dock,
4517
+ cells,
4518
+ signalAt,
4519
+ currentSignal: robot ? signalAt(robot.x, robot.y) : null
4520
+ };
4521
+ }
4522
+ function renderWifiSignalPng(map, opts = {}) {
4523
+ const scale = Math.max(1, Math.floor(opts.scale ?? 4));
4524
+ const { width, height, left, top, gridSize } = map.dimensions;
4525
+ const png = new PNG2({ width: width * scale, height: height * scale, colorType: 6 });
4526
+ for (let row = 0; row < height; row++) {
4527
+ for (let col = 0; col < width; col++) {
4528
+ const nibble = map.cells[row * width + col] ?? 0;
4529
+ const rgba = NIBBLE_RGBA[nibble];
4530
+ if (!rgba) continue;
4531
+ for (let dy = 0; dy < scale; dy++) {
4532
+ for (let dx = 0; dx < scale; dx++) {
4533
+ const px = col * scale + dx + (row * scale + dy) * width * scale;
4534
+ const o = px << 2;
4535
+ png.data[o] = rgba[0];
4536
+ png.data[o + 1] = rgba[1];
4537
+ png.data[o + 2] = rgba[2];
4538
+ png.data[o + 3] = rgba[3];
4539
+ }
4540
+ }
4541
+ }
4542
+ }
4543
+ if ((opts.markRobot ?? true) && map.robot && gridSize > 0) {
4544
+ const cx = Math.floor((map.robot.x - left) / gridSize) * scale + Math.floor(scale / 2);
4545
+ const cy = Math.floor((map.robot.y - top) / gridSize) * scale + Math.floor(scale / 2);
4546
+ const r = Math.max(2, scale);
4547
+ for (let dy = -r; dy <= r; dy++) {
4548
+ for (let dx = -r; dx <= r; dx++) {
4549
+ if (dx * dx + dy * dy > r * r) continue;
4550
+ const x = cx + dx;
4551
+ const y = cy + dy;
4552
+ if (x < 0 || y < 0 || x >= width * scale || y >= height * scale) continue;
4553
+ const o = x + y * width * scale << 2;
4554
+ png.data[o] = 255;
4555
+ png.data[o + 1] = 69;
4556
+ png.data[o + 2] = 0;
4557
+ png.data[o + 3] = 255;
4558
+ }
4559
+ }
4560
+ }
4561
+ return PNG2.sync.write(png);
4562
+ }
4563
+
3349
4564
  // src/models/vacuum/map/oss-fetch.ts
3350
4565
  var OSS_DOWNLOAD_PATH = "/dreame-user-iot/iotfile/getDownloadUrl";
3351
4566
  var DEFAULT_TTL_MS = 30 * 60 * 1e3;
@@ -3831,11 +5046,11 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice {
3831
5046
  }
3832
5047
  this.#requireCap(this.#caps.canCleanPerRoom, "cleanZones", "per-room cleaning");
3833
5048
  const { repeats, fan, water } = this.#resolveCleanOpts(opts);
3834
- const areas = zones.map((z4) => [
3835
- Math.round(z4.x0),
3836
- Math.round(z4.y0),
3837
- Math.round(z4.x1),
3838
- Math.round(z4.y1),
5049
+ const areas = zones.map((z7) => [
5050
+ Math.round(z7.x0),
5051
+ Math.round(z7.y0),
5052
+ Math.round(z7.x1),
5053
+ Math.round(z7.y1),
3839
5054
  repeats,
3840
5055
  fan,
3841
5056
  water
@@ -3963,6 +5178,66 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice {
3963
5178
  ...input.iv !== void 0 ? { iv: input.iv } : {}
3964
5179
  };
3965
5180
  }
5181
+ /**
5182
+ * Fetch the robot's Wi-Fi signal map — the last stored coverage heatmap, with
5183
+ * NO robot movement. Requests it (map service `wifiMap` action, siid 6 aiid 4),
5184
+ * polls `PropWifiMap` (6/15) for the OSS object it advertises, fetches + decodes
5185
+ * it into a per-cell signal grid + geometry + robot/dock poses. The signal at
5186
+ * any world point (incl. the robot's own position) is a lookup — see
5187
+ * {@link WifiSignalMap.signalAt} / {@link WifiSignalMap.currentSignal}.
5188
+ *
5189
+ * Pass `requestFresh: false` to skip the request and read whatever is already
5190
+ * advertised. Throws if no Wi-Fi map exists yet (never run on this device).
5191
+ */
5192
+ async getWifiSignalMap(input = {}) {
5193
+ if (input.requestFresh !== false) {
5194
+ try {
5195
+ await this.callAction(6, 4, []);
5196
+ } catch {
5197
+ }
5198
+ }
5199
+ const pollMs = input.pollMs ?? 2500;
5200
+ const deadline = Date.now() + (input.maxWaitMs ?? 2e4);
5201
+ let objectName = "";
5202
+ for (; ; ) {
5203
+ const res = await this.refreshProperties([{ siid: 6, piid: 15 }]);
5204
+ const val = res[0]?.value;
5205
+ if (typeof val === "string" && val.length > 0) {
5206
+ objectName = val;
5207
+ break;
5208
+ }
5209
+ if (Date.now() + pollMs >= deadline) {
5210
+ throw new DreameError("wifi signal map not available (none stored yet, or request timed out)");
5211
+ }
5212
+ await new Promise((r) => setTimeout(r, pollMs));
5213
+ }
5214
+ const filename = objectName.replace(/^\d+,/, "");
5215
+ const blob = await this.#fetchMapBlob({
5216
+ filename,
5217
+ ...input.fetcher ? { fetcher: input.fetcher } : {},
5218
+ ...input.host ? { host: input.host } : {},
5219
+ ...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {},
5220
+ ...input.signal ? { signal: input.signal } : {}
5221
+ });
5222
+ return decodeWifiSignalMap(blob, this.#decodeOpts(input));
5223
+ }
5224
+ /**
5225
+ * Render the Wi-Fi signal map as a simple PNG heatmap image (weakest→strongest,
5226
+ * unreached grey, robot marker) — ready to display. Convenience over
5227
+ * {@link getWifiSignalMap} + {@link renderWifiSignalPng}.
5228
+ */
5229
+ async getWifiSignalImage(input = {}) {
5230
+ const map = await this.getWifiSignalMap(input);
5231
+ return renderWifiSignalPng(map, input);
5232
+ }
5233
+ /**
5234
+ * The one-call "current Wi-Fi signal": fetches the signal map and returns the
5235
+ * bars (1–4, 0 = unreached) at the robot's own position, or null when unknown.
5236
+ */
5237
+ async getCurrentSignal(input = {}) {
5238
+ const map = await this.getWifiSignalMap(input);
5239
+ return map.currentSignal;
5240
+ }
3966
5241
  /**
3967
5242
  * The merge base for live P-frame streaming — always an INFLATED frame buffer.
3968
5243
  * An I-frame (re)seeds it; each P-frame merge replaces it with the merged
@@ -4026,6 +5301,39 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice {
4026
5301
  this.emit("map", frame);
4027
5302
  return frame;
4028
5303
  }
5304
+ /**
5305
+ * Build a {@link DreameCameraController} for this device's camera, wired to
5306
+ * both planes it needs: the MIoT action transport (this device handle) and a
5307
+ * fresh Aliyun {@link DreameVideoSession} for the relay. Call
5308
+ * `controller.open()` to autonomously cold-start the stream and get a pullable
5309
+ * RTMP URL, then `controller.close()` to release it.
5310
+ *
5311
+ * The camera's per-session privacy gate means a coded camera needs its PIN:
5312
+ * pass `accessCode` (the code set at pairing). `iotId` is resolved from the
5313
+ * device's cloud video profile when not supplied.
5314
+ */
5315
+ async createCameraController(opts) {
5316
+ const session = this.currentSession();
5317
+ const region = this.region;
5318
+ const relay = opts?.relay ?? new DreameVideoSession({ session, region });
5319
+ const iotId = opts?.iotId ?? (await getDeviceVideoProfile({ session, region, did: this.deviceId })).iotId;
5320
+ if (!iotId) {
5321
+ throw new DreameError("device has no LinkVisual iotId; not a camera device or not provisioned");
5322
+ }
5323
+ return new DreameCameraController({
5324
+ device: this,
5325
+ relay,
5326
+ iotId,
5327
+ accountId: session.uid,
5328
+ ...opts?.vendor ? { vendor: opts.vendor } : {},
5329
+ ...opts?.accessCode !== void 0 ? { accessCode: opts.accessCode } : {},
5330
+ ...opts?.area ? { area: opts.area } : {},
5331
+ ...opts?.keepAliveIntervalMs !== void 0 ? { keepAliveIntervalMs: opts.keepAliveIntervalMs } : {},
5332
+ ...opts?.wakeTimeoutMs !== void 0 ? { wakeTimeoutMs: opts.wakeTimeoutMs } : {},
5333
+ ...opts?.onKeepAliveError ? { onKeepAliveError: opts.onKeepAliveError } : {},
5334
+ ...opts?.log ? { log: opts.log } : {}
5335
+ });
5336
+ }
4029
5337
  /** Props worth seeding on start() / polling — exported for the facade. */
4030
5338
  static DEFAULT_PROPS = [
4031
5339
  VACUUM_PROP.STATE,
@@ -4564,7 +5872,7 @@ function isRecord3(v) {
4564
5872
  function asNumber(v, fallback) {
4565
5873
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
4566
5874
  }
4567
- function asString(v, fallback) {
5875
+ function asString2(v, fallback) {
4568
5876
  return typeof v === "string" ? v : fallback;
4569
5877
  }
4570
5878
  function mapIdFromIndex(mapIndex) {
@@ -4659,7 +5967,7 @@ function parseZones(list, includeTimes) {
4659
5967
  out.push({
4660
5968
  zoneId: asNumber(id, 0),
4661
5969
  path: extractPathCoords(data["path"]),
4662
- name: asString(data["name"], ""),
5970
+ name: asString2(data["name"], ""),
4663
5971
  zoneType: asNumber(data["type"], 0),
4664
5972
  shapeType: includeTimes ? asNumber(data["shapeType"], 0) : 0,
4665
5973
  area: includeTimes ? asNumber(data["area"], 0) : 0,
@@ -4680,7 +5988,7 @@ function parseSpotAreas(list) {
4680
5988
  out.push({
4681
5989
  areaId: Math.trunc(asNumber(id, 0)),
4682
5990
  path: extractPathCoords(data["path"]),
4683
- name: asString(data["name"], ""),
5991
+ name: asString2(data["name"], ""),
4684
5992
  shapeType: asNumber(data["shapeType"], 0),
4685
5993
  area: asNumber(data["area"], 0)
4686
5994
  });
@@ -4744,7 +6052,7 @@ function parseMowerMap(mapJsonStr) {
4744
6052
  contours: parseContours(parsePolygonList(data["contours"])),
4745
6053
  boundary: parseBoundary(data["boundary"]),
4746
6054
  totalArea: asNumber(data["totalArea"], 0),
4747
- name: asString(data["name"], ""),
6055
+ name: asString2(data["name"], ""),
4748
6056
  mapIndex,
4749
6057
  mapId: mapIdFromIndex(mapIndex),
4750
6058
  mowPaths: [],
@@ -5272,7 +6580,7 @@ var MowerDevice = class _MowerDevice extends BaseDevice {
5272
6580
  if (zoneIds.length === 0) {
5273
6581
  throw new RangeError("startMowingZones: zoneIds must not be empty");
5274
6582
  }
5275
- return this.#sendTask(buildZonePayload(zoneIds.map((z4) => Math.trunc(z4))));
6583
+ return this.#sendTask(buildZonePayload(zoneIds.map((z7) => Math.trunc(z7))));
5276
6584
  }
5277
6585
  /** Edge / contour mowing (2:50 o:101). Contour ids are two-int pairs [[1,0]]. */
5278
6586
  async startMowingEdges(contourIds) {
@@ -5437,6 +6745,7 @@ function defaultDeps(opts) {
5437
6745
  device: args.device,
5438
6746
  region: args.region,
5439
6747
  sessionRef: args.sessionRef,
6748
+ ...args.onAuthFailure !== void 0 ? { onAuthFailure: args.onAuthFailure } : {},
5440
6749
  ...opts.fetchInitialValues !== void 0 ? { fetchInitialValues: opts.fetchInitialValues } : {},
5441
6750
  ...opts.pollIntervalMs !== void 0 ? { pollIntervalMs: opts.pollIntervalMs } : {}
5442
6751
  });
@@ -5450,6 +6759,15 @@ var Nodreame = class extends TypedEmitter {
5450
6759
  #session = null;
5451
6760
  #devices = [];
5452
6761
  #closed = false;
6762
+ /** Background timer that proactively refreshes before the token expires. */
6763
+ #refreshTimer = null;
6764
+ /** Guard against overlapping proactive-refresh runs. */
6765
+ #refreshInFlight = false;
6766
+ /**
6767
+ * The single in-flight refresh, shared by concurrent callers so the proactive
6768
+ * timer and a manual `ensureSession()`/`reauthenticate()` never double-refresh.
6769
+ */
6770
+ #inFlightRefresh = null;
5453
6771
  constructor(opts, deps) {
5454
6772
  super();
5455
6773
  if (!opts.username || !opts.password) {
@@ -5478,6 +6796,7 @@ var Nodreame = class extends TypedEmitter {
5478
6796
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5479
6797
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5480
6798
  });
6799
+ this.#scheduleProactiveRefresh();
5481
6800
  return this.#session;
5482
6801
  }
5483
6802
  /** Return a valid session, refreshing proactively within the leeway window. */
@@ -5489,18 +6808,61 @@ var Nodreame = class extends TypedEmitter {
5489
6808
  if (Date.now() < current.expiresAt - this.#leewayMs) {
5490
6809
  return current;
5491
6810
  }
6811
+ return this.#refreshNow(current);
6812
+ }
6813
+ /**
6814
+ * Force a session refresh REGARDLESS of the current expiry, then propagate the
6815
+ * new token to every live device push. Wired as each push's `onAuthFailure`:
6816
+ * when a broker refuses a CONNECT because it considers the token stale (even
6817
+ * though {@link ensureSession} still thinks it valid — e.g. server-side early
6818
+ * revocation or clock skew), this mints a genuinely fresh token instead of
6819
+ * replaying the rejected one.
6820
+ */
6821
+ async reauthenticate() {
6822
+ if (this.#closed) {
6823
+ throw new DreameAuthError("client is closed");
6824
+ }
6825
+ const current = this.#session;
6826
+ if (!current) {
6827
+ return this.login();
6828
+ }
6829
+ return this.#refreshNow(current);
6830
+ }
6831
+ /**
6832
+ * Refresh via the refresh-token (with a full re-login fallback) and propagate
6833
+ * the new token to every device push. Shared by {@link ensureSession} (within
6834
+ * the leeway window) and {@link reauthenticate} (unconditional).
6835
+ */
6836
+ async #refreshNow(current) {
6837
+ const existing = this.#inFlightRefresh;
6838
+ if (existing) {
6839
+ return existing;
6840
+ }
6841
+ const run = this.#doRefresh(current);
6842
+ this.#inFlightRefresh = run;
6843
+ try {
6844
+ return await run;
6845
+ } finally {
6846
+ this.#inFlightRefresh = null;
6847
+ }
6848
+ }
6849
+ async #doRefresh(current) {
5492
6850
  if (current.refreshToken) {
6851
+ let next = null;
5493
6852
  try {
5494
- const next = await this.#deps.refresh({
6853
+ next = await this.#deps.refresh({
5495
6854
  refreshToken: current.refreshToken,
5496
6855
  region: this.#opts.region,
5497
6856
  ...this.#opts.country !== void 0 ? { country: this.#opts.country } : {},
5498
6857
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5499
6858
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5500
6859
  });
6860
+ } catch {
6861
+ next = null;
6862
+ }
6863
+ if (next) {
5501
6864
  await this.#adoptSession(next);
5502
6865
  return next;
5503
- } catch {
5504
6866
  }
5505
6867
  }
5506
6868
  const fresh = await this.login();
@@ -5522,23 +6884,30 @@ var Nodreame = class extends TypedEmitter {
5522
6884
  (device) => this.#deps.createDevice({
5523
6885
  device,
5524
6886
  region: this.#opts.region,
5525
- sessionRef: () => this.#requireSession()
6887
+ sessionRef: () => this.#requireSession(),
6888
+ onAuthFailure: () => this.reauthenticate()
5526
6889
  })
5527
6890
  );
5528
6891
  for (const h of handles) {
5529
6892
  h.on("stateChanged", (e) => this.emit("stateChanged", e));
5530
6893
  h.on("event", (e) => this.emit("event", e));
5531
- h.on("error", (err) => this.emit("error", err));
6894
+ h.on("error", (err) => {
6895
+ if (this.listenerCount("error") > 0) {
6896
+ this.emit("error", err);
6897
+ }
6898
+ });
5532
6899
  await h.start();
5533
6900
  }
5534
6901
  const previous = this.#devices;
5535
6902
  this.#devices = [...handles];
5536
6903
  await Promise.all(previous.map((d) => d.close()));
6904
+ this.#scheduleProactiveRefresh();
5537
6905
  return this.#devices;
5538
6906
  }
5539
6907
  /** Tear everything down: close every device push and clear timers. */
5540
6908
  async close() {
5541
6909
  this.#closed = true;
6910
+ this.#clearRefreshTimer();
5542
6911
  const devices = this.#devices;
5543
6912
  this.#devices = [];
5544
6913
  await Promise.all(devices.map((d) => d.close()));
@@ -5553,13 +6922,68 @@ var Nodreame = class extends TypedEmitter {
5553
6922
  async #adoptSession(session) {
5554
6923
  this.#session = session;
5555
6924
  await this.#propagateSession(session);
6925
+ this.#scheduleProactiveRefresh();
6926
+ }
6927
+ #clearRefreshTimer() {
6928
+ if (this.#refreshTimer) {
6929
+ clearTimeout(this.#refreshTimer);
6930
+ this.#refreshTimer = null;
6931
+ }
6932
+ }
6933
+ /** Arm the proactive-refresh timer `delayMs` from now (clamped to ≥0). */
6934
+ #armRefreshTimer(delayMs) {
6935
+ this.#clearRefreshTimer();
6936
+ if (this.#closed) {
6937
+ return;
6938
+ }
6939
+ this.#refreshTimer = setTimeout(
6940
+ () => {
6941
+ this.#refreshTimer = null;
6942
+ void this.#runProactiveRefresh();
6943
+ },
6944
+ Math.max(0, delayMs)
6945
+ );
6946
+ }
6947
+ /** (Re)arm the proactive-refresh timer against the current session's expiry. */
6948
+ #scheduleProactiveRefresh() {
6949
+ const session = this.#session;
6950
+ if (!session) {
6951
+ this.#clearRefreshTimer();
6952
+ return;
6953
+ }
6954
+ this.#armRefreshTimer(session.expiresAt - this.#leewayMs - Date.now());
6955
+ }
6956
+ /**
6957
+ * Timer body: refresh the session before it expires, then reschedule against
6958
+ * the new expiry. Because {@link ensureSession} propagates the refreshed token
6959
+ * to every device push, each MQTT connection is re-keyed BEFORE the broker
6960
+ * would reject the old one — the reactive re-auth loop never even starts.
6961
+ */
6962
+ async #runProactiveRefresh() {
6963
+ if (this.#closed || this.#refreshInFlight) {
6964
+ return;
6965
+ }
6966
+ this.#refreshInFlight = true;
6967
+ try {
6968
+ await this.ensureSession();
6969
+ this.#refreshInFlight = false;
6970
+ this.#scheduleProactiveRefresh();
6971
+ } catch (err) {
6972
+ this.#refreshInFlight = false;
6973
+ if (this.listenerCount("error") > 0) {
6974
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
6975
+ }
6976
+ if (!this.#closed) {
6977
+ this.#armRefreshTimer(this.#leewayMs);
6978
+ }
6979
+ }
5556
6980
  }
5557
6981
  /** Push the refreshed token to every live device push. */
5558
6982
  async #propagateSession(session) {
5559
6983
  if (this.#closed) {
5560
6984
  return;
5561
6985
  }
5562
- await Promise.all(this.#devices.map((d) => d.applySession(session)));
6986
+ await Promise.allSettled(this.#devices.map((d) => d.applySession(session)));
5563
6987
  }
5564
6988
  };
5565
6989
 
@@ -5676,59 +7100,59 @@ function redact(value) {
5676
7100
  }
5677
7101
 
5678
7102
  // src/diagnostics/dump-format.ts
5679
- import { z as z3 } from "zod";
5680
- var DumpScalarSchema = z3.union([z3.string(), z3.number(), z3.boolean()]);
5681
- var PropertyObservationSchema = z3.object({
5682
- values: z3.array(DumpScalarSchema),
5683
- unmapped: z3.array(DumpScalarSchema),
5684
- enum: z3.string().optional(),
5685
- count: z3.number(),
5686
- firstSeen: z3.number(),
5687
- lastSeen: z3.number()
7103
+ import { z as z6 } from "zod";
7104
+ var DumpScalarSchema = z6.union([z6.string(), z6.number(), z6.boolean()]);
7105
+ var PropertyObservationSchema = z6.object({
7106
+ values: z6.array(DumpScalarSchema),
7107
+ unmapped: z6.array(DumpScalarSchema),
7108
+ enum: z6.string().optional(),
7109
+ count: z6.number(),
7110
+ firstSeen: z6.number(),
7111
+ lastSeen: z6.number()
5688
7112
  });
5689
- var EventObservationSchema = z3.object({
5690
- at: z3.number(),
5691
- type: z3.string(),
5692
- data: z3.unknown().optional()
7113
+ var EventObservationSchema = z6.object({
7114
+ at: z6.number(),
7115
+ type: z6.string(),
7116
+ data: z6.unknown().optional()
5693
7117
  });
5694
- var RawFrameSchema = z3.object({
5695
- at: z3.number(),
5696
- source: z3.string(),
5697
- payload: z3.unknown()
7118
+ var RawFrameSchema = z6.object({
7119
+ at: z6.number(),
7120
+ source: z6.string(),
7121
+ payload: z6.unknown()
5698
7122
  });
5699
- var CommandSchema = z3.object({
5700
- name: z3.string(),
5701
- siid: z3.number().optional(),
5702
- aiid: z3.number().optional()
7123
+ var CommandSchema = z6.object({
7124
+ name: z6.string(),
7125
+ siid: z6.number().optional(),
7126
+ aiid: z6.number().optional()
5703
7127
  });
5704
- var SensorSchema = z3.object({
5705
- model: z3.string(),
5706
- channel: z3.number().optional()
7128
+ var SensorSchema = z6.object({
7129
+ model: z6.string(),
7130
+ channel: z6.number().optional()
5707
7131
  });
5708
- var DeviceDumpSchema = z3.object({
5709
- schemaVersion: z3.literal(1),
5710
- library: z3.union([z3.literal("nodedreame"), z3.literal("nodewitt")]),
5711
- libraryVersion: z3.string(),
5712
- device: z3.object({
5713
- model: z3.string(),
5714
- firmware: z3.string().optional(),
5715
- region: z3.string().optional(),
5716
- type: z3.string().optional()
7132
+ var DeviceDumpSchema = z6.object({
7133
+ schemaVersion: z6.literal(1),
7134
+ library: z6.union([z6.literal("nodedreame"), z6.literal("nodewitt")]),
7135
+ libraryVersion: z6.string(),
7136
+ device: z6.object({
7137
+ model: z6.string(),
7138
+ firmware: z6.string().optional(),
7139
+ region: z6.string().optional(),
7140
+ type: z6.string().optional()
5717
7141
  }),
5718
- observations: z3.object({
5719
- properties: z3.record(z3.string(), PropertyObservationSchema),
5720
- events: z3.array(EventObservationSchema),
5721
- rawFrames: z3.array(RawFrameSchema).optional()
7142
+ observations: z6.object({
7143
+ properties: z6.record(z6.string(), PropertyObservationSchema),
7144
+ events: z6.array(EventObservationSchema),
7145
+ rawFrames: z6.array(RawFrameSchema).optional()
5722
7146
  }),
5723
- catalog: z3.object({
5724
- commands: z3.array(CommandSchema).optional(),
5725
- capabilities: z3.record(z3.string(), z3.unknown()).optional(),
5726
- sensors: z3.array(SensorSchema).optional()
7147
+ catalog: z6.object({
7148
+ commands: z6.array(CommandSchema).optional(),
7149
+ capabilities: z6.record(z6.string(), z6.unknown()).optional(),
7150
+ sensors: z6.array(SensorSchema).optional()
5727
7151
  }),
5728
- meta: z3.object({
5729
- startedAt: z3.number(),
5730
- durationMs: z3.number(),
5731
- generatedAt: z3.number()
7152
+ meta: z6.object({
7153
+ startedAt: z6.number(),
7154
+ durationMs: z6.number(),
7155
+ generatedAt: z6.number()
5732
7156
  })
5733
7157
  });
5734
7158
 
@@ -6017,20 +7441,938 @@ function createDumper(target, options) {
6017
7441
  function createClientDumper(client, options) {
6018
7442
  return client.devices.map((d) => createDumper(d, options));
6019
7443
  }
7444
+
7445
+ // src/video/monitor/detections.ts
7446
+ var asRecord = (value) => {
7447
+ if (typeof value === "string") {
7448
+ try {
7449
+ const parsed = JSON.parse(value);
7450
+ return parsed && typeof parsed === "object" ? parsed : null;
7451
+ } catch {
7452
+ return null;
7453
+ }
7454
+ }
7455
+ return value && typeof value === "object" ? value : null;
7456
+ };
7457
+ var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
7458
+ var toMs = (raw) => {
7459
+ const n = num(raw);
7460
+ if (n === void 0) return Date.now();
7461
+ return n > 1e15 ? Math.round(n / 1e6) : n;
7462
+ };
7463
+ var toBox = (raw) => {
7464
+ if (Array.isArray(raw) && raw.length >= 4) {
7465
+ const [x2, y2, w2, h2] = raw;
7466
+ if ([x2, y2, w2, h2].every((v) => num(v) !== void 0)) {
7467
+ return { x: x2, y: y2, w: w2, h: h2 };
7468
+ }
7469
+ return null;
7470
+ }
7471
+ const r = asRecord(raw);
7472
+ if (!r) return null;
7473
+ const x = num(r["x"]);
7474
+ const y = num(r["y"]);
7475
+ const w = num(r["w"]) ?? num(r["width"]);
7476
+ const h = num(r["h"]) ?? num(r["height"]);
7477
+ if (x === void 0 || y === void 0 || w === void 0 || h === void 0) return null;
7478
+ const label = typeof r["label"] === "string" ? r["label"] : void 0;
7479
+ const score = num(r["score"]) ?? num(r["confidence"]);
7480
+ return { x, y, w, h, ...label !== void 0 ? { label } : {}, ...score !== void 0 ? { score } : {} };
7481
+ };
7482
+ function parsePersonFollow(value) {
7483
+ const r = asRecord(value);
7484
+ if (!r) return null;
7485
+ return {
7486
+ timestampMs: toMs(r["timestamp"] ?? r["time"]),
7487
+ box: toBox(r["bbox"] ?? r["box"])
7488
+ };
7489
+ }
7490
+ function parseObstacleData(value) {
7491
+ const r = asRecord(value);
7492
+ if (!r) return null;
7493
+ const list = r["boxlist"] ?? r["boxes"] ?? r["boxList"];
7494
+ const boxes = [];
7495
+ if (Array.isArray(list)) {
7496
+ for (const item of list) {
7497
+ const box = toBox(item);
7498
+ if (box) boxes.push(box);
7499
+ }
7500
+ }
7501
+ return { timestampMs: toMs(r["timestamp"] ?? r["time"]), boxes };
7502
+ }
7503
+
7504
+ // src/video/media/lv-rtmp-client.ts
7505
+ import net from "net";
7506
+ import { EventEmitter as EventEmitter2 } from "events";
7507
+ import { randomBytes as randomBytes2 } from "crypto";
7508
+
7509
+ // src/video/media/amf0.ts
7510
+ var AMF0 = {
7511
+ NUMBER: 0,
7512
+ BOOLEAN: 1,
7513
+ STRING: 2,
7514
+ OBJECT: 3,
7515
+ NULL: 5,
7516
+ UNDEFINED: 6,
7517
+ ECMA_ARRAY: 8,
7518
+ OBJECT_END: 9,
7519
+ STRICT_ARRAY: 10,
7520
+ LONG_STRING: 12
7521
+ };
7522
+ function amfNumber(n) {
7523
+ const b = Buffer.alloc(9);
7524
+ b[0] = AMF0.NUMBER;
7525
+ b.writeDoubleBE(n, 1);
7526
+ return b;
7527
+ }
7528
+ function amfBoolean(v) {
7529
+ return Buffer.from([AMF0.BOOLEAN, v ? 1 : 0]);
7530
+ }
7531
+ function amfString(s) {
7532
+ const str2 = Buffer.from(s, "utf8");
7533
+ const b = Buffer.alloc(3 + str2.length);
7534
+ b[0] = AMF0.STRING;
7535
+ b.writeUInt16BE(str2.length, 1);
7536
+ str2.copy(b, 3);
7537
+ return b;
7538
+ }
7539
+ function amfNull() {
7540
+ return Buffer.from([AMF0.NULL]);
7541
+ }
7542
+ function amfNamed(key2, value) {
7543
+ const k = Buffer.from(key2, "utf8");
7544
+ const b = Buffer.alloc(2 + k.length);
7545
+ b.writeUInt16BE(k.length, 0);
7546
+ k.copy(b, 2);
7547
+ return Buffer.concat([b, value]);
7548
+ }
7549
+ function amfObject(pairs) {
7550
+ const parts = [Buffer.from([AMF0.OBJECT])];
7551
+ for (const [k, v] of pairs) parts.push(amfNamed(k, v));
7552
+ parts.push(Buffer.from([0, 0, AMF0.OBJECT_END]));
7553
+ return Buffer.concat(parts);
7554
+ }
7555
+ function decodeOne(buf, pos) {
7556
+ const type = buf[pos];
7557
+ let p = pos + 1;
7558
+ switch (type) {
7559
+ case AMF0.NUMBER:
7560
+ return { value: buf.readDoubleBE(p), next: p + 8 };
7561
+ case AMF0.BOOLEAN:
7562
+ return { value: buf[p] !== 0, next: p + 1 };
7563
+ case AMF0.STRING: {
7564
+ const len = buf.readUInt16BE(p);
7565
+ return { value: buf.toString("utf8", p + 2, p + 2 + len), next: p + 2 + len };
7566
+ }
7567
+ case AMF0.LONG_STRING: {
7568
+ const len = buf.readUInt32BE(p);
7569
+ return { value: buf.toString("utf8", p + 4, p + 4 + len), next: p + 4 + len };
7570
+ }
7571
+ case AMF0.NULL:
7572
+ return { value: null, next: p };
7573
+ case AMF0.UNDEFINED:
7574
+ return { value: void 0, next: p };
7575
+ case AMF0.ECMA_ARRAY:
7576
+ case AMF0.OBJECT: {
7577
+ if (type === AMF0.ECMA_ARRAY) p += 4;
7578
+ const obj = {};
7579
+ for (; ; ) {
7580
+ const klen = buf.readUInt16BE(p);
7581
+ if (klen === 0 && buf[p + 2] === AMF0.OBJECT_END) return { value: obj, next: p + 3 };
7582
+ const key2 = buf.toString("utf8", p + 2, p + 2 + klen);
7583
+ const r = decodeOne(buf, p + 2 + klen);
7584
+ obj[key2] = r.value;
7585
+ p = r.next;
7586
+ }
7587
+ }
7588
+ case AMF0.STRICT_ARRAY: {
7589
+ const count = buf.readUInt32BE(p);
7590
+ p += 4;
7591
+ const arr = [];
7592
+ for (let i = 0; i < count; i++) {
7593
+ const r = decodeOne(buf, p);
7594
+ arr.push(r.value);
7595
+ p = r.next;
7596
+ }
7597
+ return { value: arr, next: p };
7598
+ }
7599
+ default:
7600
+ throw new Error(`AMF0: unsupported type 0x${(type ?? -1).toString(16)} at ${pos}`);
7601
+ }
7602
+ }
7603
+ function decodeAmf0(buf) {
7604
+ const out = [];
7605
+ let pos = 0;
7606
+ while (pos < buf.length) {
7607
+ const r = decodeOne(buf, pos);
7608
+ out.push(r.value);
7609
+ pos = r.next;
7610
+ }
7611
+ return out;
7612
+ }
7613
+
7614
+ // src/video/media/chunk.ts
7615
+ var MSG = {
7616
+ SET_CHUNK_SIZE: 1,
7617
+ ABORT: 2,
7618
+ ACK: 3,
7619
+ USER_CONTROL: 4,
7620
+ WINDOW_ACK_SIZE: 5,
7621
+ SET_PEER_BW: 6,
7622
+ AUDIO: 8,
7623
+ VIDEO: 9,
7624
+ DATA_AMF0: 18,
7625
+ COMMAND_AMF0: 20
7626
+ };
7627
+ var PRIVATE_MODE_CHUNK_SIZE = 6e4;
7628
+ function basicHeader(fmt, csid) {
7629
+ if (csid < 64) return Buffer.from([fmt << 6 | csid]);
7630
+ if (csid < 320) return Buffer.from([fmt << 6, csid - 64]);
7631
+ return Buffer.from([fmt << 6 | 1, csid - 64 & 255, csid - 64 >> 8]);
7632
+ }
7633
+ function encodeMessage(o) {
7634
+ if (o.body.length > PRIVATE_MODE_CHUNK_SIZE) {
7635
+ throw new Error(`message body ${o.body.length} exceeds private-mode chunk size ${PRIVATE_MODE_CHUNK_SIZE}`);
7636
+ }
7637
+ const ts = o.timestamp ?? 0;
7638
+ const hdrLen = o.fmt === 0 ? 11 : 7;
7639
+ const hdr = Buffer.alloc(hdrLen);
7640
+ hdr.writeUIntBE(ts >= 16777215 ? 16777215 : ts, 0, 3);
7641
+ hdr.writeUIntBE(o.body.length, 3, 3);
7642
+ hdr[6] = o.type;
7643
+ if (o.fmt === 0) hdr.writeUInt32LE(o.msid ?? 0, 7);
7644
+ const ext = ts >= 16777215 ? (() => {
7645
+ const e = Buffer.alloc(4);
7646
+ e.writeUInt32BE(ts, 0);
7647
+ return e;
7648
+ })() : Buffer.alloc(0);
7649
+ return Buffer.concat([basicHeader(o.fmt, o.csid), hdr, ext, o.body]);
7650
+ }
7651
+ function freshState() {
7652
+ return { timestamp: 0, delta: 0, length: 0, type: 0, msid: 0, extTs: false, partial: [], received: 0 };
7653
+ }
7654
+ var ChunkReader = class {
7655
+ chunkSize;
7656
+ pending = Buffer.alloc(0);
7657
+ streams = /* @__PURE__ */ new Map();
7658
+ constructor(chunkSize = PRIVATE_MODE_CHUNK_SIZE) {
7659
+ this.chunkSize = chunkSize;
7660
+ }
7661
+ feed(data) {
7662
+ this.pending = this.pending.length ? Buffer.concat([this.pending, data]) : data;
7663
+ const out = [];
7664
+ for (; ; ) {
7665
+ const consumed = this.tryReadChunk(out);
7666
+ if (consumed === 0) break;
7667
+ this.pending = this.pending.subarray(consumed);
7668
+ }
7669
+ return out;
7670
+ }
7671
+ /** Returns bytes consumed for one chunk, or 0 if more data is needed. */
7672
+ tryReadChunk(out) {
7673
+ const buf = this.pending;
7674
+ if (buf.length < 1) return 0;
7675
+ const fmt = buf[0] >> 6;
7676
+ let csid = buf[0] & 63;
7677
+ let p = 1;
7678
+ if (csid === 0) {
7679
+ if (buf.length < 2) return 0;
7680
+ csid = 64 + buf[1];
7681
+ p = 2;
7682
+ } else if (csid === 1) {
7683
+ if (buf.length < 3) return 0;
7684
+ csid = 64 + buf[1] + buf[2] * 256;
7685
+ p = 3;
7686
+ }
7687
+ const st = this.streams.get(csid) ?? freshState();
7688
+ const hdrLen = fmt === 0 ? 11 : fmt === 1 ? 7 : fmt === 2 ? 3 : 0;
7689
+ if (buf.length < p + hdrLen) return 0;
7690
+ let tsField = 0;
7691
+ if (fmt <= 2) {
7692
+ tsField = buf.readUIntBE(p, 3);
7693
+ if (fmt <= 1) {
7694
+ st.length = buf.readUIntBE(p + 3, 3);
7695
+ st.type = buf[p + 6];
7696
+ }
7697
+ if (fmt === 0) st.msid = buf.readUInt32LE(p + 7);
7698
+ st.extTs = tsField === 16777215;
7699
+ }
7700
+ p += hdrLen;
7701
+ if (st.extTs) {
7702
+ if (buf.length < p + 4) return 0;
7703
+ tsField = buf.readUInt32BE(p);
7704
+ p += 4;
7705
+ }
7706
+ const startOfMessage = st.received === 0;
7707
+ if (startOfMessage) {
7708
+ if (fmt === 0) {
7709
+ st.timestamp = tsField;
7710
+ st.delta = 0;
7711
+ } else if (fmt <= 2) {
7712
+ st.delta = tsField;
7713
+ st.timestamp += tsField;
7714
+ } else st.timestamp += st.delta;
7715
+ }
7716
+ const take = Math.min(this.chunkSize, st.length - st.received);
7717
+ if (buf.length < p + take) return 0;
7718
+ st.partial.push(Buffer.from(buf.subarray(p, p + take)));
7719
+ st.received += take;
7720
+ if (st.received >= st.length) {
7721
+ out.push({ csid, msid: st.msid, timestamp: st.timestamp, type: st.type, body: Buffer.concat(st.partial) });
7722
+ st.partial = [];
7723
+ st.received = 0;
7724
+ }
7725
+ this.streams.set(csid, st);
7726
+ return p + take;
7727
+ }
7728
+ };
7729
+
7730
+ // src/video/media/flv.ts
7731
+ var ANNEXB_START = Buffer.from([0, 0, 0, 1]);
7732
+ function parseVideoTag(body) {
7733
+ if (body.length < 5) return null;
7734
+ const b0 = body[0];
7735
+ return {
7736
+ frameType: b0 >> 4,
7737
+ codecId: b0 & 15,
7738
+ avcPacketType: body[1],
7739
+ compositionTime: body.readIntBE(2, 3),
7740
+ data: body.subarray(5)
7741
+ };
7742
+ }
7743
+ function parseAudioTag(body) {
7744
+ if (body.length < 1) return null;
7745
+ const b0 = body[0];
7746
+ const soundFormat = b0 >> 4;
7747
+ const isAac = soundFormat === 10;
7748
+ if (isAac && body.length < 2) return null;
7749
+ return {
7750
+ soundFormat,
7751
+ sampleRateIndex: b0 >> 2 & 3,
7752
+ channels: (b0 & 1) + 1,
7753
+ aacPacketType: isAac ? body[1] : null,
7754
+ data: body.subarray(isAac ? 2 : 1)
7755
+ };
7756
+ }
7757
+ function parseAvcConfig(rec) {
7758
+ const nalLengthSize = (rec[4] & 3) + 1;
7759
+ let p = 5;
7760
+ const sps = [];
7761
+ const pps = [];
7762
+ const numSps = rec[p++] & 31;
7763
+ for (let i = 0; i < numSps; i++) {
7764
+ const n = rec.readUInt16BE(p);
7765
+ sps.push(Buffer.from(rec.subarray(p + 2, p + 2 + n)));
7766
+ p += 2 + n;
7767
+ }
7768
+ const numPps = rec[p++];
7769
+ for (let i = 0; i < numPps; i++) {
7770
+ const n = rec.readUInt16BE(p);
7771
+ pps.push(Buffer.from(rec.subarray(p + 2, p + 2 + n)));
7772
+ p += 2 + n;
7773
+ }
7774
+ return { sps, pps, nalLengthSize };
7775
+ }
7776
+ function avccToAnnexB(data, cfg, prependParams) {
7777
+ const parts = [];
7778
+ if (prependParams) for (const nal of [...cfg.sps, ...cfg.pps]) parts.push(ANNEXB_START, nal);
7779
+ let p = 0;
7780
+ while (p + cfg.nalLengthSize <= data.length) {
7781
+ const len = data.readUIntBE(p, cfg.nalLengthSize);
7782
+ p += cfg.nalLengthSize;
7783
+ if (len === 0 || p + len > data.length) break;
7784
+ parts.push(ANNEXB_START, data.subarray(p, p + len));
7785
+ p += len;
7786
+ }
7787
+ return Buffer.concat(parts);
7788
+ }
7789
+ function parseAacConfig(asc) {
7790
+ const a0 = asc[0];
7791
+ const a1 = asc[1];
7792
+ return {
7793
+ objectType: a0 >> 3,
7794
+ samplingFrequencyIndex: (a0 & 7) << 1 | a1 >> 7,
7795
+ channelConfiguration: a1 >> 3 & 15
7796
+ };
7797
+ }
7798
+ var AAC_SAMPLE_RATES = [96e3, 88200, 64e3, 48e3, 44100, 32e3, 24e3, 22050, 16e3, 12e3, 11025, 8e3, 7350];
7799
+ function aacSampleRate(cfg) {
7800
+ return AAC_SAMPLE_RATES[cfg.samplingFrequencyIndex] ?? 0;
7801
+ }
7802
+ function aacToAdts(raw, cfg) {
7803
+ const frameLen = raw.length + 7;
7804
+ const h = Buffer.alloc(7);
7805
+ h[0] = 255;
7806
+ h[1] = 241;
7807
+ h[2] = cfg.objectType - 1 << 6 | cfg.samplingFrequencyIndex << 2 | cfg.channelConfiguration >> 2;
7808
+ h[3] = (cfg.channelConfiguration & 3) << 6 | frameLen >> 11 & 3;
7809
+ h[4] = frameLen >> 3 & 255;
7810
+ h[5] = (frameLen & 7) << 5 | 31;
7811
+ h[6] = 252;
7812
+ return Buffer.concat([h, raw]);
7813
+ }
7814
+
7815
+ // src/video/media/lv-rtmp-client.ts
7816
+ var LV_STATUS = {
7817
+ REQUEST_AUDIO_TYPE: "NetStream.RequestAudioType",
7818
+ FORCE_IFRAME_START: "NetStream.ForceIFrame.Start",
7819
+ PING_START: "NetStream.Ping.Start",
7820
+ PING_RESPONSE: "NetStream.Ping.Response",
7821
+ PRE_CONNECT: "NetStream.PreConnect",
7822
+ KEEPALIVE: "NetStream.Keepalive",
7823
+ SERVER_INFO: "NetStream.ServerInfo",
7824
+ TRANSFER_DATA: "NetStream.TransferData",
7825
+ PLAY_START: "NetStream.Play.Start",
7826
+ /** Server signals the talk (intercom uplink) channel is ready. */
7827
+ TALK_READY: "NetStream.TalkReady"
7828
+ };
7829
+ var TALK_AUDIO_HEADER_G711A = 114;
7830
+ var CONNECT_CAPABILITIES = 15;
7831
+ var CONNECT_AUDIO_CODECS = 3191;
7832
+ var CONNECT_VIDEO_CODECS = 252;
7833
+ var CONNECT_VIDEO_FUNCTION = 1;
7834
+ var PLAY_START_LIVE = -1e3;
7835
+ var HANDSHAKE_C0_PRIVATE = 4;
7836
+ var HANDSHAKE_S0S1_LEN = 1537;
7837
+ var DEFAULT_PING_INTERVAL_MS = 3e4;
7838
+ var DEFAULT_CACHE_DURATION_MS = 3e3;
7839
+ var ACK_THRESHOLD_BYTES = 25e4;
7840
+ var CSID_CONTROL = 2;
7841
+ var CSID_COMMAND = 3;
7842
+ var CSID_TALK = 4;
7843
+ var CSID_PLAY = 8;
7844
+ var PLAY_STREAM_ID = 1;
7845
+ function parseRelayUrl(url, cacheDurationMs) {
7846
+ const m = /^rtmp:\/\/([^/:]+)(?::(\d+))?\/(.*)$/.exec(url);
7847
+ if (!m) throw new Error(`not an rtmp:// url: ${url}`);
7848
+ const host = m[1];
7849
+ const port = m[2] ? Number(m[2]) : 1935;
7850
+ const path = m[3];
7851
+ const slash = path.lastIndexOf("/");
7852
+ if (slash < 0) throw new Error(`relay url has no playpath: ${url}`);
7853
+ let app = path.slice(0, slash);
7854
+ const playpath = path.slice(slash + 1);
7855
+ if (!/[?&]cacheDuration=/.test(app)) app += `${app.includes("?") ? "&" : "?"}cacheDuration=${cacheDurationMs}`;
7856
+ return { host, port, app, playpath, tcUrl: `rtmp://${host}:${port}/${app}` };
7857
+ }
7858
+ var LvRtmpClient = class extends EventEmitter2 {
7859
+ opts;
7860
+ endpoint;
7861
+ socket = null;
7862
+ handshakeBuf = Buffer.alloc(0);
7863
+ handshakeDone = false;
7864
+ c1Time = 0;
7865
+ reader = new ChunkReader(PRIVATE_MODE_CHUNK_SIZE);
7866
+ transactionId = 0;
7867
+ bytesIn = 0;
7868
+ bytesAcked = 0;
7869
+ ackWindow = ACK_THRESHOLD_BYTES;
7870
+ pingTimer = null;
7871
+ firstMessageSeen = false;
7872
+ avcConfig = null;
7873
+ aacConfig = null;
7874
+ closed = false;
7875
+ constructor(options) {
7876
+ super();
7877
+ this.opts = {
7878
+ url: options.url,
7879
+ cacheDurationMs: options.cacheDurationMs ?? DEFAULT_CACHE_DURATION_MS,
7880
+ requestAudioType: options.requestAudioType ?? true,
7881
+ ping: options.ping ?? true,
7882
+ pingIntervalMs: options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS,
7883
+ forceIFrameOnStart: options.forceIFrameOnStart ?? false,
7884
+ connectTimeoutMs: options.connectTimeoutMs ?? 1e4,
7885
+ log: options.log ?? (() => {
7886
+ })
7887
+ };
7888
+ this.endpoint = parseRelayUrl(this.opts.url, this.opts.cacheDurationMs);
7889
+ }
7890
+ /** Open TCP, write C0+C1 and the pipelined connect/play/RequestAudioType. Resolves once written. */
7891
+ connect() {
7892
+ return new Promise((resolve, reject) => {
7893
+ const sock = net.createConnection({ host: this.endpoint.host, port: this.endpoint.port });
7894
+ this.socket = sock;
7895
+ sock.setNoDelay(true);
7896
+ sock.setTimeout(this.opts.connectTimeoutMs, () => {
7897
+ if (!this.handshakeDone) this.fail(new Error("handshake timeout"));
7898
+ });
7899
+ sock.once("error", (err) => {
7900
+ this.emit("error", err);
7901
+ reject(err);
7902
+ });
7903
+ sock.on("data", (d) => this.onData(d));
7904
+ sock.on("close", () => this.onClose());
7905
+ sock.once("connect", () => {
7906
+ this.opts.log(`tcp connected ${this.endpoint.host}:${this.endpoint.port}`);
7907
+ sock.write(Buffer.concat([this.buildC0C1(), ...this.buildOpeningSequence()]));
7908
+ this.emit("connected");
7909
+ resolve();
7910
+ });
7911
+ });
7912
+ }
7913
+ close() {
7914
+ this.closed = true;
7915
+ if (this.pingTimer) clearInterval(this.pingTimer);
7916
+ this.pingTimer = null;
7917
+ this.socket?.destroy();
7918
+ }
7919
+ /** onStatus encoder — byte-exact replica of liblvmedia IOT_RTMP_SendMessage (csid 3, fmt 1, ts 0). */
7920
+ buildStatus(code, extra) {
7921
+ const pairs = [
7922
+ ["level", amfString("status")],
7923
+ ["code", amfString(code)],
7924
+ ["description", amfString("desc")]
7925
+ ];
7926
+ if (extra) pairs.push([extra[0], amfString(extra[1])]);
7927
+ const body = Buffer.concat([amfString("onStatus"), amfNumber(++this.transactionId), amfNull(), amfObject(pairs)]);
7928
+ return encodeMessage({ fmt: 1, csid: CSID_COMMAND, type: MSG.COMMAND_AMF0, body });
7929
+ }
7930
+ sendStatus(code, extra) {
7931
+ this.write(this.buildStatus(code, extra));
7932
+ this.opts.log(`>> onStatus ${code}${extra ? ` ${extra[0]}=${extra[1]}` : ""} (txn ${this.transactionId})`);
7933
+ }
7934
+ forceIFrame() {
7935
+ this.sendStatus(LV_STATUS.FORCE_IFRAME_START);
7936
+ }
7937
+ // ---------------------------------------------------------------- outgoing
7938
+ buildC0C1() {
7939
+ this.c1Time = Math.floor(performance.now()) >>> 0;
7940
+ const c1 = Buffer.alloc(1536);
7941
+ c1.writeUInt32BE(this.c1Time, 0);
7942
+ c1.writeUInt32BE(0, 4);
7943
+ randomBytes2(1528).copy(c1, 8);
7944
+ return Buffer.concat([Buffer.from([HANDSHAKE_C0_PRIVATE]), c1]);
7945
+ }
7946
+ buildConnect() {
7947
+ const obj = amfObject([
7948
+ ["app", amfString(this.endpoint.app)],
7949
+ ["tcUrl", amfString(this.endpoint.tcUrl)],
7950
+ ["fpad", amfBoolean(false)],
7951
+ ["capabilities", amfNumber(CONNECT_CAPABILITIES)],
7952
+ ["audioCodecs", amfNumber(CONNECT_AUDIO_CODECS)],
7953
+ ["videoCodecs", amfNumber(CONNECT_VIDEO_CODECS)],
7954
+ ["videoFunction", amfNumber(CONNECT_VIDEO_FUNCTION)]
7955
+ ]);
7956
+ const body = Buffer.concat([amfString("connect"), amfNumber(++this.transactionId), obj]);
7957
+ return encodeMessage({ fmt: 0, csid: CSID_COMMAND, msid: 0, type: MSG.COMMAND_AMF0, body });
7958
+ }
7959
+ buildPlay() {
7960
+ const body = Buffer.concat([
7961
+ amfString("play"),
7962
+ amfNumber(++this.transactionId),
7963
+ amfNull(),
7964
+ amfString(this.endpoint.playpath),
7965
+ amfNumber(PLAY_START_LIVE)
7966
+ ]);
7967
+ return encodeMessage({ fmt: 0, csid: CSID_PLAY, msid: PLAY_STREAM_ID, type: MSG.COMMAND_AMF0, body });
7968
+ }
7969
+ /** Exposed for the byte-exact self-test against the pcap. */
7970
+ buildOpeningSequence() {
7971
+ const seq = [this.buildConnect(), this.buildPlay()];
7972
+ if (this.opts.requestAudioType) seq.push(this.buildStatus(LV_STATUS.REQUEST_AUDIO_TYPE, ["audio_type", "1"]));
7973
+ return seq;
7974
+ }
7975
+ /**
7976
+ * Push one intercom (talk-back) audio frame UPSTREAM to the device — an RTMP
7977
+ * type-8 audio message on the talk chunk stream (csid 4, msid 1), exactly as
7978
+ * liblvmedia's `IOT_RTMP_SendAudio` does. `payload` is the raw codec audio
7979
+ * (default framing = G.711 A-law); `tsMs` is a running millisecond timestamp.
7980
+ * Only meaningful after the server sends {@link LV_STATUS.TALK_READY}.
7981
+ */
7982
+ sendAudioFrame(payload, tsMs, header = TALK_AUDIO_HEADER_G711A) {
7983
+ const body = Buffer.concat([Buffer.from([header]), payload]);
7984
+ this.write(
7985
+ encodeMessage({ fmt: 0, csid: CSID_TALK, msid: PLAY_STREAM_ID, timestamp: tsMs >>> 0, type: MSG.AUDIO, body })
7986
+ );
7987
+ }
7988
+ write(buf) {
7989
+ if (!this.socket || this.socket.destroyed) return;
7990
+ this.socket.write(buf);
7991
+ }
7992
+ sendAck() {
7993
+ const b = Buffer.alloc(4);
7994
+ b.writeUInt32BE(this.bytesIn >>> 0, 0);
7995
+ this.write(encodeMessage({ fmt: 0, csid: CSID_CONTROL, msid: 0, type: MSG.ACK, body: b }));
7996
+ this.bytesAcked = this.bytesIn;
7997
+ }
7998
+ sendPingResponse(serverTs) {
7999
+ const body = Buffer.concat([Buffer.from([0, 7]), serverTs]);
8000
+ this.write(encodeMessage({ fmt: 0, csid: CSID_CONTROL, msid: 0, type: MSG.USER_CONTROL, body }));
8001
+ }
8002
+ startPinging() {
8003
+ if (!this.opts.ping || this.pingTimer) return;
8004
+ this.sendStatus(LV_STATUS.PING_START);
8005
+ this.pingTimer = setInterval(() => this.sendStatus(LV_STATUS.PING_START), this.opts.pingIntervalMs);
8006
+ }
8007
+ // ---------------------------------------------------------------- incoming
8008
+ onData(data) {
8009
+ this.bytesIn += data.length;
8010
+ let payload = data;
8011
+ if (!this.handshakeDone) {
8012
+ this.handshakeBuf = Buffer.concat([this.handshakeBuf, data]);
8013
+ if (this.handshakeBuf.length < HANDSHAKE_S0S1_LEN) return;
8014
+ this.onHandshake(this.handshakeBuf.subarray(0, HANDSHAKE_S0S1_LEN));
8015
+ payload = this.handshakeBuf.subarray(HANDSHAKE_S0S1_LEN);
8016
+ this.handshakeBuf = Buffer.alloc(0);
8017
+ this.handshakeDone = true;
8018
+ if (payload.length === 0) return;
8019
+ }
8020
+ let messages;
8021
+ try {
8022
+ messages = this.reader.feed(payload);
8023
+ } catch (err) {
8024
+ this.fail(err instanceof Error ? err : new Error(String(err)));
8025
+ return;
8026
+ }
8027
+ for (const m of messages) this.dispatch(m);
8028
+ if (this.bytesIn - this.bytesAcked >= this.ackWindow) this.sendAck();
8029
+ }
8030
+ onHandshake(s0s1) {
8031
+ const s0 = s0s1[0];
8032
+ const serverTime = s0s1.readUInt32BE(1);
8033
+ const zero = s0s1.readUInt32BE(5);
8034
+ this.opts.log(`<< S0=0x${s0.toString(16)} S1.time=${serverTime} S1.zero=0x${zero.toString(16)} (client sent 0x04; app logs a mismatch but continues)`);
8035
+ }
8036
+ dispatch(m) {
8037
+ if (!this.firstMessageSeen) {
8038
+ this.firstMessageSeen = true;
8039
+ this.startPinging();
8040
+ }
8041
+ switch (m.type) {
8042
+ case MSG.SET_CHUNK_SIZE: {
8043
+ const size = m.body.readUInt32BE(0) & 2147483647;
8044
+ this.opts.log(`<< SetChunkSize ${size}`);
8045
+ this.reader.chunkSize = size;
8046
+ return;
8047
+ }
8048
+ case MSG.WINDOW_ACK_SIZE:
8049
+ this.ackWindow = Math.min(ACK_THRESHOLD_BYTES, m.body.readUInt32BE(0));
8050
+ this.opts.log(`<< WindowAckSize ${m.body.readUInt32BE(0)}`);
8051
+ return;
8052
+ case MSG.SET_PEER_BW:
8053
+ this.opts.log(`<< SetPeerBandwidth ${m.body.readUInt32BE(0)} limit=${m.body[4]}`);
8054
+ return;
8055
+ case MSG.ACK:
8056
+ case MSG.ABORT:
8057
+ return;
8058
+ case MSG.USER_CONTROL:
8059
+ return this.onUserControl(m.body);
8060
+ case MSG.COMMAND_AMF0:
8061
+ return this.onCommand(m);
8062
+ case MSG.DATA_AMF0:
8063
+ return this.onDataMessage(m.body);
8064
+ case MSG.AUDIO:
8065
+ return this.onAudio(m);
8066
+ case MSG.VIDEO:
8067
+ return this.onVideo(m);
8068
+ default:
8069
+ this.opts.log(`<< unknown message type ${m.type} len=${m.body.length}`);
8070
+ }
8071
+ }
8072
+ onUserControl(body) {
8073
+ const event = body.readUInt16BE(0);
8074
+ const names = { 0: "StreamBegin", 1: "StreamEOF", 2: "StreamDry", 4: "StreamIsRecorded", 6: "PingRequest", 7: "PingResponse" };
8075
+ this.opts.log(`<< UserControl ${names[event] ?? event} ${body.subarray(2).toString("hex")}`);
8076
+ if (event === 6) this.sendPingResponse(body.subarray(2, 6));
8077
+ }
8078
+ onCommand(m) {
8079
+ let values;
8080
+ try {
8081
+ values = decodeAmf0(m.body);
8082
+ } catch (err) {
8083
+ this.opts.log(`<< undecodable command: ${m.body.toString("hex")}`);
8084
+ return;
8085
+ }
8086
+ const [name, txn, , info] = values;
8087
+ this.opts.log(`<< ${String(name)} ${JSON.stringify(values.slice(1))}`);
8088
+ this.emit("command", String(name), values);
8089
+ if (name === "onStatus" && info && typeof info === "object" && !Array.isArray(info)) {
8090
+ const fields = info;
8091
+ this.emit("status", {
8092
+ code: String(fields.code ?? ""),
8093
+ level: String(fields.level ?? ""),
8094
+ description: String(fields.description ?? ""),
8095
+ fields,
8096
+ transactionId: typeof txn === "number" ? txn : 0
8097
+ });
8098
+ if (fields.code === LV_STATUS.PLAY_START && this.opts.forceIFrameOnStart) this.forceIFrame();
8099
+ }
8100
+ }
8101
+ onDataMessage(body) {
8102
+ try {
8103
+ const values = decodeAmf0(body);
8104
+ this.opts.log(`<< data ${JSON.stringify(values).slice(0, 300)}`);
8105
+ const obj = values.find((v) => v && typeof v === "object" && !Array.isArray(v));
8106
+ if (obj) this.emit("metadata", obj);
8107
+ } catch {
8108
+ this.opts.log(`<< undecodable data message len=${body.length}`);
8109
+ }
8110
+ }
8111
+ onAudio(m) {
8112
+ this.emit("audio", { timestamp: m.timestamp, body: m.body });
8113
+ const tag = parseAudioTag(m.body);
8114
+ if (!tag) return;
8115
+ if (tag.soundFormat === 10) {
8116
+ if (tag.aacPacketType === 0) {
8117
+ this.aacConfig = parseAacConfig(tag.data);
8118
+ this.emit("audioInfo", { codec: "aac", sampleRate: aacSampleRate(this.aacConfig), channels: this.aacConfig.channelConfiguration });
8119
+ return;
8120
+ }
8121
+ if (this.aacConfig) this.emit("audioFrame", aacToAdts(tag.data, this.aacConfig));
8122
+ return;
8123
+ }
8124
+ if (tag.soundFormat === 7 || tag.soundFormat === 8) {
8125
+ if (!this.aacConfig) this.emit("audioInfo", { codec: tag.soundFormat === 7 ? "g711a" : "g711u", sampleRate: 8e3, channels: 1 });
8126
+ this.emit("audioFrame", tag.data);
8127
+ return;
8128
+ }
8129
+ this.emit("audioInfo", { codec: "unknown", sampleRate: 0, channels: tag.channels });
8130
+ }
8131
+ onVideo(m) {
8132
+ this.emit("video", { timestamp: m.timestamp, body: m.body });
8133
+ const tag = parseVideoTag(m.body);
8134
+ if (!tag || tag.codecId !== 7) return;
8135
+ if (tag.avcPacketType === 0) {
8136
+ this.avcConfig = parseAvcConfig(tag.data);
8137
+ return;
8138
+ }
8139
+ if (tag.avcPacketType !== 1 || !this.avcConfig) return;
8140
+ const isKeyframe = tag.frameType === 1;
8141
+ this.emit("videoAccessUnit", {
8142
+ data: avccToAnnexB(tag.data, this.avcConfig, isKeyframe),
8143
+ isKeyframe,
8144
+ videoType: "H264",
8145
+ microseconds: m.timestamp * 1e3,
8146
+ time: m.timestamp + tag.compositionTime
8147
+ });
8148
+ }
8149
+ fail(err) {
8150
+ this.opts.log(`!! ${err.message}`);
8151
+ this.emit("error", err);
8152
+ this.close();
8153
+ }
8154
+ onClose() {
8155
+ if (this.pingTimer) clearInterval(this.pingTimer);
8156
+ this.pingTimer = null;
8157
+ if (!this.closed) this.opts.log("socket closed by peer");
8158
+ this.closed = true;
8159
+ this.emit("close");
8160
+ }
8161
+ };
8162
+
8163
+ // src/video/media/g711.ts
8164
+ var ALAW_SEG_END = [31, 63, 127, 255, 511, 1023, 2047, 4095];
8165
+ function pcm16ToALaw(sample) {
8166
+ let s = sample;
8167
+ let sign = ~s >> 8 & 128;
8168
+ if (sign === 0) s = -s;
8169
+ if (s > 32635) s = 32635;
8170
+ let aval;
8171
+ if (s >= 256) {
8172
+ let seg = 7;
8173
+ for (let i = 0; i < 8; i++) {
8174
+ if (s <= ALAW_SEG_END[i]) {
8175
+ seg = i;
8176
+ break;
8177
+ }
8178
+ }
8179
+ const mantissa = s >> seg + 3 & 15;
8180
+ aval = seg << 4 | mantissa;
8181
+ } else {
8182
+ aval = s >> 4;
8183
+ }
8184
+ return (aval ^ sign ^ 85) & 255;
8185
+ }
8186
+ function pcm16leToALaw(pcm) {
8187
+ const count = pcm.length >> 1;
8188
+ const out = Buffer.allocUnsafe(count);
8189
+ for (let i = 0; i < count; i++) {
8190
+ out[i] = pcm16ToALaw(pcm.readInt16LE(i * 2));
8191
+ }
8192
+ return out;
8193
+ }
8194
+
8195
+ // src/video/media/camera-stream.ts
8196
+ var DreameCameraStream = class extends TypedEmitter {
8197
+ #controller;
8198
+ #clientFactory;
8199
+ #log;
8200
+ #client = null;
8201
+ #state = "idle";
8202
+ #videoFrames = 0;
8203
+ #audioFrames = 0;
8204
+ #talkReady = false;
8205
+ #talkWaiters = [];
8206
+ #talkTs = 0;
8207
+ constructor(input) {
8208
+ super();
8209
+ this.#controller = input.controller;
8210
+ this.#log = input.log ?? (() => {
8211
+ });
8212
+ this.#clientFactory = input.clientFactory ?? ((url) => new LvRtmpClient({
8213
+ url,
8214
+ ...input.requestAudioType !== void 0 ? { requestAudioType: input.requestAudioType } : {},
8215
+ ...input.ping !== void 0 ? { ping: input.ping } : {},
8216
+ ...input.log ? { log: input.log } : {}
8217
+ }));
8218
+ }
8219
+ /** True while frames are flowing. */
8220
+ get isRunning() {
8221
+ return this.#state === "running";
8222
+ }
8223
+ /**
8224
+ * Cold-start the monitor, connect to the relay, and begin emitting frames.
8225
+ * Resolves once connected. Throws (and cleans up) if the monitor or relay
8226
+ * connection fails.
8227
+ */
8228
+ async start() {
8229
+ if (this.#state !== "idle") {
8230
+ throw new DreameError(`camera stream cannot start from state '${this.#state}'`);
8231
+ }
8232
+ this.#state = "starting";
8233
+ try {
8234
+ this.#log("[nodedreame:stream] starting");
8235
+ const handle = await this.#controller.open();
8236
+ const client = this.#clientFactory(handle.rtmpUrl);
8237
+ this.#client = client;
8238
+ client.on("videoAccessUnit", (e) => {
8239
+ this.#videoFrames += 1;
8240
+ if (this.#videoFrames === 1) this.#log("[nodedreame:stream] first video access unit");
8241
+ this.emit("videoAccessUnit", e);
8242
+ });
8243
+ client.on("audioFrame", (buf) => {
8244
+ this.#audioFrames += 1;
8245
+ if (this.#audioFrames === 1) this.#log("[nodedreame:stream] first audio frame");
8246
+ this.emit("audioFrame", buf);
8247
+ });
8248
+ client.on("audioInfo", (info) => {
8249
+ this.#log(`[nodedreame:stream] audio ${info.codec} ${info.sampleRate}Hz x${info.channels}`);
8250
+ this.emit("audioInfo", info);
8251
+ });
8252
+ client.on("status", (e) => {
8253
+ if (e.code === LV_STATUS.TALK_READY) {
8254
+ this.#log("[nodedreame:stream] talk channel ready");
8255
+ this.#talkReady = true;
8256
+ const waiters = this.#talkWaiters;
8257
+ this.#talkWaiters = [];
8258
+ for (const w of waiters) w();
8259
+ }
8260
+ });
8261
+ client.on("error", (err) => {
8262
+ this.#log(`[nodedreame:stream] rtmp error: ${err.message}`);
8263
+ this.emit("error", err);
8264
+ });
8265
+ client.on("close", () => {
8266
+ this.#log("[nodedreame:stream] upstream relay closed");
8267
+ if (this.#state === "running") void this.stop();
8268
+ });
8269
+ await client.connect();
8270
+ this.#state = "running";
8271
+ this.#log("[nodedreame:stream] connected to relay");
8272
+ this.emit("started", { rtmpUrl: handle.rtmpUrl });
8273
+ return { rtmpUrl: handle.rtmpUrl };
8274
+ } catch (err) {
8275
+ this.#state = "idle";
8276
+ await this.#safeTeardown();
8277
+ throw err;
8278
+ }
8279
+ }
8280
+ /** Close the relay connection and release the monitor. Idempotent. */
8281
+ async stop() {
8282
+ if (this.#state === "idle" || this.#state === "stopping") return;
8283
+ this.#state = "stopping";
8284
+ this.#log(`[nodedreame:stream] stopping (video=${this.#videoFrames} audio=${this.#audioFrames})`);
8285
+ await this.#safeTeardown();
8286
+ this.#state = "idle";
8287
+ this.emit("stopped");
8288
+ }
8289
+ /**
8290
+ * Open the two-way intercom: send the MIoT start, then resolve once the relay
8291
+ * signals the talk channel is ready (so it's safe to {@link sendTalkPcm}).
8292
+ * Rejects if not ready within `timeoutMs`.
8293
+ */
8294
+ async startTalk(opts) {
8295
+ if (this.#state !== "running") throw new DreameError("stream is not running; start() first");
8296
+ this.#log("[nodedreame:stream] intercom start");
8297
+ await this.#controller.startIntercom(
8298
+ opts?.needRecordSound !== void 0 || opts?.videoCall !== void 0 ? { ...opts.needRecordSound !== void 0 ? { needRecordSound: opts.needRecordSound } : {}, ...opts.videoCall ? { videoCall: true } : {} } : void 0
8299
+ );
8300
+ this.#talkTs = 0;
8301
+ await this.whenTalkReady(opts?.timeoutMs ?? 5e3);
8302
+ }
8303
+ /** Resolve when the talk channel is ready (or reject on timeout). */
8304
+ whenTalkReady(timeoutMs = 5e3) {
8305
+ if (this.#talkReady) return Promise.resolve();
8306
+ return new Promise((resolve, reject) => {
8307
+ const timer = setTimeout(() => reject(new DreameError("timed out waiting for TalkReady")), timeoutMs);
8308
+ timer.unref?.();
8309
+ this.#talkWaiters.push(() => {
8310
+ clearTimeout(timer);
8311
+ resolve();
8312
+ });
8313
+ });
8314
+ }
8315
+ /**
8316
+ * Encode 16-bit LE mono PCM (8 kHz) to G.711 A-law and push it upstream.
8317
+ * `tsMs` defaults to a running clock advanced by the frame duration.
8318
+ */
8319
+ sendTalkPcm(pcm16le, tsMs) {
8320
+ const alaw = pcm16leToALaw(pcm16le);
8321
+ this.sendTalkAlaw(alaw, tsMs);
8322
+ }
8323
+ /** Push raw A-law audio upstream (advancing the running talk clock). */
8324
+ sendTalkAlaw(alaw, tsMs) {
8325
+ if (!this.#client) throw new DreameError("stream is not running");
8326
+ const ts = tsMs ?? this.#talkTs;
8327
+ this.#client.sendAudioFrame(alaw, ts);
8328
+ this.#talkTs = ts + Math.round(alaw.length / 8);
8329
+ }
8330
+ /** Close the two-way intercom (MIoT stop). */
8331
+ async stopTalk() {
8332
+ this.#talkReady = false;
8333
+ this.#log("[nodedreame:stream] intercom stop");
8334
+ await this.#controller.stopIntercom();
8335
+ }
8336
+ async #safeTeardown() {
8337
+ this.#talkReady = false;
8338
+ this.#talkWaiters = [];
8339
+ try {
8340
+ this.#client?.close();
8341
+ } catch {
8342
+ }
8343
+ this.#client = null;
8344
+ try {
8345
+ await this.#controller.close();
8346
+ } catch (err) {
8347
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
8348
+ }
8349
+ }
8350
+ };
6020
8351
  export {
6021
8352
  AI_FEATURE_BIT,
6022
8353
  AI_FEATURE_JSON_KEY,
8354
+ ALIYUN_REGION_ID,
6023
8355
  AUTO_SWITCH_JSON_KEY,
6024
8356
  BaseDevice,
6025
8357
  ChargingStatus,
6026
8358
  CleaningMode,
8359
+ DRIVE_DIRECTIONS,
6027
8360
  DefaultCapabilityResolver,
6028
8361
  DreameApiError,
6029
8362
  DreameAuthError,
8363
+ DreameCameraController,
8364
+ DreameCameraStream,
6030
8365
  DreameDeviceOfflineError,
6031
8366
  DreameError,
6032
8367
  DreameTransportError,
8368
+ DreameVideoSession,
6033
8369
  LIBRARY_NAME,
8370
+ LV_STATUS,
8371
+ LvRtmpClient,
8372
+ MONITOR_AIID,
8373
+ MONITOR_PIID,
8374
+ MONITOR_SIID,
8375
+ MONITOR_VENDOR_TOKEN,
6034
8376
  MODEL_CAPABILITIES2 as MOWER_MODEL_CAPABILITIES,
6035
8377
  MOWER_TASK_SUBSTATES,
6036
8378
  MiotError,
@@ -6043,33 +8385,65 @@ export {
6043
8385
  MowerStatus,
6044
8386
  MowerTaskStatus,
6045
8387
  Nodreame,
8388
+ PET_SOUNDS,
6046
8389
  SuctionLevel,
8390
+ TALK_AUDIO_HEADER_G711A,
6047
8391
  TaskStatus,
8392
+ VACUUM_ACTIONS,
6048
8393
  VACUUM_CONSUMABLES,
6049
8394
  MODEL_CAPABILITIES as VACUUM_MODEL_CAPABILITIES,
8395
+ VACUUM_MOVE,
8396
+ VACUUM_SIID,
6050
8397
  VacuumCapabilityResolver,
6051
8398
  VacuumDevice,
6052
8399
  WaterVolume,
8400
+ aacSampleRate,
8401
+ aacToAdts,
8402
+ avccToAnnexB,
6053
8403
  consumableSpec,
6054
8404
  createClientDumper,
6055
8405
  createDumper,
8406
+ createIotSession,
6056
8407
  decodeAiFeature,
6057
8408
  decodeAutoSwitch,
6058
8409
  decodeAutoSwitchAll,
8410
+ decodeWifiSignalMap,
6059
8411
  encodeAiFeatureWrite,
6060
8412
  encodeAutoSwitchWrite,
6061
8413
  extractMowerConsumableValues,
8414
+ getAliyunAuthCode,
8415
+ getDeviceVideoProfile,
6062
8416
  getMowerCapabilities,
6063
8417
  getVacuumCapabilities,
8418
+ getVideoAccessToken,
8419
+ getVideoFamilyId,
8420
+ hashAccessCode,
8421
+ isAuthRefusedError,
6064
8422
  isDreameConsumableKey,
8423
+ listDevices,
8424
+ loginByOauth,
8425
+ makeMonitorSession,
6065
8426
  mowerConsumableIndex,
6066
8427
  mowerFaultSeverity,
8428
+ parseAacConfig,
8429
+ parseAvcConfig,
8430
+ parseConnectivity,
6067
8431
  parseMowerConsumables,
6068
8432
  parseMowerHeartbeat,
6069
8433
  parseMowingProgress,
8434
+ parseObstacleData,
8435
+ parsePersonFollow,
8436
+ parseRelayUrl,
8437
+ pcm16ToALaw,
8438
+ pcm16leToALaw,
8439
+ remoteDriveValue,
6070
8440
  renderMowerSvg,
6071
8441
  renderVacuumPng,
8442
+ renderWifiSignalPng,
6072
8443
  resolveCapabilities,
6073
- supportedAutoSwitchKeys
8444
+ signApiGatewayRequest,
8445
+ streamQuery,
8446
+ supportedAutoSwitchKeys,
8447
+ toVideoProfile
6074
8448
  };
6075
8449
  //# sourceMappingURL=index.js.map