@camstack/addon-provider-ecowitt 0.1.11 → 0.1.13

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.
Files changed (3) hide show
  1. package/dist/addon.js +372 -28
  2. package/dist/addon.mjs +372 -28
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -4645,7 +4645,7 @@ function preprocess(fn, schema) {
4645
4645
  });
4646
4646
  }
4647
4647
  //#endregion
4648
- //#region ../types/dist/sleep-C2M2zF7x.mjs
4648
+ //#region ../types/dist/sleep-MHm--th-.mjs
4649
4649
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4650
4650
  EventCategory["SystemBoot"] = "system.boot";
4651
4651
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -5907,6 +5907,7 @@ var CamStreamKindSchema = _enum([
5907
5907
  "pull-rtsp",
5908
5908
  "pull-rtmp",
5909
5909
  "pull-http",
5910
+ "pull-flv",
5910
5911
  "pull-rfc4571",
5911
5912
  "push-annexb",
5912
5913
  "derived"
@@ -7066,7 +7067,21 @@ var StorageLocationDeclarationSchema = object({
7066
7067
  * slots (e.g. `recordingsLow` → `recordings`) so operators only need to
7067
7068
  * configure the primary location.
7068
7069
  */
7069
- defaultsTo: string().optional()
7070
+ defaultsTo: string().optional(),
7071
+ /**
7072
+ * Which node root the seeded `<id>:default` instance is placed under on a
7073
+ * FRESH install:
7074
+ * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
7075
+ * the appData volume. Right for small/durable data (backups, logs, models).
7076
+ * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
7077
+ * env is set, else falls back to the data root. Right for bulky, hot media
7078
+ * (recordings, event media) that should stay off the appData disk.
7079
+ *
7080
+ * Only affects the seeded default's `basePath`; operators can repoint any
7081
+ * location afterwards, and a `defaultsTo` slot inherits its parent's root
7082
+ * regardless of this field. Absent (the common case) is treated as `'data'`.
7083
+ */
7084
+ defaultRoot: _enum(["data", "media"]).optional()
7070
7085
  });
7071
7086
  var DecoderStatsSchema = object({
7072
7087
  inputFps: number(),
@@ -8422,6 +8437,10 @@ var RtspRestreamEntrySchema = object({
8422
8437
  var BrokerRtspClientSchema = object({
8423
8438
  sessionId: string(),
8424
8439
  remoteAddr: string(),
8440
+ /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
8441
+ * null/absent when the client sent none. Lets the UI label a consumer by
8442
+ * purpose. Optional so a client built against an older schema stays valid. */
8443
+ userAgent: string().nullish(),
8425
8444
  playing: boolean(),
8426
8445
  muted: boolean(),
8427
8446
  connectedAt: number(),
@@ -16025,7 +16044,7 @@ var AddBrokerInputSchema = object({
16025
16044
  });
16026
16045
  var AddBrokerResultSchema = object({ id: string() });
16027
16046
  var IdInputSchema = object({ id: string() });
16028
- var TestResultSchema = discriminatedUnion("ok", [object({
16047
+ var TestResultSchema$1 = discriminatedUnion("ok", [object({
16029
16048
  ok: literal(true),
16030
16049
  latencyMs: number()
16031
16050
  }), object({
@@ -16048,7 +16067,7 @@ var StatusSchema = object({
16048
16067
  brokerCount: number(),
16049
16068
  embeddedRunning: boolean()
16050
16069
  });
16051
- method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
16070
+ method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema$1, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
16052
16071
  var NetworkEndpointSchema = object({
16053
16072
  url: string(),
16054
16073
  hostname: string(),
@@ -16082,23 +16101,198 @@ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
16082
16101
  sourcePort: number().optional()
16083
16102
  });
16084
16103
  method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
16085
- method(object({
16086
- title: string(),
16104
+ /**
16105
+ * notification-output — canonical, capability-gated notification delivery.
16106
+ *
16107
+ * Apprise-derived model (see
16108
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
16109
+ * callers emit ONE canonical `Notification`; each provider declares a
16110
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
16111
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
16112
+ * message to what the kind supports — callers never special-case a service.
16113
+ *
16114
+ * DESIGN DECISIONS (locked):
16115
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
16116
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
16117
+ * cap. Rationale: the admin UI needs one uniform surface across the
16118
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
16119
+ * alternative would fork the UI per addon and cannot host the
16120
+ * discovery→adopt flow.
16121
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
16122
+ * the generated cap-mount auto-`concatCollection`-fans them across every
16123
+ * registered provider (notifiers addon + HA addon) so one catalog is
16124
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
16125
+ * `addonId` the generated collection router extracts from the call input.
16126
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
16127
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
16128
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
16129
+ * base64 fallback needed.
16130
+ *
16131
+ * TODO (deferred, closed-set change — separate decision): add
16132
+ * `providerKind: 'notify'` so notification providers surface on the unified
16133
+ * admin "Integrations" page.
16134
+ */
16135
+ /**
16136
+ * Zentik-derived typed-media enum — the superset across every kind. Each
16137
+ * adapter picks what it supports and the degrade engine filters the rest.
16138
+ */
16139
+ var AttachmentMediaTypeSchema = _enum([
16140
+ "image",
16141
+ "video",
16142
+ "gif",
16143
+ "audio",
16144
+ "icon"
16145
+ ]);
16146
+ /**
16147
+ * A single attachment. Exactly one of `url` (remote source, most adapters
16148
+ * prefer this) or `bytes` (inline source; required for Pushover-style
16149
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
16150
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
16151
+ */
16152
+ var AttachmentSchema = object({
16153
+ mediaType: AttachmentMediaTypeSchema,
16154
+ url: string().optional(),
16155
+ bytes: _instanceof(Uint8Array).optional(),
16156
+ mime: string().optional(),
16157
+ name: string().optional()
16158
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
16159
+ var NotificationFormatSchema = _enum([
16160
+ "text",
16161
+ "markdown",
16162
+ "html"
16163
+ ]);
16164
+ /** A single tap-through action button. */
16165
+ var NotificationActionSchema = object({
16166
+ id: string(),
16167
+ label: string(),
16168
+ url: string().optional()
16169
+ });
16170
+ /**
16171
+ * The canonical notification. `body` is the only hard field (Apprise model).
16172
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
16173
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
16174
+ * the adapter maps this ordinal onto its native level. `level?` is an
16175
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
16176
+ * `priority` for that one target.
16177
+ */
16178
+ var NotificationSchema = object({
16087
16179
  body: string(),
16088
- imageUrl: string().optional(),
16180
+ title: string().optional(),
16181
+ format: NotificationFormatSchema.default("text"),
16182
+ priority: number().int().min(1).max(5).default(3),
16183
+ level: string().optional(),
16184
+ attachments: array(AttachmentSchema).optional(),
16185
+ clickUrl: string().optional(),
16186
+ actions: array(NotificationActionSchema).optional(),
16187
+ sound: string().optional(),
16188
+ ttl: number().optional(),
16189
+ tag: string().optional(),
16089
16190
  deviceId: number().optional(),
16090
16191
  eventId: string().optional(),
16091
- priority: _enum([
16092
- "low",
16093
- "normal",
16094
- "high",
16095
- "critical"
16096
- ]).default("normal"),
16097
16192
  metadata: record(string(), unknown()).optional()
16098
- }), _void(), { kind: "mutation" }), method(_void(), object({
16193
+ });
16194
+ /** One declared native severity/priority level for a kind. */
16195
+ var TargetKindLevelSchema = object({
16196
+ id: string(),
16197
+ label: string(),
16198
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
16199
+ ordinal: number().int().min(1).max(5).nullable(),
16200
+ flags: object({
16201
+ critical: boolean().optional(),
16202
+ silent: boolean().optional(),
16203
+ noPush: boolean().optional()
16204
+ }).optional(),
16205
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
16206
+ requires: array(string()).optional(),
16207
+ description: string().optional()
16208
+ });
16209
+ /** The full capability block consulted before dispatch. */
16210
+ var TargetKindCapsSchema = object({
16211
+ attachments: object({
16212
+ mediaTypes: array(AttachmentMediaTypeSchema),
16213
+ mode: _enum([
16214
+ "url",
16215
+ "bytes",
16216
+ "both"
16217
+ ]),
16218
+ max: number().int().nonnegative(),
16219
+ maxBytes: number().int().positive().optional()
16220
+ }),
16221
+ /** Max action buttons (0 = none). */
16222
+ actions: number().int().nonnegative(),
16223
+ levels: array(TargetKindLevelSchema),
16224
+ format: array(NotificationFormatSchema),
16225
+ clickUrl: boolean(),
16226
+ sound: boolean(),
16227
+ ttl: boolean(),
16228
+ bodyMaxLen: number().int().positive()
16229
+ });
16230
+ /**
16231
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
16232
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
16233
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
16234
+ * the union is large and not meant for runtime validation here; the exported
16235
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
16236
+ */
16237
+ var ConfigSchemaPassthrough = unknown();
16238
+ var TargetKindSchema = object({
16239
+ kind: string(),
16240
+ label: string(),
16241
+ icon: string(),
16242
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
16243
+ addonId: string(),
16244
+ configSchema: ConfigSchemaPassthrough,
16245
+ supportsDiscovery: boolean(),
16246
+ caps: TargetKindCapsSchema
16247
+ });
16248
+ /**
16249
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
16250
+ * (return a presence marker only) when serving `listTargets` — never
16251
+ * round-trip a stored secret to the UI.
16252
+ */
16253
+ var TargetSchema = object({
16254
+ id: string(),
16255
+ name: string(),
16256
+ kind: string(),
16257
+ addonId: string(),
16258
+ enabled: boolean(),
16259
+ config: record(string(), unknown())
16260
+ });
16261
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
16262
+ var DiscoveredTargetSchema = object({
16263
+ kind: string(),
16264
+ suggestedName: string(),
16265
+ config: record(string(), unknown())
16266
+ });
16267
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
16268
+ var RenderedAsSchema = object({
16269
+ level: string(),
16270
+ format: NotificationFormatSchema,
16271
+ attachmentsSent: number().int().nonnegative(),
16272
+ actionsSent: number().int().nonnegative(),
16273
+ truncated: boolean(),
16274
+ dropped: array(string())
16275
+ });
16276
+ var SendResultSchema = object({
16099
16277
  success: boolean(),
16100
- error: string().optional()
16101
- }), { kind: "mutation" });
16278
+ error: string().optional(),
16279
+ renderedAs: RenderedAsSchema.optional()
16280
+ });
16281
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
16282
+ var TestResultSchema = SendResultSchema;
16283
+ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
16284
+ kind: string(),
16285
+ config: record(string(), unknown()).optional()
16286
+ }), array(DiscoveredTargetSchema)), method(object({
16287
+ targetId: string(),
16288
+ notification: NotificationSchema
16289
+ }), SendResultSchema, { kind: "mutation" }), method(object({
16290
+ targetId: string(),
16291
+ sample: NotificationSchema.optional()
16292
+ }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
16293
+ targetId: string(),
16294
+ enabled: boolean()
16295
+ }), _void(), { kind: "mutation" });
16102
16296
  /**
16103
16297
  * Zod schemas for persisted record types.
16104
16298
  *
@@ -22093,13 +22287,49 @@ Object.freeze({
22093
22287
  addonId: null,
22094
22288
  access: "create"
22095
22289
  },
22290
+ "notificationOutput.deleteTarget": {
22291
+ capName: "notification-output",
22292
+ capScope: "system",
22293
+ addonId: null,
22294
+ access: "delete"
22295
+ },
22296
+ "notificationOutput.discoverTargets": {
22297
+ capName: "notification-output",
22298
+ capScope: "system",
22299
+ addonId: null,
22300
+ access: "view"
22301
+ },
22302
+ "notificationOutput.listTargetKinds": {
22303
+ capName: "notification-output",
22304
+ capScope: "system",
22305
+ addonId: null,
22306
+ access: "view"
22307
+ },
22308
+ "notificationOutput.listTargets": {
22309
+ capName: "notification-output",
22310
+ capScope: "system",
22311
+ addonId: null,
22312
+ access: "view"
22313
+ },
22096
22314
  "notificationOutput.send": {
22097
22315
  capName: "notification-output",
22098
22316
  capScope: "system",
22099
22317
  addonId: null,
22100
22318
  access: "create"
22101
22319
  },
22102
- "notificationOutput.sendTest": {
22320
+ "notificationOutput.setTargetEnabled": {
22321
+ capName: "notification-output",
22322
+ capScope: "system",
22323
+ addonId: null,
22324
+ access: "create"
22325
+ },
22326
+ "notificationOutput.testTarget": {
22327
+ capName: "notification-output",
22328
+ capScope: "system",
22329
+ addonId: null,
22330
+ access: "create"
22331
+ },
22332
+ "notificationOutput.upsertTarget": {
22103
22333
  capName: "notification-output",
22104
22334
  capScope: "system",
22105
22335
  addonId: null,
@@ -30072,22 +30302,23 @@ function slugifyHost(value) {
30072
30302
  }
30073
30303
  /**
30074
30304
  * Extra per-scan inputs for Ecowitt network discovery. Mirrors Gree's scan form. On a flat network,
30075
- * leave blank to sweep the addon node's local subnet. Gateways on ANOTHER subnet usually can't be
30076
- * reached by a directed broadcast (most routers don't forward it) enter the gateway's IP instead:
30077
- * the `CMD_BROADCAST` probe answers a unicast just as well and returns the gateway's identity.
30305
+ * leave blank to sweep the addon node's local subnet. To reach gateways on OTHER subnets, list one or
30306
+ * more directed broadcast addresses, gateway IPs, or CIDRs (comma / newline separated) each is
30307
+ * swept independently and the responders are merged. A directed broadcast (e.g. 192.168.20.255) or a
30308
+ * unicast gateway IP both work because the `CMD_BROADCAST` probe answers a unicast just as well.
30078
30309
  */
30079
30310
  function buildDiscoveryParamsFormSchema() {
30080
30311
  return { sections: [{
30081
30312
  id: "scan",
30082
30313
  title: "Scan options",
30083
- description: "Leave empty to scan the local subnet. To find a gateway on a different subnet, enter that subnet’s broadcast address (e.g. 192.168.20.255) or the gateway’s IP directly (e.g. 192.168.20.181).",
30314
+ description: "Leave empty to scan the local subnet. To scan other subnets, list their broadcast addresses (e.g. 192.168.20.255), gateway IPs (e.g. 192.168.20.181), or CIDRs (e.g. 192.168.20.0/24) — comma or newline separated. Each subnet is swept independently and unreachable ones are skipped.",
30084
30315
  columns: 1,
30085
30316
  fields: [{
30086
30317
  type: "text",
30087
30318
  key: "broadcastAddress",
30088
- label: "Broadcast address or gateway IP",
30319
+ label: "Broadcast addresses / gateway IPs / CIDRs",
30089
30320
  required: false,
30090
- placeholder: "192.168.20.255"
30321
+ placeholder: "192.168.20.255, 192.168.30.0/24"
30091
30322
  }, {
30092
30323
  type: "number",
30093
30324
  key: "timeoutMs",
@@ -30240,6 +30471,111 @@ var EcowittIntegrationManager = class {
30240
30471
  }
30241
30472
  };
30242
30473
  //#endregion
30474
+ //#region src/ecowitt-network-scan.ts
30475
+ /** Strict dotted-quad IPv4 octet (0–255), no leading-zero ambiguity beyond what routers accept. */
30476
+ function parseOctet(token) {
30477
+ if (!/^\d{1,3}$/.test(token)) return null;
30478
+ const n = Number(token);
30479
+ return n >= 0 && n <= 255 ? n : null;
30480
+ }
30481
+ /** Parse a dotted-quad into its four octets, or null when malformed. */
30482
+ function parseIpv4(value) {
30483
+ const parts = value.split(".");
30484
+ if (parts.length !== 4) return null;
30485
+ const octets = parts.map(parseOctet);
30486
+ if (octets.some((o) => o === null)) return null;
30487
+ return [
30488
+ octets[0],
30489
+ octets[1],
30490
+ octets[2],
30491
+ octets[3]
30492
+ ];
30493
+ }
30494
+ /**
30495
+ * Convert an IPv4 CIDR (e.g. `192.168.20.0/24`) into its directed broadcast address
30496
+ * (`192.168.20.255`) — the address `Ecowitt.discover` sweeps to reach a foreign subnet. Returns
30497
+ * `null` for anything that is not a well-formed IPv4 CIDR (a bare IP, garbage, or an out-of-range
30498
+ * prefix), so the caller can treat the input as a literal broadcast/gateway address instead.
30499
+ */
30500
+ function cidrToBroadcast(value) {
30501
+ const slash = value.indexOf("/");
30502
+ if (slash < 0) return null;
30503
+ const ip = parseIpv4(value.slice(0, slash));
30504
+ const prefixToken = value.slice(slash + 1);
30505
+ if (ip === null || !/^\d{1,2}$/.test(prefixToken)) return null;
30506
+ const prefix = Number(prefixToken);
30507
+ if (prefix < 0 || prefix > 32) return null;
30508
+ const broadcast = ((ip[0] << 24 | ip[1] << 16 | ip[2] << 8 | ip[3]) >>> 0 | (prefix === 0 ? 4294967295 : 4294967295 >>> prefix >>> 0)) >>> 0;
30509
+ return [
30510
+ broadcast >>> 24 & 255,
30511
+ broadcast >>> 16 & 255,
30512
+ broadcast >>> 8 & 255,
30513
+ broadcast & 255
30514
+ ].join(".");
30515
+ }
30516
+ /**
30517
+ * Parse the operator scan input into a de-duplicated, ordered list of directed broadcast targets.
30518
+ *
30519
+ * Accepts a free-form string of comma / newline / whitespace separated tokens where each token is
30520
+ * either a broadcast address (`192.168.20.255`), a bare gateway IP (`192.168.20.181` — the
30521
+ * `CMD_BROADCAST` probe answers a unicast too), or a CIDR (`192.168.20.0/24`, expanded to its
30522
+ * directed broadcast). Blank input yields an empty list, meaning "sweep the local subnet only".
30523
+ * Invalid tokens are dropped rather than throwing — discovery input is best-effort.
30524
+ */
30525
+ function parseBroadcastTargets(raw) {
30526
+ const seen = /* @__PURE__ */ new Set();
30527
+ const out = [];
30528
+ for (const rawToken of raw.split(/[\s,]+/)) {
30529
+ const token = rawToken.trim();
30530
+ if (token.length === 0) continue;
30531
+ const target = token.includes("/") ? cidrToBroadcast(token) : parseIpv4(token) ? token : null;
30532
+ if (target === null || seen.has(target)) continue;
30533
+ seen.add(target);
30534
+ out.push(target);
30535
+ }
30536
+ return out;
30537
+ }
30538
+ /** Deterministic dedup key for a discovered gateway — MAC is the physical identity. */
30539
+ function gatewayKey(gateway) {
30540
+ return gateway.mac.trim().toLowerCase() || gateway.ip;
30541
+ }
30542
+ /**
30543
+ * Cross-subnet Ecowitt discovery: run one {@link EcowittDiscoverFn} sweep per target subnet (or a
30544
+ * single local sweep when there are no targets), then merge the responders de-duplicated by MAC.
30545
+ *
30546
+ * Every sweep runs independently and its failure (an unreachable subnet, a router that drops the
30547
+ * directed broadcast, a socket error) is isolated via `onError` — a single bad subnet never fails the
30548
+ * whole scan. Sweeps run concurrently; results are flattened in target order so the merged output is
30549
+ * stable regardless of which subnet answers first.
30550
+ */
30551
+ async function scanEcowittGateways(input) {
30552
+ const { discover, targets, timeoutMs, onError } = input;
30553
+ const timeoutOpt = timeoutMs !== void 0 ? { timeoutMs } : {};
30554
+ if (targets.length === 0) try {
30555
+ return [...await discover({ ...timeoutOpt })];
30556
+ } catch (error) {
30557
+ onError?.("local", error);
30558
+ return [];
30559
+ }
30560
+ const settled = await Promise.all(targets.map(async (broadcastAddr) => {
30561
+ try {
30562
+ return await discover({
30563
+ broadcastAddr,
30564
+ ...timeoutOpt
30565
+ });
30566
+ } catch (error) {
30567
+ onError?.(broadcastAddr, error);
30568
+ return [];
30569
+ }
30570
+ }));
30571
+ const merged = /* @__PURE__ */ new Map();
30572
+ for (const batch of settled) for (const gateway of batch) {
30573
+ const key = gatewayKey(gateway);
30574
+ if (!merged.has(key)) merged.set(key, gateway);
30575
+ }
30576
+ return [...merged.values()];
30577
+ }
30578
+ //#endregion
30243
30579
  //#region src/ecowitt-gateway.ts
30244
30580
  /**
30245
30581
  * Per-gateway registry that device classes use to reach the live {@link Ecowitt}
@@ -31259,15 +31595,23 @@ var EcowittProviderAddon = class extends BaseDeviceProvider {
31259
31595
  return buildDiscoveryParamsFormSchema();
31260
31596
  }
31261
31597
  async discoverDevices(input) {
31262
- const broadcastAddr = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"].trim() : "";
31598
+ const rawTargets = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"] : "";
31263
31599
  const timeoutMs = typeof input?.params?.["timeoutMs"] === "number" ? input.params["timeoutMs"] : void 0;
31264
- const gateways = await Ecowitt.discover({
31265
- ...broadcastAddr.length > 0 ? { broadcastAddr } : {},
31266
- ...timeoutMs !== void 0 ? { timeoutMs } : {}
31600
+ const targets = parseBroadcastTargets(rawTargets);
31601
+ const gateways = await scanEcowittGateways({
31602
+ discover: (opts) => Ecowitt.discover(opts),
31603
+ targets,
31604
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
31605
+ onError: (target, error) => {
31606
+ this.ctx.logger.warn("Ecowitt subnet sweep failed — skipping", { meta: {
31607
+ target,
31608
+ error: errMsg(error)
31609
+ } });
31610
+ }
31267
31611
  });
31268
31612
  this.ctx.logger.info("Ecowitt discovery complete", { meta: {
31269
31613
  count: gateways.length,
31270
- broadcastAddr: broadcastAddr.length > 0 ? broadcastAddr : "local"
31614
+ subnets: targets.length > 0 ? targets : "local"
31271
31615
  } });
31272
31616
  return gateways.map((g) => {
31273
31617
  const displayName = g.model ?? g.name;
package/dist/addon.mjs CHANGED
@@ -4644,7 +4644,7 @@ function preprocess(fn, schema) {
4644
4644
  });
4645
4645
  }
4646
4646
  //#endregion
4647
- //#region ../types/dist/sleep-C2M2zF7x.mjs
4647
+ //#region ../types/dist/sleep-MHm--th-.mjs
4648
4648
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4649
4649
  EventCategory["SystemBoot"] = "system.boot";
4650
4650
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -5906,6 +5906,7 @@ var CamStreamKindSchema = _enum([
5906
5906
  "pull-rtsp",
5907
5907
  "pull-rtmp",
5908
5908
  "pull-http",
5909
+ "pull-flv",
5909
5910
  "pull-rfc4571",
5910
5911
  "push-annexb",
5911
5912
  "derived"
@@ -7065,7 +7066,21 @@ var StorageLocationDeclarationSchema = object({
7065
7066
  * slots (e.g. `recordingsLow` → `recordings`) so operators only need to
7066
7067
  * configure the primary location.
7067
7068
  */
7068
- defaultsTo: string().optional()
7069
+ defaultsTo: string().optional(),
7070
+ /**
7071
+ * Which node root the seeded `<id>:default` instance is placed under on a
7072
+ * FRESH install:
7073
+ * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
7074
+ * the appData volume. Right for small/durable data (backups, logs, models).
7075
+ * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
7076
+ * env is set, else falls back to the data root. Right for bulky, hot media
7077
+ * (recordings, event media) that should stay off the appData disk.
7078
+ *
7079
+ * Only affects the seeded default's `basePath`; operators can repoint any
7080
+ * location afterwards, and a `defaultsTo` slot inherits its parent's root
7081
+ * regardless of this field. Absent (the common case) is treated as `'data'`.
7082
+ */
7083
+ defaultRoot: _enum(["data", "media"]).optional()
7069
7084
  });
7070
7085
  var DecoderStatsSchema = object({
7071
7086
  inputFps: number(),
@@ -8421,6 +8436,10 @@ var RtspRestreamEntrySchema = object({
8421
8436
  var BrokerRtspClientSchema = object({
8422
8437
  sessionId: string(),
8423
8438
  remoteAddr: string(),
8439
+ /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
8440
+ * null/absent when the client sent none. Lets the UI label a consumer by
8441
+ * purpose. Optional so a client built against an older schema stays valid. */
8442
+ userAgent: string().nullish(),
8424
8443
  playing: boolean(),
8425
8444
  muted: boolean(),
8426
8445
  connectedAt: number(),
@@ -16024,7 +16043,7 @@ var AddBrokerInputSchema = object({
16024
16043
  });
16025
16044
  var AddBrokerResultSchema = object({ id: string() });
16026
16045
  var IdInputSchema = object({ id: string() });
16027
- var TestResultSchema = discriminatedUnion("ok", [object({
16046
+ var TestResultSchema$1 = discriminatedUnion("ok", [object({
16028
16047
  ok: literal(true),
16029
16048
  latencyMs: number()
16030
16049
  }), object({
@@ -16047,7 +16066,7 @@ var StatusSchema = object({
16047
16066
  brokerCount: number(),
16048
16067
  embeddedRunning: boolean()
16049
16068
  });
16050
- method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
16069
+ method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema$1, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
16051
16070
  var NetworkEndpointSchema = object({
16052
16071
  url: string(),
16053
16072
  hostname: string(),
@@ -16081,23 +16100,198 @@ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
16081
16100
  sourcePort: number().optional()
16082
16101
  });
16083
16102
  method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
16084
- method(object({
16085
- title: string(),
16103
+ /**
16104
+ * notification-output — canonical, capability-gated notification delivery.
16105
+ *
16106
+ * Apprise-derived model (see
16107
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
16108
+ * callers emit ONE canonical `Notification`; each provider declares a
16109
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
16110
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
16111
+ * message to what the kind supports — callers never special-case a service.
16112
+ *
16113
+ * DESIGN DECISIONS (locked):
16114
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
16115
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
16116
+ * cap. Rationale: the admin UI needs one uniform surface across the
16117
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
16118
+ * alternative would fork the UI per addon and cannot host the
16119
+ * discovery→adopt flow.
16120
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
16121
+ * the generated cap-mount auto-`concatCollection`-fans them across every
16122
+ * registered provider (notifiers addon + HA addon) so one catalog is
16123
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
16124
+ * `addonId` the generated collection router extracts from the call input.
16125
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
16126
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
16127
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
16128
+ * base64 fallback needed.
16129
+ *
16130
+ * TODO (deferred, closed-set change — separate decision): add
16131
+ * `providerKind: 'notify'` so notification providers surface on the unified
16132
+ * admin "Integrations" page.
16133
+ */
16134
+ /**
16135
+ * Zentik-derived typed-media enum — the superset across every kind. Each
16136
+ * adapter picks what it supports and the degrade engine filters the rest.
16137
+ */
16138
+ var AttachmentMediaTypeSchema = _enum([
16139
+ "image",
16140
+ "video",
16141
+ "gif",
16142
+ "audio",
16143
+ "icon"
16144
+ ]);
16145
+ /**
16146
+ * A single attachment. Exactly one of `url` (remote source, most adapters
16147
+ * prefer this) or `bytes` (inline source; required for Pushover-style
16148
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
16149
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
16150
+ */
16151
+ var AttachmentSchema = object({
16152
+ mediaType: AttachmentMediaTypeSchema,
16153
+ url: string().optional(),
16154
+ bytes: _instanceof(Uint8Array).optional(),
16155
+ mime: string().optional(),
16156
+ name: string().optional()
16157
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
16158
+ var NotificationFormatSchema = _enum([
16159
+ "text",
16160
+ "markdown",
16161
+ "html"
16162
+ ]);
16163
+ /** A single tap-through action button. */
16164
+ var NotificationActionSchema = object({
16165
+ id: string(),
16166
+ label: string(),
16167
+ url: string().optional()
16168
+ });
16169
+ /**
16170
+ * The canonical notification. `body` is the only hard field (Apprise model).
16171
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
16172
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
16173
+ * the adapter maps this ordinal onto its native level. `level?` is an
16174
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
16175
+ * `priority` for that one target.
16176
+ */
16177
+ var NotificationSchema = object({
16086
16178
  body: string(),
16087
- imageUrl: string().optional(),
16179
+ title: string().optional(),
16180
+ format: NotificationFormatSchema.default("text"),
16181
+ priority: number().int().min(1).max(5).default(3),
16182
+ level: string().optional(),
16183
+ attachments: array(AttachmentSchema).optional(),
16184
+ clickUrl: string().optional(),
16185
+ actions: array(NotificationActionSchema).optional(),
16186
+ sound: string().optional(),
16187
+ ttl: number().optional(),
16188
+ tag: string().optional(),
16088
16189
  deviceId: number().optional(),
16089
16190
  eventId: string().optional(),
16090
- priority: _enum([
16091
- "low",
16092
- "normal",
16093
- "high",
16094
- "critical"
16095
- ]).default("normal"),
16096
16191
  metadata: record(string(), unknown()).optional()
16097
- }), _void(), { kind: "mutation" }), method(_void(), object({
16192
+ });
16193
+ /** One declared native severity/priority level for a kind. */
16194
+ var TargetKindLevelSchema = object({
16195
+ id: string(),
16196
+ label: string(),
16197
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
16198
+ ordinal: number().int().min(1).max(5).nullable(),
16199
+ flags: object({
16200
+ critical: boolean().optional(),
16201
+ silent: boolean().optional(),
16202
+ noPush: boolean().optional()
16203
+ }).optional(),
16204
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
16205
+ requires: array(string()).optional(),
16206
+ description: string().optional()
16207
+ });
16208
+ /** The full capability block consulted before dispatch. */
16209
+ var TargetKindCapsSchema = object({
16210
+ attachments: object({
16211
+ mediaTypes: array(AttachmentMediaTypeSchema),
16212
+ mode: _enum([
16213
+ "url",
16214
+ "bytes",
16215
+ "both"
16216
+ ]),
16217
+ max: number().int().nonnegative(),
16218
+ maxBytes: number().int().positive().optional()
16219
+ }),
16220
+ /** Max action buttons (0 = none). */
16221
+ actions: number().int().nonnegative(),
16222
+ levels: array(TargetKindLevelSchema),
16223
+ format: array(NotificationFormatSchema),
16224
+ clickUrl: boolean(),
16225
+ sound: boolean(),
16226
+ ttl: boolean(),
16227
+ bodyMaxLen: number().int().positive()
16228
+ });
16229
+ /**
16230
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
16231
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
16232
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
16233
+ * the union is large and not meant for runtime validation here; the exported
16234
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
16235
+ */
16236
+ var ConfigSchemaPassthrough = unknown();
16237
+ var TargetKindSchema = object({
16238
+ kind: string(),
16239
+ label: string(),
16240
+ icon: string(),
16241
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
16242
+ addonId: string(),
16243
+ configSchema: ConfigSchemaPassthrough,
16244
+ supportsDiscovery: boolean(),
16245
+ caps: TargetKindCapsSchema
16246
+ });
16247
+ /**
16248
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
16249
+ * (return a presence marker only) when serving `listTargets` — never
16250
+ * round-trip a stored secret to the UI.
16251
+ */
16252
+ var TargetSchema = object({
16253
+ id: string(),
16254
+ name: string(),
16255
+ kind: string(),
16256
+ addonId: string(),
16257
+ enabled: boolean(),
16258
+ config: record(string(), unknown())
16259
+ });
16260
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
16261
+ var DiscoveredTargetSchema = object({
16262
+ kind: string(),
16263
+ suggestedName: string(),
16264
+ config: record(string(), unknown())
16265
+ });
16266
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
16267
+ var RenderedAsSchema = object({
16268
+ level: string(),
16269
+ format: NotificationFormatSchema,
16270
+ attachmentsSent: number().int().nonnegative(),
16271
+ actionsSent: number().int().nonnegative(),
16272
+ truncated: boolean(),
16273
+ dropped: array(string())
16274
+ });
16275
+ var SendResultSchema = object({
16098
16276
  success: boolean(),
16099
- error: string().optional()
16100
- }), { kind: "mutation" });
16277
+ error: string().optional(),
16278
+ renderedAs: RenderedAsSchema.optional()
16279
+ });
16280
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
16281
+ var TestResultSchema = SendResultSchema;
16282
+ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
16283
+ kind: string(),
16284
+ config: record(string(), unknown()).optional()
16285
+ }), array(DiscoveredTargetSchema)), method(object({
16286
+ targetId: string(),
16287
+ notification: NotificationSchema
16288
+ }), SendResultSchema, { kind: "mutation" }), method(object({
16289
+ targetId: string(),
16290
+ sample: NotificationSchema.optional()
16291
+ }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
16292
+ targetId: string(),
16293
+ enabled: boolean()
16294
+ }), _void(), { kind: "mutation" });
16101
16295
  /**
16102
16296
  * Zod schemas for persisted record types.
16103
16297
  *
@@ -22092,13 +22286,49 @@ Object.freeze({
22092
22286
  addonId: null,
22093
22287
  access: "create"
22094
22288
  },
22289
+ "notificationOutput.deleteTarget": {
22290
+ capName: "notification-output",
22291
+ capScope: "system",
22292
+ addonId: null,
22293
+ access: "delete"
22294
+ },
22295
+ "notificationOutput.discoverTargets": {
22296
+ capName: "notification-output",
22297
+ capScope: "system",
22298
+ addonId: null,
22299
+ access: "view"
22300
+ },
22301
+ "notificationOutput.listTargetKinds": {
22302
+ capName: "notification-output",
22303
+ capScope: "system",
22304
+ addonId: null,
22305
+ access: "view"
22306
+ },
22307
+ "notificationOutput.listTargets": {
22308
+ capName: "notification-output",
22309
+ capScope: "system",
22310
+ addonId: null,
22311
+ access: "view"
22312
+ },
22095
22313
  "notificationOutput.send": {
22096
22314
  capName: "notification-output",
22097
22315
  capScope: "system",
22098
22316
  addonId: null,
22099
22317
  access: "create"
22100
22318
  },
22101
- "notificationOutput.sendTest": {
22319
+ "notificationOutput.setTargetEnabled": {
22320
+ capName: "notification-output",
22321
+ capScope: "system",
22322
+ addonId: null,
22323
+ access: "create"
22324
+ },
22325
+ "notificationOutput.testTarget": {
22326
+ capName: "notification-output",
22327
+ capScope: "system",
22328
+ addonId: null,
22329
+ access: "create"
22330
+ },
22331
+ "notificationOutput.upsertTarget": {
22102
22332
  capName: "notification-output",
22103
22333
  capScope: "system",
22104
22334
  addonId: null,
@@ -30071,22 +30301,23 @@ function slugifyHost(value) {
30071
30301
  }
30072
30302
  /**
30073
30303
  * Extra per-scan inputs for Ecowitt network discovery. Mirrors Gree's scan form. On a flat network,
30074
- * leave blank to sweep the addon node's local subnet. Gateways on ANOTHER subnet usually can't be
30075
- * reached by a directed broadcast (most routers don't forward it) enter the gateway's IP instead:
30076
- * the `CMD_BROADCAST` probe answers a unicast just as well and returns the gateway's identity.
30304
+ * leave blank to sweep the addon node's local subnet. To reach gateways on OTHER subnets, list one or
30305
+ * more directed broadcast addresses, gateway IPs, or CIDRs (comma / newline separated) each is
30306
+ * swept independently and the responders are merged. A directed broadcast (e.g. 192.168.20.255) or a
30307
+ * unicast gateway IP both work because the `CMD_BROADCAST` probe answers a unicast just as well.
30077
30308
  */
30078
30309
  function buildDiscoveryParamsFormSchema() {
30079
30310
  return { sections: [{
30080
30311
  id: "scan",
30081
30312
  title: "Scan options",
30082
- description: "Leave empty to scan the local subnet. To find a gateway on a different subnet, enter that subnet’s broadcast address (e.g. 192.168.20.255) or the gateway’s IP directly (e.g. 192.168.20.181).",
30313
+ description: "Leave empty to scan the local subnet. To scan other subnets, list their broadcast addresses (e.g. 192.168.20.255), gateway IPs (e.g. 192.168.20.181), or CIDRs (e.g. 192.168.20.0/24) — comma or newline separated. Each subnet is swept independently and unreachable ones are skipped.",
30083
30314
  columns: 1,
30084
30315
  fields: [{
30085
30316
  type: "text",
30086
30317
  key: "broadcastAddress",
30087
- label: "Broadcast address or gateway IP",
30318
+ label: "Broadcast addresses / gateway IPs / CIDRs",
30088
30319
  required: false,
30089
- placeholder: "192.168.20.255"
30320
+ placeholder: "192.168.20.255, 192.168.30.0/24"
30090
30321
  }, {
30091
30322
  type: "number",
30092
30323
  key: "timeoutMs",
@@ -30239,6 +30470,111 @@ var EcowittIntegrationManager = class {
30239
30470
  }
30240
30471
  };
30241
30472
  //#endregion
30473
+ //#region src/ecowitt-network-scan.ts
30474
+ /** Strict dotted-quad IPv4 octet (0–255), no leading-zero ambiguity beyond what routers accept. */
30475
+ function parseOctet(token) {
30476
+ if (!/^\d{1,3}$/.test(token)) return null;
30477
+ const n = Number(token);
30478
+ return n >= 0 && n <= 255 ? n : null;
30479
+ }
30480
+ /** Parse a dotted-quad into its four octets, or null when malformed. */
30481
+ function parseIpv4(value) {
30482
+ const parts = value.split(".");
30483
+ if (parts.length !== 4) return null;
30484
+ const octets = parts.map(parseOctet);
30485
+ if (octets.some((o) => o === null)) return null;
30486
+ return [
30487
+ octets[0],
30488
+ octets[1],
30489
+ octets[2],
30490
+ octets[3]
30491
+ ];
30492
+ }
30493
+ /**
30494
+ * Convert an IPv4 CIDR (e.g. `192.168.20.0/24`) into its directed broadcast address
30495
+ * (`192.168.20.255`) — the address `Ecowitt.discover` sweeps to reach a foreign subnet. Returns
30496
+ * `null` for anything that is not a well-formed IPv4 CIDR (a bare IP, garbage, or an out-of-range
30497
+ * prefix), so the caller can treat the input as a literal broadcast/gateway address instead.
30498
+ */
30499
+ function cidrToBroadcast(value) {
30500
+ const slash = value.indexOf("/");
30501
+ if (slash < 0) return null;
30502
+ const ip = parseIpv4(value.slice(0, slash));
30503
+ const prefixToken = value.slice(slash + 1);
30504
+ if (ip === null || !/^\d{1,2}$/.test(prefixToken)) return null;
30505
+ const prefix = Number(prefixToken);
30506
+ if (prefix < 0 || prefix > 32) return null;
30507
+ const broadcast = ((ip[0] << 24 | ip[1] << 16 | ip[2] << 8 | ip[3]) >>> 0 | (prefix === 0 ? 4294967295 : 4294967295 >>> prefix >>> 0)) >>> 0;
30508
+ return [
30509
+ broadcast >>> 24 & 255,
30510
+ broadcast >>> 16 & 255,
30511
+ broadcast >>> 8 & 255,
30512
+ broadcast & 255
30513
+ ].join(".");
30514
+ }
30515
+ /**
30516
+ * Parse the operator scan input into a de-duplicated, ordered list of directed broadcast targets.
30517
+ *
30518
+ * Accepts a free-form string of comma / newline / whitespace separated tokens where each token is
30519
+ * either a broadcast address (`192.168.20.255`), a bare gateway IP (`192.168.20.181` — the
30520
+ * `CMD_BROADCAST` probe answers a unicast too), or a CIDR (`192.168.20.0/24`, expanded to its
30521
+ * directed broadcast). Blank input yields an empty list, meaning "sweep the local subnet only".
30522
+ * Invalid tokens are dropped rather than throwing — discovery input is best-effort.
30523
+ */
30524
+ function parseBroadcastTargets(raw) {
30525
+ const seen = /* @__PURE__ */ new Set();
30526
+ const out = [];
30527
+ for (const rawToken of raw.split(/[\s,]+/)) {
30528
+ const token = rawToken.trim();
30529
+ if (token.length === 0) continue;
30530
+ const target = token.includes("/") ? cidrToBroadcast(token) : parseIpv4(token) ? token : null;
30531
+ if (target === null || seen.has(target)) continue;
30532
+ seen.add(target);
30533
+ out.push(target);
30534
+ }
30535
+ return out;
30536
+ }
30537
+ /** Deterministic dedup key for a discovered gateway — MAC is the physical identity. */
30538
+ function gatewayKey(gateway) {
30539
+ return gateway.mac.trim().toLowerCase() || gateway.ip;
30540
+ }
30541
+ /**
30542
+ * Cross-subnet Ecowitt discovery: run one {@link EcowittDiscoverFn} sweep per target subnet (or a
30543
+ * single local sweep when there are no targets), then merge the responders de-duplicated by MAC.
30544
+ *
30545
+ * Every sweep runs independently and its failure (an unreachable subnet, a router that drops the
30546
+ * directed broadcast, a socket error) is isolated via `onError` — a single bad subnet never fails the
30547
+ * whole scan. Sweeps run concurrently; results are flattened in target order so the merged output is
30548
+ * stable regardless of which subnet answers first.
30549
+ */
30550
+ async function scanEcowittGateways(input) {
30551
+ const { discover, targets, timeoutMs, onError } = input;
30552
+ const timeoutOpt = timeoutMs !== void 0 ? { timeoutMs } : {};
30553
+ if (targets.length === 0) try {
30554
+ return [...await discover({ ...timeoutOpt })];
30555
+ } catch (error) {
30556
+ onError?.("local", error);
30557
+ return [];
30558
+ }
30559
+ const settled = await Promise.all(targets.map(async (broadcastAddr) => {
30560
+ try {
30561
+ return await discover({
30562
+ broadcastAddr,
30563
+ ...timeoutOpt
30564
+ });
30565
+ } catch (error) {
30566
+ onError?.(broadcastAddr, error);
30567
+ return [];
30568
+ }
30569
+ }));
30570
+ const merged = /* @__PURE__ */ new Map();
30571
+ for (const batch of settled) for (const gateway of batch) {
30572
+ const key = gatewayKey(gateway);
30573
+ if (!merged.has(key)) merged.set(key, gateway);
30574
+ }
30575
+ return [...merged.values()];
30576
+ }
30577
+ //#endregion
30242
30578
  //#region src/ecowitt-gateway.ts
30243
30579
  /**
30244
30580
  * Per-gateway registry that device classes use to reach the live {@link Ecowitt}
@@ -31258,15 +31594,23 @@ var EcowittProviderAddon = class extends BaseDeviceProvider {
31258
31594
  return buildDiscoveryParamsFormSchema();
31259
31595
  }
31260
31596
  async discoverDevices(input) {
31261
- const broadcastAddr = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"].trim() : "";
31597
+ const rawTargets = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"] : "";
31262
31598
  const timeoutMs = typeof input?.params?.["timeoutMs"] === "number" ? input.params["timeoutMs"] : void 0;
31263
- const gateways = await Ecowitt.discover({
31264
- ...broadcastAddr.length > 0 ? { broadcastAddr } : {},
31265
- ...timeoutMs !== void 0 ? { timeoutMs } : {}
31599
+ const targets = parseBroadcastTargets(rawTargets);
31600
+ const gateways = await scanEcowittGateways({
31601
+ discover: (opts) => Ecowitt.discover(opts),
31602
+ targets,
31603
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
31604
+ onError: (target, error) => {
31605
+ this.ctx.logger.warn("Ecowitt subnet sweep failed — skipping", { meta: {
31606
+ target,
31607
+ error: errMsg(error)
31608
+ } });
31609
+ }
31266
31610
  });
31267
31611
  this.ctx.logger.info("Ecowitt discovery complete", { meta: {
31268
31612
  count: gateways.length,
31269
- broadcastAddr: broadcastAddr.length > 0 ? broadcastAddr : "local"
31613
+ subnets: targets.length > 0 ? targets : "local"
31270
31614
  } });
31271
31615
  return gateways.map((g) => {
31272
31616
  const displayName = g.model ?? g.name;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-ecowitt",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Ecowitt weather-station device-provider addon for CamStack — wraps the @apocaliss92/nodewitt local-poll / push client",
5
5
  "keywords": [
6
6
  "camstack",