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