@camstack/addon-provider-ecowitt 0.1.9 → 0.1.10

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 +521 -207
  2. package/dist/addon.mjs +521 -207
  3. package/package.json +2 -2
package/dist/addon.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createServer } from "http";
3
3
  import { EventEmitter } from "events";
4
+ import { createSocket } from "dgram";
4
5
  //#region \0rolldown/runtime.js
5
6
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
6
7
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
@@ -4648,7 +4649,7 @@ function preprocess(fn, schema) {
4648
4649
  });
4649
4650
  }
4650
4651
  //#endregion
4651
- //#region ../types/dist/sleep-B3AOslwX.mjs
4652
+ //#region ../types/dist/sleep-C2M2zF7x.mjs
4652
4653
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4653
4654
  EventCategory["SystemBoot"] = "system.boot";
4654
4655
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -6216,6 +6217,12 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
6216
6217
  DeviceType["Switch"] = "switch";
6217
6218
  DeviceType["Sensor"] = "sensor";
6218
6219
  DeviceType["Thermostat"] = "thermostat";
6220
+ /** Air-conditioner / heat-pump climate device (HVAC) — shares the
6221
+ * `climate-control` cap surface with `Thermostat` but renders a
6222
+ * dedicated AC-appropriate control UI (mode chips, fan speed,
6223
+ * independent vertical/horizontal swing). Sources: native Gree, and
6224
+ * reusable by other AC integrations. */
6225
+ DeviceType["Climate"] = "climate";
6219
6226
  DeviceType["Button"] = "button";
6220
6227
  /** Generic stateless event emitter — carries a device's EXACT declared
6221
6228
  * event vocabulary verbatim (no normalization). Installed with the
@@ -9011,7 +9018,7 @@ var climateControlCapability = {
9011
9018
  scope: "device",
9012
9019
  deviceNative: true,
9013
9020
  mode: "singleton",
9014
- deviceTypes: [DeviceType.Thermostat],
9021
+ deviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
9015
9022
  methods: {
9016
9023
  setMode: method(object({
9017
9024
  deviceId: number().int().nonnegative(),
@@ -13610,10 +13617,30 @@ var deviceProviderCapability = {
13610
13617
  type: string()
13611
13618
  }))),
13612
13619
  supportsDiscovery: method(object({}), boolean()),
13613
- discoverDevices: method(object({}), array(DiscoveryCandidateSchema), {
13620
+ /**
13621
+ * Run a network scan. `params` carries optional provider-specific scan
13622
+ * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
13623
+ * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
13624
+ * (provider uses its local-network default).
13625
+ */
13626
+ discoverDevices: method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
13614
13627
  kind: "mutation",
13615
13628
  auth: "admin"
13616
13629
  }),
13630
+ /**
13631
+ * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
13632
+ * provider accepts (e.g. Gree's broadcast address for a different subnet).
13633
+ * `null` when the provider takes no extra scan params — the generic
13634
+ * aggregated scan never renders this; the per-integration scan does.
13635
+ */
13636
+ getDiscoveryParamsSchema: method(object({}), CreationSchemaOutputSchema),
13637
+ /**
13638
+ * The DeviceType this provider creates via manual add (Camera for
13639
+ * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
13640
+ * provider does not support manual creation. Lets the Add-Device dialog
13641
+ * pick the right type instead of assuming Camera.
13642
+ */
13643
+ getManualCreationType: method(object({}), object({ deviceType: _enum(DeviceType).nullable() })),
13617
13644
  adoptDiscoveredDevice: method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
13618
13645
  kind: "mutation",
13619
13646
  auth: "admin"
@@ -13737,9 +13764,23 @@ var BaseDeviceProvider = class extends BaseAddon {
13737
13764
  async supportsDiscovery() {
13738
13765
  return false;
13739
13766
  }
13740
- async discoverDevices() {
13767
+ async discoverDevices(_input) {
13741
13768
  return [];
13742
13769
  }
13770
+ /** Extra per-scan input form (e.g. a broadcast address for another subnet).
13771
+ * Null = no extra params. Override in providers that support scoped scans. */
13772
+ async getDiscoveryParamsSchema() {
13773
+ return null;
13774
+ }
13775
+ /**
13776
+ * The DeviceType this provider creates via manual add — derived from the
13777
+ * `deviceClasses` map (first registered type). `null` when manual creation is
13778
+ * unsupported. Lets the Add-Device dialog pick the right type per provider.
13779
+ */
13780
+ async getManualCreationType() {
13781
+ if (!await this.supportsManualCreation()) return { deviceType: null };
13782
+ return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
13783
+ }
13743
13784
  async adoptDiscoveredDevice(_input) {
13744
13785
  throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
13745
13786
  }
@@ -15584,7 +15625,10 @@ method(object({
15584
15625
  }), FieldProbeResultSchema, {
15585
15626
  kind: "mutation",
15586
15627
  auth: "admin"
15587
- }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15628
+ }), method(object({
15629
+ addonId: string(),
15630
+ integrationId: string()
15631
+ }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15588
15632
  addonId: string(),
15589
15633
  integrationId: string()
15590
15634
  }), AdoptionStatusSchema, {
@@ -15599,7 +15643,24 @@ method(object({
15599
15643
  }), method(ResyncInputSchema, ResyncResultSchema, {
15600
15644
  kind: "mutation",
15601
15645
  auth: "admin"
15646
+ }), method(object({}), object({ providers: array(object({
15647
+ addonId: string(),
15648
+ label: string()
15649
+ })).readonly() }), { auth: "admin" }), method(object({}), object({ groups: array(object({
15650
+ addonId: string(),
15651
+ label: string(),
15652
+ candidates: array(DiscoveryCandidateSchema).readonly(),
15653
+ error: string().nullable()
15654
+ })).readonly() }), {
15655
+ kind: "mutation",
15656
+ auth: "admin"
15602
15657
  }), method(object({
15658
+ addonId: string(),
15659
+ params: record(string(), unknown()).optional()
15660
+ }), object({ candidates: array(DiscoveryCandidateSchema).readonly() }), {
15661
+ kind: "mutation",
15662
+ auth: "admin"
15663
+ }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
15603
15664
  deviceId: number(),
15604
15665
  key: string(),
15605
15666
  value: unknown()
@@ -20722,6 +20783,12 @@ Object.freeze({
20722
20783
  addonId: null,
20723
20784
  access: "create"
20724
20785
  },
20786
+ "deviceManager.adoptionListCandidateFilters": {
20787
+ capName: "device-manager",
20788
+ capScope: "system",
20789
+ addonId: null,
20790
+ access: "view"
20791
+ },
20725
20792
  "deviceManager.adoptionListCandidates": {
20726
20793
  capName: "device-manager",
20727
20794
  capScope: "system",
@@ -20770,12 +20837,30 @@ Object.freeze({
20770
20837
  addonId: null,
20771
20838
  access: "create"
20772
20839
  },
20840
+ "deviceManager.discoverAllProviders": {
20841
+ capName: "device-manager",
20842
+ capScope: "system",
20843
+ addonId: null,
20844
+ access: "create"
20845
+ },
20773
20846
  "deviceManager.discoverDevices": {
20774
20847
  capName: "device-manager",
20775
20848
  capScope: "system",
20776
20849
  addonId: null,
20777
20850
  access: "create"
20778
20851
  },
20852
+ "deviceManager.discoverProvider": {
20853
+ capName: "device-manager",
20854
+ capScope: "system",
20855
+ addonId: null,
20856
+ access: "create"
20857
+ },
20858
+ "deviceManager.discoveryProviders": {
20859
+ capName: "device-manager",
20860
+ capScope: "system",
20861
+ addonId: null,
20862
+ access: "view"
20863
+ },
20779
20864
  "deviceManager.enable": {
20780
20865
  capName: "device-manager",
20781
20866
  capScope: "system",
@@ -20926,6 +21011,18 @@ Object.freeze({
20926
21011
  addonId: null,
20927
21012
  access: "create"
20928
21013
  },
21014
+ "deviceManager.providerCreationType": {
21015
+ capName: "device-manager",
21016
+ capScope: "system",
21017
+ addonId: null,
21018
+ access: "view"
21019
+ },
21020
+ "deviceManager.providerDiscoveryParamsSchema": {
21021
+ capName: "device-manager",
21022
+ capScope: "system",
21023
+ addonId: null,
21024
+ access: "view"
21025
+ },
20929
21026
  "deviceManager.registerDevice": {
20930
21027
  capName: "device-manager",
20931
21028
  capScope: "system",
@@ -21142,6 +21239,18 @@ Object.freeze({
21142
21239
  addonId: null,
21143
21240
  access: "view"
21144
21241
  },
21242
+ "deviceProvider.getDiscoveryParamsSchema": {
21243
+ capName: "device-provider",
21244
+ capScope: "system",
21245
+ addonId: null,
21246
+ access: "view"
21247
+ },
21248
+ "deviceProvider.getManualCreationType": {
21249
+ capName: "device-provider",
21250
+ capScope: "system",
21251
+ addonId: null,
21252
+ access: "view"
21253
+ },
21145
21254
  "deviceProvider.getStatus": {
21146
21255
  capName: "device-provider",
21147
21256
  capScope: "system",
@@ -23980,183 +24089,6 @@ object({
23980
24089
  schemaVersion: literal(1)
23981
24090
  });
23982
24091
  //#endregion
23983
- //#region src/config.ts
23984
- /**
23985
- * Operator-supplied Ecowitt gateway settings for ONE broker (= one station).
23986
- * Two transports behind a `transport` discriminator:
23987
- *
23988
- * - `local` (local-poll): the addon polls the gateway over HTTP (`host`/`port`/
23989
- * optional `password`, with poll + mapping intervals).
23990
- * - `listener` (push): the addon runs an HTTP server the gateway uploads to
23991
- * (the gateway's "Customized" upload must point at the hub's LAN IP:port).
23992
- *
23993
- * Local-poll is the recommended default — it has no inbound-IP constraint.
23994
- */
23995
- var baseFields = {
23996
- /** Human-overridable station identity used to scope the adopted gateway. */
23997
- stationId: string().default("").describe("Optional stable station id (auto-derived when blank)") };
23998
- var ecowittConfigSchema = discriminatedUnion("transport", [object({
23999
- transport: literal("local"),
24000
- host: string().min(1).describe("Ecowitt gateway host name or IP address"),
24001
- port: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(80)).describe("Gateway HTTP port"),
24002
- password: string().default("").describe("Gateway password (optional — newer firmware)"),
24003
- pollIntervalMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1e3).max(36e5).default(6e4)).describe("Live-data poll interval (ms)"),
24004
- mappingIntervalMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1e4).max(864e5).default(6e5)).describe("Sensor-map refresh interval (ms)"),
24005
- ...baseFields
24006
- }), object({
24007
- transport: literal("listener"),
24008
- listenPort: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(4199)).describe("Local TCP port the gateway uploads to"),
24009
- listenHost: string().default("").describe("Optional bind host (blank = all interfaces)"),
24010
- ...baseFields
24011
- })]);
24012
- /**
24013
- * Narrow an `unknown` value to a shallow-cloned plain object record, or an empty
24014
- * record when it is not a plain object. Mirrors the Homematic `toRecord` helper —
24015
- * a cast-free boundary narrowing.
24016
- */
24017
- function toRecord(value) {
24018
- if (typeof value === "object" && value !== null && !Array.isArray(value)) return Object.fromEntries(Object.entries(value));
24019
- return {};
24020
- }
24021
- /** Coerce a loose settings blob through the connection schema, applying defaults.
24022
- * A blob with no `transport` defaults to `local` for back-compat with simple forms. */
24023
- function settingsToEcowittConfig(settings) {
24024
- const raw = toRecord(settings);
24025
- if (raw["transport"] !== "local" && raw["transport"] !== "listener") raw["transport"] = "local";
24026
- return ecowittConfigSchema.parse(raw);
24027
- }
24028
- /**
24029
- * Persisted config for an Ecowitt gateway {@link import('@camstack/types').DeviceType.Hub}
24030
- * device. The operator-supplied CONNECTION lives directly on the device (Reolink
24031
- * pattern) — there is no broker registry. The gateway owns its own live nodewitt
24032
- * client keyed on its own `stableId`; group Container children reference it via
24033
- * their `gatewayId` config field.
24034
- */
24035
- var ecowittGatewaySchema = object({
24036
- /** The transport + endpoint the gateway's live client dials / binds. */
24037
- connection: ecowittConfigSchema,
24038
- /** Provenance marker used to scope offline / cascade helpers. */
24039
- system: literal("ecowitt-gateway").optional()
24040
- });
24041
- /**
24042
- * Hand-written connection form for the gateway device-creation UI (standalone
24043
- * mode — Reolink pattern). The connection lives on the gateway DEVICE config,
24044
- * not in a broker registry. A `name` field is included so the operator names
24045
- * the gateway Hub at creation time (mirrors the Reolink creation form).
24046
- */
24047
- function buildConnectionFormSchema() {
24048
- return { sections: [
24049
- {
24050
- id: "identity",
24051
- title: "Gateway",
24052
- columns: 1,
24053
- fields: [{
24054
- type: "text",
24055
- key: "name",
24056
- label: "Name",
24057
- required: true,
24058
- placeholder: "Weather station"
24059
- }]
24060
- },
24061
- {
24062
- id: "transport",
24063
- title: "Transport",
24064
- description: "Choose how CamStack reads the gateway. Local poll connects out to the gateway (recommended). Push listener runs a server the gateway uploads to — configure the gateway \"Customized\" upload to point at this hub.",
24065
- columns: 1,
24066
- fields: [{
24067
- type: "select",
24068
- key: "transport",
24069
- label: "Transport",
24070
- default: "local",
24071
- options: [{
24072
- value: "local",
24073
- label: "Local poll (recommended)"
24074
- }, {
24075
- value: "listener",
24076
- label: "Push listener"
24077
- }]
24078
- }]
24079
- },
24080
- {
24081
- id: "local",
24082
- title: "Local poll settings",
24083
- columns: 2,
24084
- fields: [
24085
- {
24086
- type: "text",
24087
- key: "host",
24088
- label: "Gateway host / IP",
24089
- placeholder: "192.168.1.50",
24090
- showWhen: {
24091
- field: "transport",
24092
- equals: "local"
24093
- }
24094
- },
24095
- {
24096
- type: "number",
24097
- key: "port",
24098
- label: "HTTP port",
24099
- min: 1,
24100
- max: 65535,
24101
- default: 80,
24102
- showWhen: {
24103
- field: "transport",
24104
- equals: "local"
24105
- }
24106
- },
24107
- {
24108
- type: "password",
24109
- key: "password",
24110
- label: "Password (optional)",
24111
- showToggle: true,
24112
- showWhen: {
24113
- field: "transport",
24114
- equals: "local"
24115
- }
24116
- },
24117
- {
24118
- type: "number",
24119
- key: "pollIntervalMs",
24120
- label: "Poll interval (ms)",
24121
- min: 1e3,
24122
- max: 36e5,
24123
- default: 6e4,
24124
- showWhen: {
24125
- field: "transport",
24126
- equals: "local"
24127
- }
24128
- }
24129
- ]
24130
- },
24131
- {
24132
- id: "listener",
24133
- title: "Push listener settings",
24134
- columns: 2,
24135
- fields: [{
24136
- type: "number",
24137
- key: "listenPort",
24138
- label: "Listen port",
24139
- min: 1,
24140
- max: 65535,
24141
- default: 4199,
24142
- showWhen: {
24143
- field: "transport",
24144
- equals: "listener"
24145
- }
24146
- }, {
24147
- type: "text",
24148
- key: "listenHost",
24149
- label: "Bind host (optional)",
24150
- placeholder: "all interfaces",
24151
- showWhen: {
24152
- field: "transport",
24153
- equals: "listener"
24154
- }
24155
- }]
24156
- }
24157
- ] };
24158
- }
24159
- //#endregion
24160
24092
  //#region node_modules/undici/lib/core/symbols.js
24161
24093
  var require_symbols$4 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
24162
24094
  module.exports = {
@@ -45074,6 +45006,101 @@ var TypedEmitter = class {
45074
45006
  return this;
45075
45007
  }
45076
45008
  };
45009
+ var CMD_BROADCAST = 18;
45010
+ var DISCOVERY_PORT = 46e3;
45011
+ function buildScanPacket() {
45012
+ const size = 3;
45013
+ const checksum = CMD_BROADCAST + size & 255;
45014
+ return Buffer.from([
45015
+ 255,
45016
+ 255,
45017
+ CMD_BROADCAST,
45018
+ size,
45019
+ checksum
45020
+ ]);
45021
+ }
45022
+ function verifyChecksum(buf) {
45023
+ if (buf.length < 4) return false;
45024
+ let sum = 0;
45025
+ for (let i = 2; i < buf.length - 1; i += 1) sum = sum + (buf[i] ?? 0) & 255;
45026
+ return sum === buf[buf.length - 1];
45027
+ }
45028
+ function parseBroadcastResponse(buf) {
45029
+ if (buf.length < 18) return null;
45030
+ if (buf[0] !== 255 || buf[1] !== 255 || buf[2] !== CMD_BROADCAST) return null;
45031
+ if (!verifyChecksum(buf)) return null;
45032
+ const mac = macFromBytes(buf.subarray(5, 11));
45033
+ const tcpPort = buf.readUInt16BE(15);
45034
+ const ssidLen = buf[17] ?? 0;
45035
+ const ssidEnd = Math.min(18 + ssidLen, buf.length - 1);
45036
+ const { module, model, firmware } = splitSsid(buf.subarray(18, ssidEnd).toString("ascii").trim());
45037
+ return {
45038
+ mac,
45039
+ module,
45040
+ model,
45041
+ firmware,
45042
+ tcpPort
45043
+ };
45044
+ }
45045
+ function macFromBytes(bytes) {
45046
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(":");
45047
+ }
45048
+ function splitSsid(ssid) {
45049
+ const spaceIdx = ssid.indexOf(" ");
45050
+ const module = spaceIdx >= 0 ? ssid.slice(0, spaceIdx) : ssid;
45051
+ const firmwareRaw = spaceIdx >= 0 ? ssid.slice(spaceIdx + 1).trim() : "";
45052
+ const wifiIdx = module.indexOf("-WIFI");
45053
+ return {
45054
+ module,
45055
+ model: wifiIdx > 0 ? module.slice(0, wifiIdx) : void 0,
45056
+ firmware: firmwareRaw.length > 0 ? firmwareRaw : void 0
45057
+ };
45058
+ }
45059
+ function discoverGateways(socket, opts) {
45060
+ const port = opts?.port ?? DISCOVERY_PORT;
45061
+ const broadcastAddr = opts?.broadcastAddr ?? "255.255.255.255";
45062
+ const timeoutMs = opts?.timeoutMs ?? 3e3;
45063
+ const byMac = /* @__PURE__ */ new Map();
45064
+ socket.onMessage((m) => {
45065
+ const frame = parseBroadcastResponse(m.data);
45066
+ if (frame === null) return;
45067
+ byMac.set(frame.mac, {
45068
+ ip: m.address,
45069
+ mac: frame.mac,
45070
+ name: frame.module.length > 0 ? frame.module : frame.mac,
45071
+ ...frame.model !== void 0 ? { model: frame.model } : {},
45072
+ ...frame.firmware !== void 0 ? { firmware: frame.firmware } : {}
45073
+ });
45074
+ });
45075
+ socket.setBroadcast(true);
45076
+ const scan = buildScanPacket();
45077
+ return new Promise((resolve) => {
45078
+ socket.send(scan, port, broadcastAddr);
45079
+ setTimeout(() => resolve([...byMac.values()]), timeoutMs);
45080
+ });
45081
+ }
45082
+ function createDgramSocket() {
45083
+ return new Promise((resolve, reject) => {
45084
+ const socket = createSocket({
45085
+ type: "udp4",
45086
+ reuseAddr: true
45087
+ });
45088
+ socket.once("error", reject);
45089
+ socket.bind(() => {
45090
+ socket.removeListener("error", reject);
45091
+ resolve({
45092
+ send: (data, port, address) => new Promise((res, rej) => socket.send(data, port, address, (err) => err ? rej(err) : res())),
45093
+ onMessage: (handler) => socket.on("message", (data, rinfo) => handler({
45094
+ data,
45095
+ address: rinfo.address,
45096
+ port: rinfo.port
45097
+ })),
45098
+ setBroadcast: (enabled) => socket.setBroadcast(enabled),
45099
+ close: () => new Promise((res) => socket.close(() => res()))
45100
+ });
45101
+ });
45102
+ });
45103
+ }
45077
45104
  function toError(error) {
45078
45105
  return error instanceof Error ? error : new Error(String(error));
45079
45106
  }
@@ -45098,6 +45125,21 @@ var Ecowitt = class _Ecowitt {
45098
45125
  return _Ecowitt.#buildListener(defaultListenerBuilder(options));
45099
45126
  }
45100
45127
  /**
45128
+ * Discover Ecowitt gateways on the network via the `CMD_BROADCAST` UDP probe (port 46000). Opens a
45129
+ * throwaway UDP socket, sweeps the broadcast address (local subnet by default, or a directed
45130
+ * broadcast passed via {@link DiscoverOptions.broadcastAddr} for another subnet), collects
45131
+ * responders deduped by MAC, and closes the socket. No `Ecowitt` instance or connection is
45132
+ * created — the caller maps the results to whatever onboarding flow it uses.
45133
+ */
45134
+ static async discover(options) {
45135
+ const socket = await createDgramSocket();
45136
+ try {
45137
+ return await discoverGateways(socket, options);
45138
+ } finally {
45139
+ await socket.close().catch(() => void 0);
45140
+ }
45141
+ }
45142
+ /**
45101
45143
  * Build a local-transport facade from a transport builder. The wiring closure that hands the
45102
45144
  * builder its `onReadings`/`onError` callbacks lives HERE, inside the class, so it can call the
45103
45145
  * `#`-private ingest seams directly — no public method, no cast. `#`-private so it is unreachable
@@ -45303,6 +45345,219 @@ objectType({
45303
45345
  })
45304
45346
  });
45305
45347
  //#endregion
45348
+ //#region src/config.ts
45349
+ /**
45350
+ * Operator-supplied Ecowitt gateway settings for ONE broker (= one station).
45351
+ * Two transports behind a `transport` discriminator:
45352
+ *
45353
+ * - `local` (local-poll): the addon polls the gateway over HTTP (`host`/`port`/
45354
+ * optional `password`, with poll + mapping intervals).
45355
+ * - `listener` (push): the addon runs an HTTP server the gateway uploads to
45356
+ * (the gateway's "Customized" upload must point at the hub's LAN IP:port).
45357
+ *
45358
+ * Local-poll is the recommended default — it has no inbound-IP constraint.
45359
+ */
45360
+ var baseFields = {
45361
+ /** Human-overridable station identity used to scope the adopted gateway. */
45362
+ stationId: string().default("").describe("Optional stable station id (auto-derived when blank)") };
45363
+ var ecowittConfigSchema = discriminatedUnion("transport", [object({
45364
+ transport: literal("local"),
45365
+ host: string().min(1).describe("Ecowitt gateway host name or IP address"),
45366
+ port: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(80)).describe("Gateway HTTP port"),
45367
+ password: string().default("").describe("Gateway password (optional — newer firmware)"),
45368
+ pollIntervalMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1e3).max(36e5).default(6e4)).describe("Live-data poll interval (ms)"),
45369
+ mappingIntervalMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1e4).max(864e5).default(6e5)).describe("Sensor-map refresh interval (ms)"),
45370
+ ...baseFields
45371
+ }), object({
45372
+ transport: literal("listener"),
45373
+ listenPort: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(4199)).describe("Local TCP port the gateway uploads to"),
45374
+ listenHost: string().default("").describe("Optional bind host (blank = all interfaces)"),
45375
+ ...baseFields
45376
+ })]);
45377
+ /**
45378
+ * Narrow an `unknown` value to a shallow-cloned plain object record, or an empty
45379
+ * record when it is not a plain object. Mirrors the Homematic `toRecord` helper —
45380
+ * a cast-free boundary narrowing.
45381
+ */
45382
+ function toRecord(value) {
45383
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return Object.fromEntries(Object.entries(value));
45384
+ return {};
45385
+ }
45386
+ /** Coerce a loose settings blob through the connection schema, applying defaults.
45387
+ * A blob with no `transport` defaults to `local` for back-compat with simple forms. */
45388
+ function settingsToEcowittConfig(settings) {
45389
+ const raw = toRecord(settings);
45390
+ if (raw["transport"] !== "local" && raw["transport"] !== "listener") raw["transport"] = "local";
45391
+ return ecowittConfigSchema.parse(raw);
45392
+ }
45393
+ /**
45394
+ * Persisted config for an Ecowitt gateway {@link import('@camstack/types').DeviceType.Hub}
45395
+ * device. The operator-supplied CONNECTION lives directly on the device (Reolink
45396
+ * pattern) — there is no broker registry. The gateway owns its own live nodewitt
45397
+ * client keyed on its own `stableId`; group Container children reference it via
45398
+ * their `gatewayId` config field.
45399
+ */
45400
+ var ecowittGatewaySchema = object({
45401
+ /** The transport + endpoint the gateway's live client dials / binds. */
45402
+ connection: ecowittConfigSchema,
45403
+ /** Provenance marker used to scope offline / cascade helpers. */
45404
+ system: literal("ecowitt-gateway").optional()
45405
+ });
45406
+ /**
45407
+ * Hand-written connection form for the gateway device-creation UI (standalone
45408
+ * mode — Reolink pattern). The connection lives on the gateway DEVICE config,
45409
+ * not in a broker registry. A `name` field is included so the operator names
45410
+ * the gateway Hub at creation time (mirrors the Reolink creation form).
45411
+ */
45412
+ function buildConnectionFormSchema() {
45413
+ return { sections: [
45414
+ {
45415
+ id: "identity",
45416
+ title: "Gateway",
45417
+ columns: 1,
45418
+ fields: [{
45419
+ type: "text",
45420
+ key: "name",
45421
+ label: "Name",
45422
+ required: true,
45423
+ placeholder: "Weather station"
45424
+ }]
45425
+ },
45426
+ {
45427
+ id: "transport",
45428
+ title: "Transport",
45429
+ description: "Choose how CamStack reads the gateway. Local poll connects out to the gateway (recommended). Push listener runs a server the gateway uploads to — configure the gateway \"Customized\" upload to point at this hub.",
45430
+ columns: 1,
45431
+ fields: [{
45432
+ type: "select",
45433
+ key: "transport",
45434
+ label: "Transport",
45435
+ default: "local",
45436
+ options: [{
45437
+ value: "local",
45438
+ label: "Local poll (recommended)"
45439
+ }, {
45440
+ value: "listener",
45441
+ label: "Push listener"
45442
+ }]
45443
+ }]
45444
+ },
45445
+ {
45446
+ id: "local",
45447
+ title: "Local poll settings",
45448
+ columns: 2,
45449
+ fields: [
45450
+ {
45451
+ type: "text",
45452
+ key: "host",
45453
+ label: "Gateway host / IP",
45454
+ placeholder: "192.168.1.50",
45455
+ showWhen: {
45456
+ field: "transport",
45457
+ equals: "local"
45458
+ }
45459
+ },
45460
+ {
45461
+ type: "number",
45462
+ key: "port",
45463
+ label: "HTTP port",
45464
+ min: 1,
45465
+ max: 65535,
45466
+ default: 80,
45467
+ showWhen: {
45468
+ field: "transport",
45469
+ equals: "local"
45470
+ }
45471
+ },
45472
+ {
45473
+ type: "password",
45474
+ key: "password",
45475
+ label: "Password (optional)",
45476
+ showToggle: true,
45477
+ showWhen: {
45478
+ field: "transport",
45479
+ equals: "local"
45480
+ }
45481
+ },
45482
+ {
45483
+ type: "number",
45484
+ key: "pollIntervalMs",
45485
+ label: "Poll interval (ms)",
45486
+ min: 1e3,
45487
+ max: 36e5,
45488
+ default: 6e4,
45489
+ showWhen: {
45490
+ field: "transport",
45491
+ equals: "local"
45492
+ }
45493
+ }
45494
+ ]
45495
+ },
45496
+ {
45497
+ id: "listener",
45498
+ title: "Push listener settings",
45499
+ columns: 2,
45500
+ fields: [{
45501
+ type: "number",
45502
+ key: "listenPort",
45503
+ label: "Listen port",
45504
+ min: 1,
45505
+ max: 65535,
45506
+ default: 4199,
45507
+ showWhen: {
45508
+ field: "transport",
45509
+ equals: "listener"
45510
+ }
45511
+ }, {
45512
+ type: "text",
45513
+ key: "listenHost",
45514
+ label: "Bind host (optional)",
45515
+ placeholder: "all interfaces",
45516
+ showWhen: {
45517
+ field: "transport",
45518
+ equals: "listener"
45519
+ }
45520
+ }]
45521
+ }
45522
+ ] };
45523
+ }
45524
+ /**
45525
+ * Slugify a host / station id into a readable, stable row-key fragment. Exported so the discovery
45526
+ * candidate's `stableId` matches exactly what `generateStableId` derives on adopt (else a re-scan of
45527
+ * an already-added gateway would not be detected as onboarded).
45528
+ */
45529
+ function slugifyHost(value) {
45530
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45531
+ }
45532
+ /**
45533
+ * Extra per-scan inputs for Ecowitt network discovery. Mirrors Gree's scan form. On a flat network,
45534
+ * leave blank to sweep the addon node's local subnet. Gateways on ANOTHER subnet usually can't be
45535
+ * reached by a directed broadcast (most routers don't forward it) — enter the gateway's IP instead:
45536
+ * the `CMD_BROADCAST` probe answers a unicast just as well and returns the gateway's identity.
45537
+ */
45538
+ function buildDiscoveryParamsFormSchema() {
45539
+ return { sections: [{
45540
+ id: "scan",
45541
+ title: "Scan options",
45542
+ 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).",
45543
+ columns: 1,
45544
+ fields: [{
45545
+ type: "text",
45546
+ key: "broadcastAddress",
45547
+ label: "Broadcast address or gateway IP",
45548
+ required: false,
45549
+ placeholder: "192.168.20.255"
45550
+ }, {
45551
+ type: "number",
45552
+ key: "timeoutMs",
45553
+ label: "UDP timeout (ms)",
45554
+ min: 500,
45555
+ max: 3e4,
45556
+ default: 3e3
45557
+ }]
45558
+ }] };
45559
+ }
45560
+ //#endregion
45306
45561
  //#region src/ecowitt-integration-manager.ts
45307
45562
  /** Build the real nodewitt facade for a transport. Pure. */
45308
45563
  function defaultEcowittFacadeFactory(config) {
@@ -45459,6 +45714,15 @@ var EcowittIntegrationManager = class {
45459
45714
  */
45460
45715
  var EcowittFacadeResolver = class {
45461
45716
  #facades = /* @__PURE__ */ new Map();
45717
+ /**
45718
+ * Per-gateway snapshot fan-out. Container + sensor children subscribe here
45719
+ * instead of attaching directly to the nodewitt facade's EventEmitter — a
45720
+ * station with >10 sensors would otherwise trip Node's default MaxListeners
45721
+ * cap (one listener per child on a single emitter). The gateway device pumps
45722
+ * this bus once per snapshot from its single facade subscription; a plain
45723
+ * `Set` of callbacks has no listener limit.
45724
+ */
45725
+ #snapshotSubs = /* @__PURE__ */ new Map();
45462
45726
  /** Publish or remove the client for a gateway id. `null` removes the entry. */
45463
45727
  set(gatewayId, facade) {
45464
45728
  if (facade === null) {
@@ -45467,6 +45731,28 @@ var EcowittFacadeResolver = class {
45467
45731
  }
45468
45732
  this.#facades.set(gatewayId, facade);
45469
45733
  }
45734
+ /** Subscribe a child to per-gateway snapshot ticks. Returns an unsubscribe fn. */
45735
+ onSnapshot(gatewayId, cb) {
45736
+ let set = this.#snapshotSubs.get(gatewayId);
45737
+ if (!set) {
45738
+ set = /* @__PURE__ */ new Set();
45739
+ this.#snapshotSubs.set(gatewayId, set);
45740
+ }
45741
+ set.add(cb);
45742
+ return () => {
45743
+ const current = this.#snapshotSubs.get(gatewayId);
45744
+ current?.delete(cb);
45745
+ if (current && current.size === 0) this.#snapshotSubs.delete(gatewayId);
45746
+ };
45747
+ }
45748
+ /** Fan a single facade snapshot out to every subscribed child for a gateway. */
45749
+ emitSnapshot(gatewayId) {
45750
+ const set = this.#snapshotSubs.get(gatewayId);
45751
+ if (!set) return;
45752
+ for (const cb of set) try {
45753
+ cb();
45754
+ } catch {}
45755
+ }
45470
45756
  /** The live client for a gateway id, or null when unknown / stopped. */
45471
45757
  get(gatewayId) {
45472
45758
  return this.#facades.get(gatewayId) ?? null;
@@ -45482,6 +45768,7 @@ var EcowittFacadeResolver = class {
45482
45768
  /** Remove all registered clients (called on full shutdown). */
45483
45769
  clear() {
45484
45770
  this.#facades.clear();
45771
+ this.#snapshotSubs.clear();
45485
45772
  }
45486
45773
  };
45487
45774
  /** The single in-process per-gateway client resolver shared between the gateway
@@ -45694,13 +45981,14 @@ function deviceGroupForSensor(category, channel) {
45694
45981
  //#endregion
45695
45982
  //#region src/ecowitt-discovery.ts
45696
45983
  /**
45697
- * The native id a group Container candidate is keyed on:
45698
- * `ecowitt:<gatewayId>:<group>`. One physical gateway therefore yields THREE
45699
- * candidates (one per {@link EcowittDeviceGroup}). Mirrors the persisted stable
45700
- * id the group Container's `stableIdSuffix` derives for the same Container.
45984
+ * The native id a group Container candidate is keyed on: `<gatewayId>:<group>`.
45985
+ * `gatewayId` is the gateway Hub's stableId, which ALREADY carries the `ecowitt:`
45986
+ * prefix (see `addon.ts` generateStableId) so this must NOT re-add it, else the
45987
+ * id double-prefixes to `ecowitt:ecowitt:<gw>:<group>`. One physical gateway
45988
+ * yields THREE candidates (one per {@link EcowittDeviceGroup}).
45701
45989
  */
45702
45990
  function groupNativeId(gatewayId, group) {
45703
- return `ecowitt:${gatewayId}:${group}`;
45991
+ return `${gatewayId}:${group}`;
45704
45992
  }
45705
45993
  /** A preview accessory child for one sensor. */
45706
45994
  function sensorChild(sensor) {
@@ -45837,9 +46125,6 @@ var EcowittSensorDevice = class extends BaseDevice {
45837
46125
  system: "ecowitt"
45838
46126
  });
45839
46127
  }
45840
- get facade() {
45841
- return ecowittFacades.get(this.gatewayId);
45842
- }
45843
46128
  resolveSensor() {
45844
46129
  return ecowittFacades.getSensor(this.gatewayId, this.sensorId);
45845
46130
  }
@@ -45858,13 +46143,12 @@ var EcowittSensorDevice = class extends BaseDevice {
45858
46143
  this.snapshotUnsub = null;
45859
46144
  }
45860
46145
  }
45861
- /** Attach a facade `snapshot` listener that recomputes this sensor's slice. */
46146
+ /** Subscribe to per-gateway snapshot ticks (recompute this sensor's slice).
46147
+ * Uses the resolver's shared fan-out bus rather than the facade EventEmitter
46148
+ * directly, so N sensors on one gateway never trip Node's MaxListeners cap.
46149
+ * Works even before the facade exists — `recomputeSlice` no-ops on a missing
46150
+ * sensor and replays once the gateway pumps the first snapshot. */
45862
46151
  attachSnapshotListener() {
45863
- const facade = this.facade;
45864
- if (!facade) {
45865
- this.ctx.logger.debug("EcowittSensorDevice: facade not present; no live listener yet", { meta: { sensorId: this.sensorId } });
45866
- return;
45867
- }
45868
46152
  const onSnapshot = () => {
45869
46153
  try {
45870
46154
  this.recomputeSlice();
@@ -45875,10 +46159,7 @@ var EcowittSensorDevice = class extends BaseDevice {
45875
46159
  } });
45876
46160
  }
45877
46161
  };
45878
- facade.on("snapshot", onSnapshot);
45879
- this.snapshotUnsub = () => {
45880
- facade.off("snapshot", onSnapshot);
45881
- };
46162
+ this.snapshotUnsub = ecowittFacades.onSnapshot(this.gatewayId, onSnapshot);
45882
46163
  }
45883
46164
  /** Recompute + write this sensor's typed cap slice (+ battery) from the live reading. */
45884
46165
  recomputeSlice() {
@@ -46224,6 +46505,7 @@ var EcowittGatewayDevice = class extends BaseDevice {
46224
46505
  this.reconcileGroupSensors(sensors).catch((err) => {
46225
46506
  this.ctx.logger.warn("Ecowitt gateway: sensor reconcile failed", { meta: { error: errMsg(err) } });
46226
46507
  });
46508
+ ecowittFacades.emitSnapshot(this.stableId);
46227
46509
  }
46228
46510
  registerDeviceDiscovery() {
46229
46511
  this.ctx.registerNativeCap(deviceDiscoveryCapability, {
@@ -46430,7 +46712,43 @@ var EcowittProviderAddon = class extends BaseDeviceProvider {
46430
46712
  super({});
46431
46713
  }
46432
46714
  async supportsDiscovery() {
46433
- return false;
46715
+ return true;
46716
+ }
46717
+ async getDiscoveryParamsSchema() {
46718
+ return buildDiscoveryParamsFormSchema();
46719
+ }
46720
+ async discoverDevices(input) {
46721
+ const broadcastAddr = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"].trim() : "";
46722
+ const timeoutMs = typeof input?.params?.["timeoutMs"] === "number" ? input.params["timeoutMs"] : void 0;
46723
+ const gateways = await Ecowitt.discover({
46724
+ ...broadcastAddr.length > 0 ? { broadcastAddr } : {},
46725
+ ...timeoutMs !== void 0 ? { timeoutMs } : {}
46726
+ });
46727
+ this.ctx.logger.info("Ecowitt discovery complete", { meta: {
46728
+ count: gateways.length,
46729
+ broadcastAddr: broadcastAddr.length > 0 ? broadcastAddr : "local"
46730
+ } });
46731
+ return gateways.map((g) => {
46732
+ const displayName = g.model ?? g.name;
46733
+ return {
46734
+ stableId: `ecowitt:${slugifyHost(g.ip)}`,
46735
+ type: DeviceType.Hub,
46736
+ suggestedName: displayName,
46737
+ prefilledConfig: {
46738
+ name: displayName,
46739
+ connection: {
46740
+ transport: "local",
46741
+ host: g.ip
46742
+ }
46743
+ }
46744
+ };
46745
+ });
46746
+ }
46747
+ async adoptDiscoveredDevice(input) {
46748
+ return this.createDevice({
46749
+ type: DeviceType.Hub,
46750
+ config: input.candidate.prefilledConfig
46751
+ });
46434
46752
  }
46435
46753
  async supportsManualCreation() {
46436
46754
  return true;
@@ -46487,10 +46805,10 @@ var EcowittProviderAddon = class extends BaseDeviceProvider {
46487
46805
  generateStableId(_type, config) {
46488
46806
  const connection = extractConnection(config);
46489
46807
  const station = typeof connection?.stationId === "string" ? connection.stationId.trim() : "";
46490
- if (station.length > 0) return `ecowitt:${slug(station)}`;
46808
+ if (station.length > 0) return `ecowitt:${slugifyHost(station)}`;
46491
46809
  if (connection?.transport === "local") {
46492
46810
  const host = connection.host.trim();
46493
- if (host.length > 0) return `ecowitt:${slug(host)}`;
46811
+ if (host.length > 0) return `ecowitt:${slugifyHost(host)}`;
46494
46812
  }
46495
46813
  if (connection?.transport === "listener") return `ecowitt:listen-${connection.listenPort}`;
46496
46814
  throw new Error("Ecowitt: gateway resolved neither a station id nor a host/listen endpoint — cannot persist a stable row key.");
@@ -46504,9 +46822,5 @@ function extractConnection(config) {
46504
46822
  if (transport !== "local" && transport !== "listener") return null;
46505
46823
  return settingsToEcowittConfig(raw);
46506
46824
  }
46507
- /** Flatten a host / station id into a readable row-key slug. */
46508
- function slug(value) {
46509
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46510
- }
46511
46825
  //#endregion
46512
46826
  export { EcowittProviderAddon, deviceGroupForSensor as a, valueFieldForCap as c, ecowittGatewaySchema as d, settingsToEcowittConfig as f, capForQuantity as i, buildConnectionFormSchema as l, groupNativeId as n, deviceGroupLabel as o, ECOWITT_DEVICE_GROUPS as r, sensorLabel as s, buildEcowittGatewayCandidates as t, ecowittConfigSchema as u };