@camstack/addon-provider-gree 0.1.8 → 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.
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-B3AOslwX.mjs
4647
+ //#region ../types/dist/sleep-C2M2zF7x.mjs
4648
4648
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4649
4649
  EventCategory["SystemBoot"] = "system.boot";
4650
4650
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -6212,6 +6212,12 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
6212
6212
  DeviceType["Switch"] = "switch";
6213
6213
  DeviceType["Sensor"] = "sensor";
6214
6214
  DeviceType["Thermostat"] = "thermostat";
6215
+ /** Air-conditioner / heat-pump climate device (HVAC) — shares the
6216
+ * `climate-control` cap surface with `Thermostat` but renders a
6217
+ * dedicated AC-appropriate control UI (mode chips, fan speed,
6218
+ * independent vertical/horizontal swing). Sources: native Gree, and
6219
+ * reusable by other AC integrations. */
6220
+ DeviceType["Climate"] = "climate";
6215
6221
  DeviceType["Button"] = "button";
6216
6222
  /** Generic stateless event emitter — carries a device's EXACT declared
6217
6223
  * event vocabulary verbatim (no normalization). Installed with the
@@ -9007,7 +9013,7 @@ var climateControlCapability = {
9007
9013
  scope: "device",
9008
9014
  deviceNative: true,
9009
9015
  mode: "singleton",
9010
- deviceTypes: [DeviceType.Thermostat],
9016
+ deviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
9011
9017
  methods: {
9012
9018
  setMode: method(object({
9013
9019
  deviceId: number().int().nonnegative(),
@@ -13606,10 +13612,30 @@ var deviceProviderCapability = {
13606
13612
  type: string()
13607
13613
  }))),
13608
13614
  supportsDiscovery: method(object({}), boolean()),
13609
- discoverDevices: method(object({}), array(DiscoveryCandidateSchema), {
13615
+ /**
13616
+ * Run a network scan. `params` carries optional provider-specific scan
13617
+ * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
13618
+ * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
13619
+ * (provider uses its local-network default).
13620
+ */
13621
+ discoverDevices: method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
13610
13622
  kind: "mutation",
13611
13623
  auth: "admin"
13612
13624
  }),
13625
+ /**
13626
+ * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
13627
+ * provider accepts (e.g. Gree's broadcast address for a different subnet).
13628
+ * `null` when the provider takes no extra scan params — the generic
13629
+ * aggregated scan never renders this; the per-integration scan does.
13630
+ */
13631
+ getDiscoveryParamsSchema: method(object({}), CreationSchemaOutputSchema),
13632
+ /**
13633
+ * The DeviceType this provider creates via manual add (Camera for
13634
+ * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
13635
+ * provider does not support manual creation. Lets the Add-Device dialog
13636
+ * pick the right type instead of assuming Camera.
13637
+ */
13638
+ getManualCreationType: method(object({}), object({ deviceType: _enum(DeviceType).nullable() })),
13613
13639
  adoptDiscoveredDevice: method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
13614
13640
  kind: "mutation",
13615
13641
  auth: "admin"
@@ -13733,9 +13759,23 @@ var BaseDeviceProvider = class extends BaseAddon {
13733
13759
  async supportsDiscovery() {
13734
13760
  return false;
13735
13761
  }
13736
- async discoverDevices() {
13762
+ async discoverDevices(_input) {
13737
13763
  return [];
13738
13764
  }
13765
+ /** Extra per-scan input form (e.g. a broadcast address for another subnet).
13766
+ * Null = no extra params. Override in providers that support scoped scans. */
13767
+ async getDiscoveryParamsSchema() {
13768
+ return null;
13769
+ }
13770
+ /**
13771
+ * The DeviceType this provider creates via manual add — derived from the
13772
+ * `deviceClasses` map (first registered type). `null` when manual creation is
13773
+ * unsupported. Lets the Add-Device dialog pick the right type per provider.
13774
+ */
13775
+ async getManualCreationType() {
13776
+ if (!await this.supportsManualCreation()) return { deviceType: null };
13777
+ return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
13778
+ }
13739
13779
  async adoptDiscoveredDevice(_input) {
13740
13780
  throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
13741
13781
  }
@@ -14782,83 +14822,34 @@ var GetStateInputSchema = object({
14782
14822
  * HA: entity_id (returns the cached entity state). */
14783
14823
  key: string()
14784
14824
  });
14785
- var brokerCapability = {
14786
- name: "broker",
14787
- scope: "system",
14788
- mode: "collection",
14789
- providerKind: "broker",
14790
- status: {
14791
- schema: RegistryStatusSchema,
14792
- kind: "poll"
14793
- },
14794
- methods: {
14795
- list: method(ListInputSchema, array(BrokerInfoSchema$1)),
14796
- get: method(GetInputSchema, BrokerInfoSchema$1.nullable()),
14797
- /** Enumerate which addon provides which broker kind(s) for the
14798
- * unified create picker. The auto-mount fans this array across
14799
- * every registered `broker` provider (array-output method), so the
14800
- * picker sees every kind from every provider in one call. */
14801
- listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
14802
- add: method(AddInputSchema, AddResultSchema, {
14803
- kind: "mutation",
14804
- auth: "admin"
14805
- }),
14806
- remove: method(RemoveInputSchema, _void(), {
14807
- kind: "mutation",
14808
- auth: "admin"
14809
- }),
14810
- testConnection: method(GetInputSchema, TestConnectionResultSchema, {
14811
- kind: "mutation",
14812
- auth: "admin"
14813
- }),
14814
- /** Read the persisted settings record for a broker (kind-specific
14815
- * shape). Admin-only — settings may contain secrets. Returns `null`
14816
- * when the broker id is unknown to the provider (the collection
14817
- * fallback may route a foreign id to the first provider). */
14818
- getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
14819
- /** Overwrite the persisted settings record. The kind-specific
14820
- * provider validates the shape and applies the change (reconnects
14821
- * if credentials changed). */
14822
- setSettings: method(object({
14823
- id: string(),
14824
- settings: SettingsRecordSchema$1
14825
- }), _void(), {
14826
- kind: "mutation",
14827
- auth: "admin"
14828
- }),
14829
- /** Returns the kind-specific connection config the consumer needs
14830
- * to open its own client (MQTT pattern: `{url, username, password,
14831
- * clientIdPrefix}`). HA providers MAY return the auth envelope
14832
- * but typical HA consumers use `publish` / `subscribe` instead.
14833
- * Returns `null` when the broker id is unknown to the provider. */
14834
- getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
14835
- getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
14836
- testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
14837
- kind: "mutation",
14838
- auth: "admin"
14839
- }),
14840
- publish: method(PublishInputSchema, unknown(), {
14841
- kind: "mutation",
14842
- auth: "admin"
14843
- }),
14844
- subscribe: method(SubscribeInputSchema, SubscribeResultSchema, {
14845
- kind: "mutation",
14846
- auth: "admin"
14847
- }),
14848
- unsubscribe: method(UnsubscribeInputSchema, _void(), {
14849
- kind: "mutation",
14850
- auth: "admin"
14851
- }),
14852
- /** Read the broker's cached state for a key. Returns `null` when
14853
- * unknown to the broker (never published / unknown entity). */
14854
- getState: method(GetStateInputSchema, unknown().nullable()),
14855
- /** Status method — explicit registration with a `z.void()` input so
14856
- * the codegen-generated tRPC router types its input as
14857
- * `{addonId?: string, nodeId?: string}` (system-scoped collection
14858
- * shape) instead of the device-scoped `{deviceId}` fallback. */
14859
- getStatus: method(_void(), RegistryStatusSchema)
14860
- }
14861
- };
14825
+ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, BrokerInfoSchema$1.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
14826
+ kind: "mutation",
14827
+ auth: "admin"
14828
+ }), method(RemoveInputSchema, _void(), {
14829
+ kind: "mutation",
14830
+ auth: "admin"
14831
+ }), method(GetInputSchema, TestConnectionResultSchema, {
14832
+ kind: "mutation",
14833
+ auth: "admin"
14834
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
14835
+ id: string(),
14836
+ settings: SettingsRecordSchema$1
14837
+ }), _void(), {
14838
+ kind: "mutation",
14839
+ auth: "admin"
14840
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
14841
+ kind: "mutation",
14842
+ auth: "admin"
14843
+ }), method(PublishInputSchema, unknown(), {
14844
+ kind: "mutation",
14845
+ auth: "admin"
14846
+ }), method(SubscribeInputSchema, SubscribeResultSchema, {
14847
+ kind: "mutation",
14848
+ auth: "admin"
14849
+ }), method(UnsubscribeInputSchema, _void(), {
14850
+ kind: "mutation",
14851
+ auth: "admin"
14852
+ }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
14862
14853
  DeviceType.Camera;
14863
14854
  /**
14864
14855
  * `custom-model-registry` — collection cap exposing operator-registered
@@ -15094,36 +15085,19 @@ var ResyncResultSchema = object({
15094
15085
  * provider re-derived the device. 0/absent for a normal incremental re-sync. */
15095
15086
  removedChildren: number().int().nonnegative().optional()
15096
15087
  });
15097
- var deviceAdoptionCapability = {
15098
- name: "device-adoption",
15099
- scope: "system",
15100
- mode: "singleton",
15101
- status: {
15102
- schema: AdoptionStatusSchema,
15103
- kind: "poll"
15104
- },
15105
- methods: {
15106
- listCandidateFilters: method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }),
15107
- listCandidates: method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }),
15108
- getCandidate: method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }),
15109
- refresh: method(object({ integrationId: string() }), AdoptionStatusSchema, {
15110
- kind: "mutation",
15111
- auth: "admin"
15112
- }),
15113
- adopt: method(AdoptInputSchema, AdoptResultSchema, {
15114
- kind: "mutation",
15115
- auth: "admin"
15116
- }),
15117
- release: method(ReleaseInputSchema, _void(), {
15118
- kind: "mutation",
15119
- auth: "admin"
15120
- }),
15121
- resync: method(ResyncInputSchema, ResyncResultSchema, {
15122
- kind: "mutation",
15123
- auth: "admin"
15124
- })
15125
- }
15126
- };
15088
+ method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
15089
+ kind: "mutation",
15090
+ auth: "admin"
15091
+ }), method(AdoptInputSchema, AdoptResultSchema, {
15092
+ kind: "mutation",
15093
+ auth: "admin"
15094
+ }), method(ReleaseInputSchema, _void(), {
15095
+ kind: "mutation",
15096
+ auth: "admin"
15097
+ }), method(ResyncInputSchema, ResyncResultSchema, {
15098
+ kind: "mutation",
15099
+ auth: "admin"
15100
+ });
15127
15101
  /**
15128
15102
  * `device-export` — collection cap for addons that export camstack
15129
15103
  * devices to external ecosystems (HomeAssistant via MQTT discovery,
@@ -15646,7 +15620,10 @@ method(object({
15646
15620
  }), FieldProbeResultSchema, {
15647
15621
  kind: "mutation",
15648
15622
  auth: "admin"
15649
- }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15623
+ }), method(object({
15624
+ addonId: string(),
15625
+ integrationId: string()
15626
+ }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15650
15627
  addonId: string(),
15651
15628
  integrationId: string()
15652
15629
  }), AdoptionStatusSchema, {
@@ -15661,7 +15638,24 @@ method(object({
15661
15638
  }), method(ResyncInputSchema, ResyncResultSchema, {
15662
15639
  kind: "mutation",
15663
15640
  auth: "admin"
15641
+ }), method(object({}), object({ providers: array(object({
15642
+ addonId: string(),
15643
+ label: string()
15644
+ })).readonly() }), { auth: "admin" }), method(object({}), object({ groups: array(object({
15645
+ addonId: string(),
15646
+ label: string(),
15647
+ candidates: array(DiscoveryCandidateSchema).readonly(),
15648
+ error: string().nullable()
15649
+ })).readonly() }), {
15650
+ kind: "mutation",
15651
+ auth: "admin"
15664
15652
  }), method(object({
15653
+ addonId: string(),
15654
+ params: record(string(), unknown()).optional()
15655
+ }), object({ candidates: array(DiscoveryCandidateSchema).readonly() }), {
15656
+ kind: "mutation",
15657
+ auth: "admin"
15658
+ }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
15665
15659
  deviceId: number(),
15666
15660
  key: string(),
15667
15661
  value: unknown()
@@ -18313,6 +18307,17 @@ var AvailableIntegrationTypeSchema = object({
18313
18307
  iconUrl: string().nullable(),
18314
18308
  color: string(),
18315
18309
  instanceMode: string(),
18310
+ /**
18311
+ * Integration wizard `mode` (LOCKED MODEL): `standalone` (create
18312
+ * immediately then add devices, no config step/button), `account` (config
18313
+ * step), or `broker` (broker step). Derived server-side by
18314
+ * `getAvailableTypes` when the addon manifest omits an explicit `mode`.
18315
+ */
18316
+ mode: _enum([
18317
+ "standalone",
18318
+ "account",
18319
+ "broker"
18320
+ ]),
18316
18321
  discoveryMode: string(),
18317
18322
  /**
18318
18323
  * Which integration-marker cap the addon declared, so the wizard can
@@ -20773,6 +20778,12 @@ Object.freeze({
20773
20778
  addonId: null,
20774
20779
  access: "create"
20775
20780
  },
20781
+ "deviceManager.adoptionListCandidateFilters": {
20782
+ capName: "device-manager",
20783
+ capScope: "system",
20784
+ addonId: null,
20785
+ access: "view"
20786
+ },
20776
20787
  "deviceManager.adoptionListCandidates": {
20777
20788
  capName: "device-manager",
20778
20789
  capScope: "system",
@@ -20821,12 +20832,30 @@ Object.freeze({
20821
20832
  addonId: null,
20822
20833
  access: "create"
20823
20834
  },
20835
+ "deviceManager.discoverAllProviders": {
20836
+ capName: "device-manager",
20837
+ capScope: "system",
20838
+ addonId: null,
20839
+ access: "create"
20840
+ },
20824
20841
  "deviceManager.discoverDevices": {
20825
20842
  capName: "device-manager",
20826
20843
  capScope: "system",
20827
20844
  addonId: null,
20828
20845
  access: "create"
20829
20846
  },
20847
+ "deviceManager.discoverProvider": {
20848
+ capName: "device-manager",
20849
+ capScope: "system",
20850
+ addonId: null,
20851
+ access: "create"
20852
+ },
20853
+ "deviceManager.discoveryProviders": {
20854
+ capName: "device-manager",
20855
+ capScope: "system",
20856
+ addonId: null,
20857
+ access: "view"
20858
+ },
20830
20859
  "deviceManager.enable": {
20831
20860
  capName: "device-manager",
20832
20861
  capScope: "system",
@@ -20977,6 +21006,18 @@ Object.freeze({
20977
21006
  addonId: null,
20978
21007
  access: "create"
20979
21008
  },
21009
+ "deviceManager.providerCreationType": {
21010
+ capName: "device-manager",
21011
+ capScope: "system",
21012
+ addonId: null,
21013
+ access: "view"
21014
+ },
21015
+ "deviceManager.providerDiscoveryParamsSchema": {
21016
+ capName: "device-manager",
21017
+ capScope: "system",
21018
+ addonId: null,
21019
+ access: "view"
21020
+ },
20980
21021
  "deviceManager.registerDevice": {
20981
21022
  capName: "device-manager",
20982
21023
  capScope: "system",
@@ -21193,6 +21234,18 @@ Object.freeze({
21193
21234
  addonId: null,
21194
21235
  access: "view"
21195
21236
  },
21237
+ "deviceProvider.getDiscoveryParamsSchema": {
21238
+ capName: "device-provider",
21239
+ capScope: "system",
21240
+ addonId: null,
21241
+ access: "view"
21242
+ },
21243
+ "deviceProvider.getManualCreationType": {
21244
+ capName: "device-provider",
21245
+ capScope: "system",
21246
+ addonId: null,
21247
+ access: "view"
21248
+ },
21196
21249
  "deviceProvider.getStatus": {
21197
21250
  capName: "device-provider",
21198
21251
  capScope: "system",
@@ -24031,77 +24084,257 @@ object({
24031
24084
  schemaVersion: literal(1)
24032
24085
  });
24033
24086
  //#endregion
24034
- //#region ../../node_modules/@apocaliss92/nodegree/dist/index.js
24035
- var GreeError = class extends Error {
24036
- constructor(message) {
24037
- super(message);
24038
- this.name = "GreeError";
24039
- }
24040
- };
24041
- var GreeAuthError = class extends GreeError {
24042
- constructor(message) {
24043
- super(message);
24044
- this.name = "GreeAuthError";
24045
- }
24046
- };
24047
- var GreeTimeoutError = class extends GreeError {
24048
- constructor(message) {
24049
- super(message);
24050
- this.name = "GreeTimeoutError";
24051
- }
24052
- };
24053
- var GreeTransportError = class extends GreeError {
24054
- constructor(message) {
24055
- super(message);
24056
- this.name = "GreeTransportError";
24057
- }
24058
- };
24059
- function createDgramSocket() {
24060
- return new Promise((resolve, reject) => {
24061
- const socket = createSocket({
24062
- type: "udp4",
24063
- reuseAddr: true
24064
- });
24065
- socket.once("error", reject);
24066
- socket.bind(() => {
24067
- socket.removeListener("error", reject);
24068
- resolve({
24069
- send: (data, port, address) => new Promise((res, rej) => socket.send(data, port, address, (err) => err ? rej(err) : res())),
24070
- onMessage: (handler) => socket.on("message", (data, rinfo) => handler({
24071
- data,
24072
- address: rinfo.address,
24073
- port: rinfo.port
24074
- })),
24075
- setBroadcast: (enabled) => socket.setBroadcast(enabled),
24076
- address: () => socket.address().port,
24077
- close: () => new Promise((res) => socket.close(() => res()))
24078
- });
24079
- });
24080
- });
24081
- }
24082
- function buildEnvelope(opts) {
24083
- const env = {
24084
- t: opts.t,
24085
- i: opts.bindOrScan ? 1 : 0,
24086
- uid: 0,
24087
- cid: "app",
24088
- pack: opts.pack
24087
+ //#region src/config.ts
24088
+ /**
24089
+ * The AES negotiation modes the wrapped `@apocaliss92/nodegree` client accepts.
24090
+ * Mirrors the library's `encryption` union — kept local so the addon validates
24091
+ * the operator-supplied value at the system boundary without importing a runtime
24092
+ * value the library does not export. `auto` tries V2 (GCM) then V1 (ECB).
24093
+ */
24094
+ var GREE_ENCRYPTION_MODES = [
24095
+ "auto",
24096
+ "v1",
24097
+ "v2"
24098
+ ];
24099
+ var GreeEncryptionSchema = _enum(GREE_ENCRYPTION_MODES);
24100
+ /**
24101
+ * Operator-supplied CONNECTION for ONE Gree air conditioner (standalone mode —
24102
+ * the Reolink / Ecowitt pattern). Gree is LOCAL-ONLY (a directed-broadcast bind
24103
+ * handshake + per-device AES control over UDP), so the connection carries no
24104
+ * credentials — only the AC's LAN address and the UDP tuning knobs. The
24105
+ * `broadcastAddr` is the directed target the bind scan is aimed at: point it at
24106
+ * the AC's own IP (unicast-directed) or the subnet broadcast (e.g.
24107
+ * `192.168.1.255`). Empty = the library's default global broadcast.
24108
+ */
24109
+ var greeConfigSchema = object({
24110
+ /** The AC's LAN IP address — the directed-bind target. Required for a manual
24111
+ * standalone add (the operator types it). */
24112
+ host: string().default("").describe("Air conditioner LAN IP address"),
24113
+ /** Directed broadcast address for the bind scan (e.g. `192.168.1.255`). Empty =
24114
+ * the library's default global broadcast; when a `host` is set the scan is
24115
+ * aimed at it directly. */
24116
+ broadcastAddr: string().default("").describe("Directed broadcast address for the bind scan"),
24117
+ /** UDP request timeout in ms. */
24118
+ timeoutMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(500).max(3e4).default(3e3)).describe("UDP request timeout (ms)"),
24119
+ /** Retry count per UDP request. */
24120
+ retries: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(0).max(10).default(3)).describe("Retries per UDP request"),
24121
+ /** AES negotiation mode. */
24122
+ encryption: GreeEncryptionSchema.default("auto").describe("AES cipher negotiation")
24123
+ });
24124
+ /**
24125
+ * Build the `NodegreeOptions` the wrapped client constructor expects from the
24126
+ * validated connection. Pure: same config in → same options out. `host` and
24127
+ * `broadcastAddr` are NOT constructor options — they are passed to `discover()`
24128
+ * per scan — so they are intentionally omitted here.
24129
+ */
24130
+ function toNodegreeOptions(config) {
24131
+ return {
24132
+ timeoutMs: config.timeoutMs,
24133
+ retries: config.retries,
24134
+ encryption: config.encryption
24089
24135
  };
24090
- if (opts.mac) env.tcid = opts.mac;
24091
- if (opts.tag) env.tag = opts.tag;
24092
- return env;
24093
24136
  }
24094
- function packRequest(cipher, opts) {
24095
- const { pack, tag } = cipher.encrypt(opts.payload);
24096
- return buildEnvelope({
24097
- t: opts.t,
24098
- mac: opts.mac,
24099
- bindOrScan: opts.bindOrScan,
24100
- pack,
24101
- tag
24102
- });
24137
+ /**
24138
+ * Coerce a loose settings blob (the manual-creation form values, or a persisted
24139
+ * `connection` blob) through the connection schema, applying all defaults.
24140
+ * Throws a `ZodError` on invalid input so the caller surfaces a clear error at
24141
+ * the system boundary.
24142
+ */
24143
+ function settingsToGreeConfig(settings) {
24144
+ return greeConfigSchema.parse(settings ?? {});
24103
24145
  }
24104
- function unpackResponse(cipher, raw) {
24146
+ /**
24147
+ * Persisted config for a Gree AC {@link import('@camstack/types').DeviceType.Container}
24148
+ * device. The operator-supplied CONNECTION lives directly on the device (Reolink
24149
+ * pattern) — there is no broker registry. The device owns its own live
24150
+ * `@apocaliss92/nodegree` client keyed on its own `connectionKey`; its AC +
24151
+ * toggle accessory children resolve the bound handle via that key.
24152
+ *
24153
+ * `greeMac` is the durable device identity (resolved by the create-time bind);
24154
+ * `greeIp` is the last-known LAN address; `connectionKey` is the per-device
24155
+ * connection-resolver key (the device's own stableId — decoupled from any
24156
+ * broker); `connection` is the operator's UDP settings; `system`/`name` are
24157
+ * provenance.
24158
+ */
24159
+ var greeAcDeviceSchema = object({
24160
+ /** Durable AC identity (MAC, lowercased) resolved by the create-time bind. */
24161
+ greeMac: string().min(1).describe("Gree AC MAC address"),
24162
+ /** Last-known LAN IP (re-bound on each activate). */
24163
+ greeIp: string().optional().describe("Last known LAN IP"),
24164
+ /** Per-device connection-resolver key (the device's own stableId). */
24165
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
24166
+ /** The operator-supplied UDP connection settings the device's client dials. */
24167
+ connection: greeConfigSchema,
24168
+ system: literal("gree").optional(),
24169
+ integrationId: string().optional(),
24170
+ name: string().optional()
24171
+ });
24172
+ /**
24173
+ * Hand-written connection form for the AC device-creation UI (standalone mode —
24174
+ * Reolink pattern). The connection lives on the AC DEVICE config, not in a broker
24175
+ * registry. A `name` field is included so the operator names the AC at creation
24176
+ * time (mirrors the Reolink / Ecowitt creation form).
24177
+ */
24178
+ function buildConnectionFormSchema() {
24179
+ return { sections: [{
24180
+ id: "identity",
24181
+ title: "Air conditioner",
24182
+ description: "Gree air conditioners are controlled directly over your LAN (no cloud). Enter the AC's IP address; CamStack binds to it over UDP.",
24183
+ columns: 1,
24184
+ fields: [{
24185
+ type: "text",
24186
+ key: "name",
24187
+ label: "Name",
24188
+ required: true,
24189
+ placeholder: "Living room AC"
24190
+ }, {
24191
+ type: "text",
24192
+ key: "host",
24193
+ label: "IP address",
24194
+ required: true,
24195
+ placeholder: "192.168.1.50"
24196
+ }]
24197
+ }, {
24198
+ id: "advanced",
24199
+ title: "Advanced (UDP)",
24200
+ columns: 2,
24201
+ fields: [
24202
+ {
24203
+ type: "text",
24204
+ key: "broadcastAddr",
24205
+ label: "Broadcast address (optional)",
24206
+ required: false,
24207
+ placeholder: "192.168.1.255"
24208
+ },
24209
+ {
24210
+ type: "number",
24211
+ key: "timeoutMs",
24212
+ label: "UDP timeout (ms)",
24213
+ min: 500,
24214
+ max: 3e4,
24215
+ default: 3e3
24216
+ },
24217
+ {
24218
+ type: "number",
24219
+ key: "retries",
24220
+ label: "Retries",
24221
+ min: 0,
24222
+ max: 10,
24223
+ default: 3
24224
+ },
24225
+ {
24226
+ type: "select",
24227
+ key: "encryption",
24228
+ label: "Encryption",
24229
+ default: "auto",
24230
+ options: GREE_ENCRYPTION_MODES.map((m) => ({
24231
+ value: m,
24232
+ label: m.toUpperCase()
24233
+ }))
24234
+ }
24235
+ ]
24236
+ }] };
24237
+ }
24238
+ /**
24239
+ * Extra params form for a PER-INTEGRATION network scan. Gree's UDP broadcast
24240
+ * only reaches the addon node's local subnet; to find ACs on a DIFFERENT subnet
24241
+ * (e.g. 192.168.20.x) the operator supplies that subnet's directed-broadcast
24242
+ * address (192.168.20.255). Empty = default local-subnet broadcast.
24243
+ */
24244
+ function buildDiscoveryParamsFormSchema() {
24245
+ return { sections: [{
24246
+ id: "scan",
24247
+ title: "Scan options",
24248
+ description: "Leave empty to scan the local subnet. To find air conditioners on a different subnet, enter that subnet’s broadcast address (e.g. 192.168.20.255).",
24249
+ columns: 1,
24250
+ fields: [{
24251
+ type: "text",
24252
+ key: "broadcastAddress",
24253
+ label: "Broadcast address",
24254
+ required: false,
24255
+ placeholder: "192.168.20.255"
24256
+ }, {
24257
+ type: "number",
24258
+ key: "timeoutMs",
24259
+ label: "UDP timeout (ms)",
24260
+ min: 500,
24261
+ max: 3e4,
24262
+ default: 3e3
24263
+ }]
24264
+ }] };
24265
+ }
24266
+ //#endregion
24267
+ //#region ../../node_modules/@apocaliss92/nodegree/dist/index.js
24268
+ var GreeError = class extends Error {
24269
+ constructor(message) {
24270
+ super(message);
24271
+ this.name = "GreeError";
24272
+ }
24273
+ };
24274
+ var GreeAuthError = class extends GreeError {
24275
+ constructor(message) {
24276
+ super(message);
24277
+ this.name = "GreeAuthError";
24278
+ }
24279
+ };
24280
+ var GreeTimeoutError = class extends GreeError {
24281
+ constructor(message) {
24282
+ super(message);
24283
+ this.name = "GreeTimeoutError";
24284
+ }
24285
+ };
24286
+ var GreeTransportError = class extends GreeError {
24287
+ constructor(message) {
24288
+ super(message);
24289
+ this.name = "GreeTransportError";
24290
+ }
24291
+ };
24292
+ function createDgramSocket() {
24293
+ return new Promise((resolve, reject) => {
24294
+ const socket = createSocket({
24295
+ type: "udp4",
24296
+ reuseAddr: true
24297
+ });
24298
+ socket.once("error", reject);
24299
+ socket.bind(() => {
24300
+ socket.removeListener("error", reject);
24301
+ resolve({
24302
+ send: (data, port, address) => new Promise((res, rej) => socket.send(data, port, address, (err) => err ? rej(err) : res())),
24303
+ onMessage: (handler) => socket.on("message", (data, rinfo) => handler({
24304
+ data,
24305
+ address: rinfo.address,
24306
+ port: rinfo.port
24307
+ })),
24308
+ setBroadcast: (enabled) => socket.setBroadcast(enabled),
24309
+ address: () => socket.address().port,
24310
+ close: () => new Promise((res) => socket.close(() => res()))
24311
+ });
24312
+ });
24313
+ });
24314
+ }
24315
+ function buildEnvelope(opts) {
24316
+ const env = {
24317
+ t: opts.t,
24318
+ i: opts.bindOrScan ? 1 : 0,
24319
+ uid: 0,
24320
+ cid: "app",
24321
+ pack: opts.pack
24322
+ };
24323
+ if (opts.mac) env.tcid = opts.mac;
24324
+ if (opts.tag) env.tag = opts.tag;
24325
+ return env;
24326
+ }
24327
+ function packRequest(cipher, opts) {
24328
+ const { pack, tag } = cipher.encrypt(opts.payload);
24329
+ return buildEnvelope({
24330
+ t: opts.t,
24331
+ mac: opts.mac,
24332
+ bindOrScan: opts.bindOrScan,
24333
+ pack,
24334
+ tag
24335
+ });
24336
+ }
24337
+ function unpackResponse(cipher, raw) {
24105
24338
  if (typeof raw !== "object" || raw === null) throw new GreeTransportError("malformed response");
24106
24339
  const obj = raw;
24107
24340
  if (typeof obj.pack !== "string") throw new GreeTransportError("response missing pack");
@@ -24712,191 +24945,108 @@ var Nodegree = class {
24712
24945
  }
24713
24946
  };
24714
24947
  //#endregion
24715
- //#region src/config.ts
24716
- /**
24717
- * The AES negotiation modes the wrapped `@apocaliss92/nodegree` client accepts.
24718
- * Mirrors the library's `encryption` union — kept local so the addon validates
24719
- * the operator-supplied value at the system boundary without importing a runtime
24720
- * value the library does not export. `auto` tries V2 (GCM) then V1 (ECB).
24721
- */
24722
- var GREE_ENCRYPTION_MODES = [
24723
- "auto",
24724
- "v1",
24725
- "v2"
24726
- ];
24727
- var GreeEncryptionSchema = _enum(GREE_ENCRYPTION_MODES);
24728
- /**
24729
- * Operator-supplied settings for ONE Gree "broker" (= one LAN discovery scope on
24730
- * one node). Gree is LOCAL-ONLY (UDP broadcast discovery + per-device AES
24731
- * control), so a broker carries no credentials — only the broadcast target and
24732
- * the UDP tuning knobs. Each node running the addon owns its own socket; the
24733
- * `broadcastAddr` lets the operator point discovery at the right subnet
24734
- * (directed broadcast) when the node has multiple interfaces.
24735
- */
24736
- var greeConfigSchema = object({
24737
- /** Directed broadcast address for discovery (e.g. `192.168.1.255`). Empty =
24738
- * the library's default global broadcast. */
24739
- broadcastAddr: string().default("").describe("Directed broadcast address for discovery"),
24740
- /** UDP request timeout in ms. */
24741
- timeoutMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(500).max(3e4).default(3e3)).describe("UDP request timeout (ms)"),
24742
- /** Retry count per UDP request. */
24743
- retries: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(0).max(10).default(3)).describe("Retries per UDP request"),
24744
- /** AES negotiation mode. */
24745
- encryption: GreeEncryptionSchema.default("auto").describe("AES cipher negotiation")
24746
- });
24747
- /**
24748
- * Build the `NodegreeOptions` the wrapped client constructor expects from the
24749
- * validated addon config. Pure: same config in → same options out. The
24750
- * `broadcastAddr` is NOT a constructor option — it is passed to `discover()` per
24751
- * scan — so it is intentionally omitted here.
24752
- */
24753
- function toNodegreeOptions(config) {
24754
- return {
24755
- timeoutMs: config.timeoutMs,
24756
- retries: config.retries,
24757
- encryption: config.encryption
24758
- };
24759
- }
24760
- /** Top-level addon config — an ordered list of broker entries (default empty). */
24761
- var greeAddonConfigSchema = object({ brokers: array(object({
24762
- /** Stable opaque identifier — e.g. 'gree_001', 'gree_002'. */
24763
- id: string().min(1),
24764
- /** Human-readable label shown in the admin UI. */
24765
- name: string().min(1),
24766
- /** Validated discovery-scope settings. */
24767
- connection: greeConfigSchema,
24768
- /** FK to the spawning integration — auto-cleanup on integration delete. */
24769
- integrationId: string().optional()
24770
- })).default([]) });
24771
- /**
24772
- * Coerce a loose settings blob (from the broker `add`/`setSettings` cap) through
24773
- * the connection schema, applying all defaults. Throws a `ZodError` on invalid
24774
- * input so the caller surfaces a clear error at the system boundary.
24775
- */
24776
- function settingsToGreeConfig(settings) {
24777
- return greeConfigSchema.parse(settings ?? {});
24778
- }
24779
- /**
24780
- * Hand-written settings form for the broker/integration creation UI. Mirrors the
24781
- * Dreame / Homematic broker-settings form shape — a flat set of sections the
24782
- * admin UI renders into the "Add Gree scope" modal.
24783
- */
24784
- function buildConnectionFormSchema() {
24785
- return { sections: [{
24786
- id: "discovery",
24787
- title: "Gree discovery scope",
24788
- description: "Gree air conditioners are controlled directly over your LAN (no cloud). Optionally point discovery at a specific subnet broadcast address.",
24789
- columns: 1,
24790
- fields: [{
24791
- type: "text",
24792
- key: "broadcastAddr",
24793
- label: "Broadcast address (optional)",
24794
- required: false,
24795
- placeholder: "192.168.1.255"
24796
- }]
24797
- }, {
24798
- id: "advanced",
24799
- title: "Advanced (UDP)",
24800
- columns: 2,
24801
- fields: [
24802
- {
24803
- type: "number",
24804
- key: "timeoutMs",
24805
- label: "UDP timeout (ms)",
24806
- min: 500,
24807
- max: 3e4,
24808
- default: 3e3
24809
- },
24810
- {
24811
- type: "number",
24812
- key: "retries",
24813
- label: "Retries",
24814
- min: 0,
24815
- max: 10,
24816
- default: 3
24817
- },
24818
- {
24819
- type: "select",
24820
- key: "encryption",
24821
- label: "Encryption",
24822
- default: "auto",
24823
- options: GREE_ENCRYPTION_MODES.map((m) => ({
24824
- value: m,
24825
- label: m.toUpperCase()
24826
- }))
24827
- }
24828
- ]
24829
- }] };
24830
- }
24831
- //#endregion
24832
24948
  //#region src/gree-gateway.ts
24833
24949
  /** Lowercase a MAC for stable map keying (Gree echoes mixed-case MACs). */
24834
- function macKey$2(mac) {
24950
+ function macKey(mac) {
24835
24951
  return mac.toLowerCase();
24836
24952
  }
24837
24953
  /**
24838
- * Per-broker registry that device classes use to reach a bound Gree AC handle
24839
- * (and the discovery rows) for a given discovery scope.
24954
+ * Per-connection registry that device classes use to reach a bound Gree AC handle
24955
+ * (and the bind-scan rows) for a given AC device (standalone mode — the
24956
+ * connection lives on the AC device, not a broker).
24840
24957
  *
24841
24958
  * The kernel constructs device classes with only a `DeviceContext` — it cannot
24842
- * thread the handle in as a constructor arg. Like the Dreame addon's
24843
- * `dreameFacades`, we keep it simple and in-process: the integration manager
24844
- * owns the connection surface per registered broker and publishes it here;
24845
- * device classes resolve their live `AcDevice` handle by `(brokerId, mac)`.
24959
+ * thread the handle in as a constructor arg. Like the Ecowitt / Dreame addon's
24960
+ * facade resolver, we keep it simple and in-process: the AC device's integration
24961
+ * manager owns the connection surface per `connectionKey` (the device's own
24962
+ * stableId) and publishes it here; the AC's accessory children resolve their live
24963
+ * `AcDevice` handle by `(connectionKey, mac)`.
24846
24964
  */
24847
24965
  var GreeConnectionResolver = class {
24848
24966
  #surfaces = /* @__PURE__ */ new Map();
24849
- /** Publish or remove the connection surface for a broker id. `null` removes. */
24850
- set(brokerId, surface) {
24967
+ /**
24968
+ * Per-connection "surface (re)published" subscribers. An AC accessory child
24969
+ * activates from `restoreDevices` BEFORE its parent's manager finishes the
24970
+ * async bind, so `getDevice` is null at `onActivate` time and its
24971
+ * `stateChanged` listener never attaches → the climate slice stays frozen at
24972
+ * cold-start (`lastFetchedAt: 0`, no temperature). Children subscribe here and
24973
+ * (re)attach + recompute once the handle is published. A plain `Set` has no
24974
+ * listener cap. Mirrors the Ecowitt facade-resolver fan-out.
24975
+ */
24976
+ #subs = /* @__PURE__ */ new Map();
24977
+ /** Publish or remove the connection surface for a connection key. `null` removes. */
24978
+ set(connectionKey, surface) {
24851
24979
  if (surface === null) {
24852
- this.#surfaces.delete(brokerId);
24980
+ this.#surfaces.delete(connectionKey);
24853
24981
  return;
24854
24982
  }
24855
- this.#surfaces.set(brokerId, surface);
24983
+ this.#surfaces.set(connectionKey, surface);
24984
+ const subs = this.#subs.get(connectionKey);
24985
+ if (subs) for (const cb of subs) try {
24986
+ cb();
24987
+ } catch {}
24988
+ }
24989
+ /** Subscribe to surface (re)publish for a connection key. Returns unsubscribe. */
24990
+ onSurface(connectionKey, cb) {
24991
+ let set = this.#subs.get(connectionKey);
24992
+ if (!set) {
24993
+ set = /* @__PURE__ */ new Set();
24994
+ this.#subs.set(connectionKey, set);
24995
+ }
24996
+ set.add(cb);
24997
+ return () => {
24998
+ const current = this.#subs.get(connectionKey);
24999
+ current?.delete(cb);
25000
+ if (current && current.size === 0) this.#subs.delete(connectionKey);
25001
+ };
24856
25002
  }
24857
- /** The discovery rows for a broker id, or empty when unknown. */
24858
- discovered(brokerId) {
24859
- return this.#surfaces.get(brokerId)?.discovered ?? [];
25003
+ /** The bind-scan rows for a connection key, or empty when unknown. */
25004
+ discovered(connectionKey) {
25005
+ return this.#surfaces.get(connectionKey)?.discovered ?? [];
24860
25006
  }
24861
- /** True when the broker has a published surface (i.e. its scope is active). */
24862
- has(brokerId) {
24863
- return this.#surfaces.has(brokerId);
25007
+ /** True when the connection has a published surface (i.e. it is bound). */
25008
+ has(connectionKey) {
25009
+ return this.#surfaces.has(connectionKey);
24864
25010
  }
24865
- /** The active broker ids (one entry per published surface). */
25011
+ /** The active connection keys (one entry per published surface). */
24866
25012
  list() {
24867
25013
  return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
24868
25014
  }
24869
25015
  /**
24870
- * Resolve the bound {@link AcDevice} handle for a `(brokerId, mac)` pair, or
24871
- * null when the broker is unknown or the AC has not been bound.
25016
+ * Resolve the bound {@link AcDevice} handle for a `(connectionKey, mac)` pair,
25017
+ * or null when the connection is unknown or the AC has not been bound.
24872
25018
  */
24873
- getDevice(brokerId, mac) {
24874
- return this.#surfaces.get(brokerId)?.handles.get(macKey$2(mac)) ?? null;
25019
+ getDevice(connectionKey, mac) {
25020
+ return this.#surfaces.get(connectionKey)?.handles.get(macKey(mac)) ?? null;
24875
25021
  }
24876
25022
  /** Remove all registered surfaces (called on full shutdown). */
24877
25023
  clear() {
24878
25024
  this.#surfaces.clear();
25025
+ this.#subs.clear();
24879
25026
  }
24880
25027
  };
24881
- /** The single in-process per-broker connection resolver shared between the
24882
- * manager and device instances. */
25028
+ /** The single in-process per-connection resolver shared between the AC device's
25029
+ * manager and its accessory children. */
24883
25030
  var greeConnections = new GreeConnectionResolver();
24884
25031
  //#endregion
24885
25032
  //#region src/gree-integration-manager.ts
24886
25033
  function defaultFacade(config) {
24887
25034
  return new Nodegree(toNodegreeOptions(config));
24888
25035
  }
24889
- var DEFAULT_POLL_INTERVAL_MS = 3e4;
25036
+ var DEFAULT_POLL_INTERVAL_MS = 1e4;
24890
25037
  /**
24891
- * Wraps exactly one `@apocaliss92/nodegree` facade (= one LAN discovery scope)
24892
- * with observable status + lifecycle. {@link start} runs a discovery scan and
24893
- * binds (creates) an {@link AcDevice} handle per found AC, publishing the
24894
- * connection surface on the shared resolver and starting per-AC polling.
24895
- * {@link stop} closes the facade; {@link applyConnection} does both atomically
24896
- * when the operator changes the scope settings.
25038
+ * Wraps exactly one `@apocaliss92/nodegree` facade bound to a SINGLE Gree AC
25039
+ * (standalone mode the connection lives on the AC device). {@link start} runs a
25040
+ * DIRECTED bind scan aimed at the configured `host` (falling back to the
25041
+ * `broadcastAddr` / global broadcast), matches the responder for that host,
25042
+ * binds (creates) its {@link AcDevice} handle, publishes the connection surface
25043
+ * on the shared resolver keyed by the device's `connectionKey`, and starts
25044
+ * polling. {@link stop} closes the facade; {@link applyConnection} does both
25045
+ * atomically when the operator changes the connection.
24897
25046
  *
24898
- * Mirrors `DreameIntegrationManager`. Binding is best-effort per AC so one
24899
- * offline unit does not fail the whole scope.
25047
+ * {@link bindOnce} is the create-time helper: a static one-shot directed scan
25048
+ * that returns the AC's durable identity (MAC/ip/name) WITHOUT holding a handle,
25049
+ * so `onCreateDevice` can persist the identity before the device exists.
24900
25050
  */
24901
25051
  var GreeIntegrationManager = class {
24902
25052
  #id;
@@ -24908,6 +25058,8 @@ var GreeIntegrationManager = class {
24908
25058
  #surfaceSink;
24909
25059
  #pollIntervalMs;
24910
25060
  #makeFacade;
25061
+ /** When set, only the responder for this MAC is bound (standalone: one AC). */
25062
+ #expectMac;
24911
25063
  #facade = null;
24912
25064
  #handles = /* @__PURE__ */ new Map();
24913
25065
  #status = "disconnected";
@@ -24924,6 +25076,7 @@ var GreeIntegrationManager = class {
24924
25076
  this.#surfaceSink = options.surfaceSink;
24925
25077
  this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
24926
25078
  this.#makeFacade = options.makeFacade ?? defaultFacade;
25079
+ this.#expectMac = options.expectMac !== void 0 ? macKey(options.expectMac) : null;
24927
25080
  }
24928
25081
  /** The connection settings the manager is currently configured with. */
24929
25082
  getConnection() {
@@ -24937,6 +25090,7 @@ var GreeIntegrationManager = class {
24937
25090
  kind: "gree",
24938
25091
  status: this.#status,
24939
25092
  info: {
25093
+ host: this.#connection.host,
24940
25094
  broadcastAddr: this.#connection.broadcastAddr,
24941
25095
  deviceCount: this.#deviceCount
24942
25096
  },
@@ -24945,14 +25099,14 @@ var GreeIntegrationManager = class {
24945
25099
  };
24946
25100
  }
24947
25101
  /**
24948
- * Build the facade, run a discovery scan, and bind one handle per found AC
24949
- * (starting per-AC polling). Publishes the connection surface. Sets `connected`
24950
- * on success (even with zero ACs an empty subnet is not an error). Surfaces a
24951
- * clear bind error on {@link GreeAuthError}.
25102
+ * Build the facade, run a DIRECTED bind scan (aimed at `host`), bind the
25103
+ * responder for the expected AC, and publish the connection surface. Sets
25104
+ * `connected` on a successful bind. Surfaces a clear bind error on
25105
+ * {@link GreeAuthError}.
24952
25106
  */
24953
25107
  async start() {
24954
25108
  if (this.#facade !== null) {
24955
- this.#logger.warn("GreeIntegrationManager: start() called while running — stop first", { tags: { brokerId: this.#id } });
25109
+ this.#logger.warn("GreeIntegrationManager: start() called while running — stop first", { tags: { connectionKey: this.#id } });
24956
25110
  return;
24957
25111
  }
24958
25112
  this.#status = "connecting";
@@ -24961,18 +25115,19 @@ var GreeIntegrationManager = class {
24961
25115
  this.#facade = facade;
24962
25116
  try {
24963
25117
  const discovered = await facade.discover(this.#discoverOpts());
25118
+ const matched = this.#selectResponders(discovered);
24964
25119
  const handles = /* @__PURE__ */ new Map();
24965
- for (const dev of discovered) try {
25120
+ for (const dev of matched) try {
24966
25121
  const ac = await facade.createAc({
24967
25122
  ip: dev.ip,
24968
25123
  port: dev.port,
24969
25124
  mac: dev.mac
24970
25125
  });
24971
25126
  if (this.#pollIntervalMs > 0) ac.startPolling(this.#pollIntervalMs);
24972
- handles.set(macKey$2(dev.mac), ac);
25127
+ handles.set(macKey(dev.mac), ac);
24973
25128
  } catch (err) {
24974
25129
  this.#logger.warn("GreeIntegrationManager: bind (createAc) failed", {
24975
- tags: { brokerId: this.#id },
25130
+ tags: { connectionKey: this.#id },
24976
25131
  meta: {
24977
25132
  mac: dev.mac,
24978
25133
  ip: dev.ip,
@@ -24982,22 +25137,24 @@ var GreeIntegrationManager = class {
24982
25137
  }
24983
25138
  this.#handles = handles;
24984
25139
  const surface = {
24985
- discovered,
25140
+ discovered: matched,
24986
25141
  handles
24987
25142
  };
24988
25143
  this.#surfaceSink.set(this.#id, surface);
24989
25144
  this.#deviceCount = handles.size;
24990
- this.#status = "connected";
24991
- this.#error = null;
25145
+ this.#status = handles.size > 0 ? "connected" : "error";
25146
+ this.#error = handles.size > 0 ? null : "AC not reachable — bind returned no handle";
24992
25147
  this.#lastCheckedAt = Date.now();
24993
- this.#onConnected(this.#id);
24994
- this.#logger.info("GreeIntegrationManager: connected", {
24995
- tags: { brokerId: this.#id },
24996
- meta: {
24997
- discovered: discovered.length,
24998
- bound: handles.size
24999
- }
25000
- });
25148
+ if (handles.size > 0) {
25149
+ this.#onConnected(this.#id);
25150
+ this.#logger.info("GreeIntegrationManager: connected", {
25151
+ tags: { connectionKey: this.#id },
25152
+ meta: {
25153
+ discovered: discovered.length,
25154
+ bound: handles.size
25155
+ }
25156
+ });
25157
+ } else this.#onDisconnected(this.#id);
25001
25158
  } catch (err) {
25002
25159
  this.#status = "error";
25003
25160
  this.#error = err instanceof GreeAuthError ? "device bind failed — check the AC is reachable" : errMsg(err);
@@ -25005,7 +25162,7 @@ var GreeIntegrationManager = class {
25005
25162
  this.#surfaceSink.set(this.#id, null);
25006
25163
  this.#onDisconnected(this.#id);
25007
25164
  this.#logger.warn("GreeIntegrationManager: scan failed", {
25008
- tags: { brokerId: this.#id },
25165
+ tags: { connectionKey: this.#id },
25009
25166
  meta: { error: this.#error }
25010
25167
  });
25011
25168
  throw err;
@@ -25025,7 +25182,7 @@ var GreeIntegrationManager = class {
25025
25182
  await facade.close();
25026
25183
  } catch (err) {
25027
25184
  this.#logger.warn("GreeIntegrationManager: facade close failed", {
25028
- tags: { brokerId: this.#id },
25185
+ tags: { connectionKey: this.#id },
25029
25186
  meta: { error: errMsg(err) }
25030
25187
  });
25031
25188
  }
@@ -25037,534 +25194,108 @@ var GreeIntegrationManager = class {
25037
25194
  this.#connection = conn;
25038
25195
  await this.start();
25039
25196
  }
25197
+ /** Keep only the responder(s) this manager should bind: the one matching the
25198
+ * expected MAC when set, else the one matching the configured host, else all. */
25199
+ #selectResponders(discovered) {
25200
+ if (this.#expectMac !== null) return discovered.filter((d) => macKey(d.mac) === this.#expectMac);
25201
+ const host = this.#connection.host.trim();
25202
+ if (host.length > 0) {
25203
+ const byHost = discovered.filter((d) => d.ip === host);
25204
+ if (byHost.length > 0) return byHost;
25205
+ }
25206
+ return [...discovered];
25207
+ }
25040
25208
  #discoverOpts() {
25041
25209
  const out = { timeoutMs: this.#connection.timeoutMs };
25042
- if (this.#connection.broadcastAddr.length > 0) out.broadcastAddr = this.#connection.broadcastAddr;
25210
+ const broadcast = resolveBroadcastTarget(this.#connection);
25211
+ if (broadcast.length > 0) out.broadcastAddr = broadcast;
25043
25212
  return out;
25044
25213
  }
25045
25214
  };
25046
- /** True when two configs are the "same connection" (no facade rebuild needed). */
25047
- function sameConnection(a, b) {
25048
- if (a === null || b === null) return false;
25049
- return a.broadcastAddr === b.broadcastAddr && a.timeoutMs === b.timeoutMs && a.retries === b.retries && a.encryption === b.encryption;
25215
+ /**
25216
+ * Resolve the directed-scan target for a connection: the explicit
25217
+ * `broadcastAddr` when set, else the AC `host` (unicast-directed scan), else the
25218
+ * empty string (library default global broadcast).
25219
+ */
25220
+ function resolveBroadcastTarget(connection) {
25221
+ const broadcast = connection.broadcastAddr.trim();
25222
+ if (broadcast.length > 0) return broadcast;
25223
+ return connection.host.trim();
25050
25224
  }
25051
- //#endregion
25052
- //#region src/gree-broker-registry.ts
25053
25225
  /**
25054
- * Manages N live Gree discovery scopes ("brokers"), each backed by a
25055
- * {@link GreeIntegrationManager}. Allocates stable ids (`gree_001`, …), supports
25056
- * CRUD, an integration FK index for cascade-delete, and lifecycle helpers.
25057
- * Mirrors `DreoBrokerRegistry`.
25226
+ * Create-time one-shot directed bind: build a throwaway facade, run a directed
25227
+ * scan (aimed at the connection's `host`/`broadcastAddr`), bind the matching
25228
+ * responder to prove the AC is reachable and learn its durable identity, then
25229
+ * close the facade. Returns the AC's identity (MAC/ip/name/model). Throws when no
25230
+ * AC responds or the bind fails — surfaced to the operator in the Add modal.
25231
+ *
25232
+ * The returned identity is what {@link import('./config.js').greeAcDeviceSchema}
25233
+ * persists on the new device; the live per-device manager re-binds on activate.
25234
+ */
25235
+ /**
25236
+ * One-shot LAN broadcast scan for Gree ACs — the network-discovery pass. Builds a
25237
+ * throwaway facade from default (or provided) UDP settings, runs the broadcast,
25238
+ * and returns every responder. No bind, no persistence. The provider's
25239
+ * `discoverDevices()` maps the responders to adoption candidates.
25058
25240
  */
25059
- var GreeBrokerRegistry = class {
25060
- #logger;
25061
- #onBrokerConnected;
25062
- #onBrokerDisconnected;
25063
- #makeManager;
25064
- #managers = /* @__PURE__ */ new Map();
25065
- #integrationToBroker = /* @__PURE__ */ new Map();
25066
- #nextId = 1;
25067
- constructor(logger, deps = {}) {
25068
- this.#logger = logger;
25069
- this.#onBrokerConnected = deps.onBrokerConnected ?? (() => void 0);
25070
- this.#onBrokerDisconnected = deps.onBrokerDisconnected ?? (() => void 0);
25071
- this.#makeManager = deps.makeManager ?? ((opts) => new GreeIntegrationManager({
25072
- id: opts.id,
25073
- name: opts.name,
25074
- connection: opts.connection,
25075
- logger: opts.logger,
25076
- onConnected: opts.onConnected,
25077
- onDisconnected: opts.onDisconnected,
25078
- surfaceSink: greeConnections
25079
- }));
25241
+ async function discoverGreeDevices(input) {
25242
+ const connection = settingsToGreeConfig({
25243
+ ...input.broadcastAddress ? { broadcastAddr: input.broadcastAddress } : {},
25244
+ ...input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}
25245
+ });
25246
+ const facade = (input.makeFacade ?? defaultFacade)(connection);
25247
+ try {
25248
+ const broadcast = resolveBroadcastTarget(connection);
25249
+ const opts = { timeoutMs: connection.timeoutMs };
25250
+ if (broadcast.length > 0) opts.broadcastAddr = broadcast;
25251
+ return await facade.discover(opts);
25252
+ } finally {
25253
+ await facade.close().catch(() => void 0);
25080
25254
  }
25081
- /** Restore persisted broker entries on boot (best-effort per manager). */
25082
- async restore(entries) {
25083
- for (const entry of entries) this.#seedCounter(entry.id);
25084
- for (const entry of entries) try {
25085
- await this.#startManager(entry);
25086
- } catch (err) {
25087
- this.#logger.warn("GreeBrokerRegistry: failed to restore manager", {
25088
- tags: { brokerId: entry.id },
25089
- meta: { error: errMsg(err) }
25090
- });
25091
- }
25092
- }
25093
- /** Stop all managers and clear state. */
25094
- async shutdown() {
25095
- const ids = Array.from(this.#managers.keys());
25096
- await Promise.all(ids.map(async (id) => {
25097
- try {
25098
- await this.#managers.get(id)?.stop();
25099
- } catch (err) {
25100
- this.#logger.warn("GreeBrokerRegistry: shutdown stop failed", {
25101
- tags: { brokerId: id },
25102
- meta: { error: errMsg(err) }
25103
- });
25104
- }
25105
- }));
25106
- this.#managers.clear();
25107
- this.#integrationToBroker.clear();
25108
- greeConnections.clear();
25109
- }
25110
- setOnBrokerConnected(cb) {
25111
- this.#onBrokerConnected = cb;
25112
- }
25113
- setOnBrokerDisconnected(cb) {
25114
- this.#onBrokerDisconnected = cb;
25115
- }
25116
- async createEntry(name, connection, opts = {}) {
25117
- const id = this.#allocateId();
25118
- const entry = {
25119
- id,
25120
- name,
25121
- connection,
25122
- ...opts.integrationId !== void 0 ? { integrationId: opts.integrationId } : {}
25123
- };
25124
- await this.#startManager(entry);
25125
- if (opts.integrationId !== void 0) this.#integrationToBroker.set(opts.integrationId, id);
25126
- return entry;
25127
- }
25128
- async removeEntry(id) {
25129
- const mgr = this.#managers.get(id);
25130
- if (!mgr) throw new Error(`GreeBrokerRegistry: unknown broker id "${id}"`);
25131
- for (const [integrationId, brokerId] of this.#integrationToBroker.entries()) if (brokerId === id) this.#integrationToBroker.delete(integrationId);
25132
- this.#managers.delete(id);
25255
+ }
25256
+ async function bindOnce(input) {
25257
+ const { connection, logger } = input;
25258
+ const facade = (input.makeFacade ?? defaultFacade)(connection);
25259
+ try {
25260
+ const broadcast = resolveBroadcastTarget(connection);
25261
+ const opts = { timeoutMs: connection.timeoutMs };
25262
+ if (broadcast.length > 0) opts.broadcastAddr = broadcast;
25263
+ const discovered = await facade.discover(opts);
25264
+ const host = connection.host.trim();
25265
+ const target = host.length > 0 ? discovered.find((d) => d.ip === host) ?? discovered[0] : discovered[0];
25266
+ if (target === void 0) throw new Error(host.length > 0 ? `no Gree AC responded at ${host}` : "no Gree AC responded to the discovery scan");
25267
+ const ac = await facade.createAc({
25268
+ ip: target.ip,
25269
+ port: target.port,
25270
+ mac: target.mac
25271
+ });
25133
25272
  try {
25134
- await mgr.stop();
25135
- } catch (err) {
25136
- this.#logger.warn("GreeBrokerRegistry: removeEntry stop failed", {
25137
- tags: { brokerId: id },
25138
- meta: { error: errMsg(err) }
25139
- });
25140
- }
25141
- }
25142
- async updateEntry(id, connection) {
25143
- const mgr = this.#managers.get(id);
25144
- if (!mgr) throw new Error(`GreeBrokerRegistry: unknown broker id "${id}"`);
25145
- await mgr.applyConnection(connection);
25273
+ ac.stopPolling();
25274
+ } catch {}
25146
25275
  return {
25147
- id,
25148
- name: mgr.getInfo().name,
25149
- connection
25276
+ mac: target.mac,
25277
+ ip: target.ip,
25278
+ port: target.port,
25279
+ name: target.name.length > 0 ? target.name : target.mac,
25280
+ ...target.model !== void 0 ? { model: target.model } : {}
25150
25281
  };
25151
- }
25152
- linkIntegration(integrationId, brokerId) {
25153
- this.#integrationToBroker.set(integrationId, brokerId);
25154
- }
25155
- getBrokerIdByIntegrationId(integrationId) {
25156
- const brokerId = this.#integrationToBroker.get(integrationId);
25157
- if (brokerId === void 0) throw new Error(`GreeBrokerRegistry: no broker linked for integration id "${integrationId}"`);
25158
- return brokerId;
25159
- }
25160
- list() {
25161
- return Array.from(this.#managers.values()).map((m) => m.getInfo());
25162
- }
25163
- get(id) {
25164
- return this.#managers.get(id)?.getInfo() ?? null;
25165
- }
25166
- getConnection(id) {
25167
- return this.#managers.get(id)?.getConnection() ?? null;
25168
- }
25169
- size() {
25170
- return this.#managers.size;
25171
- }
25172
- connectedCount() {
25173
- let count = 0;
25174
- for (const mgr of this.#managers.values()) if (mgr.getInfo().status === "connected") count++;
25175
- return count;
25176
- }
25177
- async #startManager(entry) {
25178
- if (this.#managers.has(entry.id)) {
25179
- if (entry.integrationId !== void 0) this.#integrationToBroker.set(entry.integrationId, entry.id);
25180
- return;
25181
- }
25182
- const mgr = this.#makeManager({
25183
- id: entry.id,
25184
- name: entry.name,
25185
- connection: entry.connection,
25186
- logger: this.#logger,
25187
- onConnected: (id) => this.#onBrokerConnected(id),
25188
- onDisconnected: (id) => this.#onBrokerDisconnected(id)
25189
- });
25190
- this.#managers.set(entry.id, mgr);
25191
- if (entry.integrationId !== void 0) this.#integrationToBroker.set(entry.integrationId, entry.id);
25192
- await mgr.start();
25193
- }
25194
- #allocateId() {
25195
- const id = `gree_${String(this.#nextId).padStart(3, "0")}`;
25196
- this.#nextId++;
25197
- return id;
25198
- }
25199
- #seedCounter(id) {
25200
- const match = /^gree_(\d+)$/.exec(id);
25201
- if (match === null || match[1] === void 0) return;
25202
- const n = parseInt(match[1], 10);
25203
- if (!isNaN(n)) this.#nextId = Math.max(this.#nextId, n + 1);
25204
- }
25205
- };
25206
- //#endregion
25207
- //#region src/gree-broker-provider.ts
25208
- /** Kind tag registered by this provider — matches the `listProviders` entry. */
25209
- var GREE_KIND = "gree";
25210
- /**
25211
- * Construct the `broker` cap provider for the Gree addon. Pure builder: all
25212
- * side-effecting deps are injected so the returned object is unit-testable.
25213
- * Mirrors `buildDreoBrokerProvider`.
25214
- *
25215
- * Gree carries no secret — a "broker" is just a LAN discovery scope — so the
25216
- * settings getters expose the connection verbatim (no password redaction).
25217
- */
25218
- function buildGreeBrokerProvider(deps) {
25219
- const { ownerAddonId, registry, getBrokers, persistBrokers, cascadeRemoveDevices, logger } = deps;
25220
- const owns = (id) => getBrokers().some((b) => b.id === id);
25221
- return {
25222
- list: async ({ kind }) => {
25223
- if (kind && kind !== GREE_KIND) return [];
25224
- return registry.list().map((info) => ({
25225
- ...info,
25226
- addonId: ownerAddonId
25227
- }));
25228
- },
25229
- get: async ({ id }) => {
25230
- const info = registry.get(id);
25231
- return info ? {
25232
- ...info,
25233
- addonId: ownerAddonId
25234
- } : null;
25235
- },
25236
- listProviders: async () => [{
25237
- addonId: ownerAddonId,
25238
- kinds: [{
25239
- kind: GREE_KIND,
25240
- label: "Gree"
25241
- }]
25242
- }],
25243
- add: async ({ kind, name, settings }) => {
25244
- if (kind !== GREE_KIND) throw new Error(`provider-gree: only kind '${GREE_KIND}' is handled here (got '${kind}')`);
25245
- const entry = await registry.createEntry(name, settingsToGreeConfig(settings));
25246
- await persistBrokers([...getBrokers(), entry]);
25247
- return { id: entry.id };
25248
- },
25249
- remove: async ({ id }) => {
25250
- if (!owns(id)) return;
25251
- await registry.removeEntry(id);
25252
- await cascadeRemoveDevices(id).catch((err) => {
25253
- logger.warn("gree: broker cascade-remove threw", {
25254
- tags: { brokerId: id },
25255
- meta: { error: errMsg(err) }
25256
- });
25257
- });
25258
- await persistBrokers(getBrokers().filter((b) => b.id !== id));
25259
- },
25260
- testConnection: async ({ id }) => {
25261
- if (!owns(id)) return {
25262
- ok: false,
25263
- error: "unknown broker"
25264
- };
25265
- const info = registry.get(id);
25266
- if (!info) return {
25267
- ok: false,
25268
- error: "unknown broker"
25269
- };
25270
- if (info.status === "connected") return {
25271
- ok: true,
25272
- latencyMs: info.lastCheckedAt ? Math.max(0, Date.now() - info.lastCheckedAt) : 0
25273
- };
25274
- return {
25275
- ok: false,
25276
- error: info.error ?? `status: ${info.status}`
25277
- };
25278
- },
25279
- getSettings: async ({ id }) => {
25280
- const entry = getBrokers().find((b) => b.id === id);
25281
- if (!entry) return null;
25282
- return { ...entry.connection };
25283
- },
25284
- setSettings: async ({ id, settings }) => {
25285
- if (!owns(id)) return;
25286
- if (!getBrokers().find((b) => b.id === id)) return;
25287
- const parsed = settingsToGreeConfig(settings);
25288
- const updated = await registry.updateEntry(id, parsed);
25289
- await persistBrokers(getBrokers().map((b) => b.id === id ? updated : b));
25290
- },
25291
- getBrokerConfig: async ({ id }) => {
25292
- const entry = getBrokers().find((b) => b.id === id);
25293
- if (!entry) return null;
25294
- return { ...entry.connection };
25295
- },
25296
- getSettingsSchema: async ({ kind }) => {
25297
- if (kind !== GREE_KIND) return null;
25298
- return buildConnectionFormSchema();
25299
- },
25300
- testSettings: async ({ kind, settings }) => {
25301
- if (kind !== GREE_KIND) return {
25302
- ok: false,
25303
- error: `unsupported kind: ${kind}`
25304
- };
25305
- try {
25306
- settingsToGreeConfig(settings);
25307
- return { ok: true };
25308
- } catch (err) {
25309
- return {
25310
- ok: false,
25311
- error: errMsg(err)
25312
- };
25313
- }
25314
- },
25315
- publish: async () => null,
25316
- subscribe: async () => ({ subscriptionId: "" }),
25317
- unsubscribe: async () => void 0,
25318
- getState: async () => null,
25319
- getStatus: async () => ({
25320
- brokerCount: registry.size(),
25321
- connectedCount: registry.connectedCount()
25322
- })
25323
- };
25324
- }
25325
- //#endregion
25326
- //#region src/gree-discovery.ts
25327
- /** Lowercase a MAC for stable keying (Gree echoes mixed-case MACs). */
25328
- function macKey$1(mac) {
25329
- return mac.toLowerCase();
25330
- }
25331
- /**
25332
- * Map Gree discovery rows to adoption candidates — one Thermostat-type candidate
25333
- * per AC. Every discovered AC is a valid candidate (there is no "unsupported"
25334
- * sub-kind — they are all air conditioners). Pure: no network I/O, no side
25335
- * effects. Mirrors `buildDreoCandidates`.
25336
- */
25337
- function buildGreeCandidates(input) {
25338
- const out = [];
25339
- for (const device of input.devices) {
25340
- const adoptedId = input.adopted.get(macKey$1(device.mac)) ?? null;
25341
- out.push({
25342
- childNativeId: macKey$1(device.mac),
25343
- name: device.name.length > 0 ? device.name : device.mac,
25344
- type: DeviceType.Thermostat,
25345
- status: "online",
25346
- metadata: {
25347
- serialNumber: device.mac,
25348
- ...device.model !== void 0 ? { model: device.model } : {}
25349
- },
25350
- alreadyAdopted: adoptedId !== null,
25351
- adoptedDeviceId: adoptedId,
25352
- capabilities: ["climate-control", "fan-control"]
25353
- });
25354
- }
25355
- return out;
25356
- }
25357
- //#endregion
25358
- //#region src/gree-adoption-provider.ts
25359
- /** The single granularity this provider advertises: whole-device adoption. */
25360
- var DEVICES_FILTER = {
25361
- id: "devices",
25362
- label: "Devices",
25363
- isDefault: true
25364
- };
25365
- function macKey(mac) {
25366
- return mac.toLowerCase();
25367
- }
25368
- /** Build a `mac → CamStack deviceId` map for a single broker. */
25369
- async function adoptedMapForBroker(brokerId, listAdoptedGree) {
25370
- const all = await listAdoptedGree();
25371
- const map = /* @__PURE__ */ new Map();
25372
- for (const device of all) if (device.config["system"] === "gree" && device.config["brokerId"] === brokerId) {
25373
- const mac = device.config["greeMac"];
25374
- if (typeof mac === "string") map.set(macKey(mac), device.id);
25375
- }
25376
- return map;
25377
- }
25378
- /**
25379
- * Construct the `device-adoption` cap provider for the Gree addon. Pure builder:
25380
- * all side-effecting deps are injected. Mirrors `buildDreoAdoptionProvider` with
25381
- * a single `devices` granularity (one Container per discovered AC).
25382
- */
25383
- function buildGreeAdoptionProvider(deps) {
25384
- const { registry, getBrokerIdForIntegration, listAdoptedGree, adoptDevice, removeDevice, findDeviceConfig, logger } = deps;
25385
- async function allCandidatesForBroker(brokerId) {
25386
- return buildGreeCandidates({
25387
- devices: registry.discovered(brokerId),
25388
- adopted: await adoptedMapForBroker(brokerId, listAdoptedGree)
25389
- });
25390
- }
25391
- function applyCandidateTextFilter(cands, filterText) {
25392
- let filtered = [...cands];
25393
- if (filterText === void 0) return filtered;
25394
- const { search, adoptedOnly, unadoptedOnly } = filterText;
25395
- if (search !== void 0 && search.length > 0) {
25396
- const lower = search.toLowerCase();
25397
- filtered = filtered.filter((c) => c.name.toLowerCase().includes(lower) || (c.metadata.model?.toLowerCase().includes(lower) ?? false) || c.childNativeId.toLowerCase().includes(lower));
25398
- }
25399
- if (adoptedOnly === true) filtered = filtered.filter((c) => c.alreadyAdopted);
25400
- if (unadoptedOnly === true) filtered = filtered.filter((c) => !c.alreadyAdopted);
25401
- return filtered;
25402
- }
25403
- return {
25404
- listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
25405
- listCandidates: async ({ integrationId, page, pageSize, filterText }) => {
25406
- const filtered = applyCandidateTextFilter(await allCandidatesForBroker(await getBrokerIdForIntegration(integrationId)), filterText);
25407
- const start = (page - 1) * pageSize;
25408
- return {
25409
- candidates: filtered.slice(start, start + pageSize),
25410
- totalCount: filtered.length,
25411
- page,
25412
- pageSize
25413
- };
25414
- },
25415
- getCandidate: async ({ integrationId, childNativeId }) => {
25416
- return (await allCandidatesForBroker(await getBrokerIdForIntegration(integrationId))).find((c) => c.childNativeId === childNativeId) ?? null;
25417
- },
25418
- getStatus: async () => {
25419
- try {
25420
- const brokers = registry.list();
25421
- let candidateCount = 0;
25422
- for (const broker of brokers) candidateCount += (await allCandidatesForBroker(broker.id)).length;
25423
- const adoptedCount = (await listAdoptedGree()).filter((d) => d.config["system"] === "gree").length;
25424
- return {
25425
- lastDiscoveryAt: Date.now(),
25426
- candidateCount,
25427
- adoptedCount,
25428
- lastError: null
25429
- };
25430
- } catch (err) {
25431
- logger.warn("gree adoption: getStatus failed", { meta: { error: errMsg(err) } });
25432
- return {
25433
- lastDiscoveryAt: null,
25434
- candidateCount: 0,
25435
- adoptedCount: 0,
25436
- lastError: errMsg(err)
25437
- };
25438
- }
25439
- },
25440
- refresh: async ({ integrationId }) => {
25441
- const brokerId = await getBrokerIdForIntegration(integrationId);
25442
- const candidateCount = (await allCandidatesForBroker(brokerId)).length;
25443
- const adoptedCount = (await listAdoptedGree()).filter((d) => d.config["system"] === "gree" && d.config["brokerId"] === brokerId).length;
25444
- return {
25445
- lastDiscoveryAt: Date.now(),
25446
- candidateCount,
25447
- adoptedCount,
25448
- lastError: null
25449
- };
25450
- },
25451
- adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
25452
- const brokerId = await getBrokerIdForIntegration(integrationId);
25453
- if (!registry.has(brokerId)) throw new Error(`gree adopt: broker ${brokerId} not active`);
25454
- const devices = registry.discovered(brokerId);
25455
- const adopted = [];
25456
- let failures = 0;
25457
- for (const childNativeId of childNativeIds) try {
25458
- const dev = devices.find((d) => macKey(d.mac) === macKey(childNativeId));
25459
- if (dev === void 0) {
25460
- logger.warn("gree adopt: device not found on broker — skipping", { meta: {
25461
- childNativeId,
25462
- brokerId
25463
- } });
25464
- failures++;
25465
- continue;
25466
- }
25467
- const name = perCandidate?.[childNativeId]?.name ?? dev.name ?? dev.mac;
25468
- const candidate = buildGreeCandidates({
25469
- devices: [dev],
25470
- adopted: /* @__PURE__ */ new Map()
25471
- })[0];
25472
- if (candidate === void 0) {
25473
- failures++;
25474
- continue;
25475
- }
25476
- const { deviceId, accessoryDeviceIds } = await adoptDevice({
25477
- greeMac: dev.mac,
25478
- greeIp: dev.ip,
25479
- brokerId,
25480
- integrationId,
25481
- type: candidate.type,
25482
- name
25483
- });
25484
- adopted.push({
25485
- childNativeId: macKey(childNativeId),
25486
- parentDeviceId: deviceId,
25487
- accessoryDeviceIds
25488
- });
25489
- } catch (err) {
25490
- logger.warn("gree adopt: failed to adopt device", { meta: {
25491
- childNativeId,
25492
- brokerId,
25493
- error: errMsg(err)
25494
- } });
25495
- failures++;
25496
- }
25497
- if (adopted.length === 0 && failures > 0) throw new Error(`gree adopt: all ${failures} adopt(s) failed`);
25498
- return { adopted };
25499
- },
25500
- release: async ({ camDeviceId }) => {
25501
- await removeDevice(camDeviceId);
25502
- },
25503
- resync: async ({ camDeviceId }) => {
25504
- const cfg = await findDeviceConfig(camDeviceId);
25505
- if (cfg === null) throw new Error(`gree resync: device ${camDeviceId} not found`);
25506
- if (cfg["system"] !== "gree") throw new Error(`gree resync: device ${camDeviceId} is not a Gree device`);
25507
- const brokerId = String(cfg["brokerId"]);
25508
- const greeMac = String(cfg["greeMac"]);
25509
- if (!registry.discovered(brokerId).some((d) => macKey(d.mac) === macKey(greeMac))) throw new Error(`gree resync: device ${greeMac} no longer present on broker ${brokerId}`);
25510
- return {
25511
- changed: false,
25512
- rebuiltChildren: 0
25513
- };
25514
- }
25515
- };
25516
- }
25517
- //#endregion
25518
- //#region src/gree-broker-device-cascade.ts
25519
- /**
25520
- * Remove every adopted Gree PARENT device (children cascade via the kernel)
25521
- * whose persisted config carries `{ system: 'gree', brokerId }`. Used when a
25522
- * broker is removed (broker.remove) and when its spawning integration is
25523
- * deleted. Best-effort per device; returns the count removed. Mirrors the
25524
- * Dreo cascade helper.
25525
- */
25526
- async function cascadeRemoveDevicesForBroker(input) {
25527
- const { reg, devices, addonId, brokerId, logger } = input;
25528
- let removed = 0;
25529
- for (const d of reg.getAllForAddon(addonId)) {
25530
- if (d.parentDeviceId !== null) continue;
25531
- const cfg = await devices.loadConfig(d.id).catch(() => ({}));
25532
- if (cfg["system"] !== "gree" || cfg["brokerId"] !== brokerId) continue;
25282
+ } catch (err) {
25283
+ const message = err instanceof GreeAuthError ? "device bind failed — check the AC is reachable and the encryption mode" : errMsg(err);
25284
+ logger.warn("gree bindOnce failed", { meta: {
25285
+ host: connection.host,
25286
+ error: message
25287
+ } });
25288
+ throw new Error(message);
25289
+ } finally {
25533
25290
  try {
25534
- await devices.remove(d.id);
25535
- removed += 1;
25536
- } catch (err) {
25537
- logger.warn("gree: broker cascade-remove failed", {
25538
- tags: { deviceId: d.id },
25539
- meta: {
25540
- brokerId,
25541
- error: errMsg(err)
25542
- }
25543
- });
25544
- }
25291
+ await facade.close();
25292
+ } catch {}
25545
25293
  }
25546
- return removed;
25547
25294
  }
25548
- //#endregion
25549
- //#region src/gree-broker-offline.ts
25550
- /**
25551
- * Set every Gree device belonging to `brokerId` to the requested online state.
25552
- * `devices` is the addon-scoped device list; every Gree top-level Container
25553
- * carries `{ system: 'gree', brokerId }` in its config blob.
25554
- *
25555
- * Churn-free: skips any device already in the target state. Returns the count of
25556
- * devices actually transitioned. Mirrors the Dreo offline helper.
25557
- */
25558
- function setBrokerDevicesOnline(devices, brokerId, online) {
25559
- let count = 0;
25560
- for (const dev of devices) {
25561
- if (dev.config.get("system") !== "gree") continue;
25562
- if (dev.config.get("brokerId") !== brokerId) continue;
25563
- if (dev.online === online) continue;
25564
- dev.markOnline(online);
25565
- count += 1;
25566
- }
25567
- return count;
25295
+ /** True when two configs are the "same connection" (no facade rebuild needed). */
25296
+ function sameConnection(a, b) {
25297
+ if (a === null || b === null) return false;
25298
+ return a.host === b.host && a.broadcastAddr === b.broadcastAddr && a.timeoutMs === b.timeoutMs && a.retries === b.retries && a.encryption === b.encryption;
25568
25299
  }
25569
25300
  //#endregion
25570
25301
  //#region src/gree-domain-mapping.ts
@@ -25812,14 +25543,27 @@ var FAN_COLD_START = {
25812
25543
  };
25813
25544
  /**
25814
25545
  * Persisted config every Gree AC accessory child carries. `greeMac` resolves the
25815
- * bound handle from the connection resolver; `brokerId` selects the discovery
25816
- * scope; `system`/`integrationId` are provenance.
25546
+ * bound handle from the connection resolver; `connectionKey` selects the parent
25547
+ * AC device's live connection (its own stableId); `system`/`integrationId` are
25548
+ * provenance.
25817
25549
  */
25818
25550
  var greeAcSchema = object({
25819
25551
  greeMac: string().min(1).describe("Gree AC MAC address"),
25820
- brokerId: string().min(1).describe("Registry broker id"),
25552
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
25821
25553
  system: literal("gree").optional(),
25822
- integrationId: string().optional()
25554
+ integrationId: string().optional(),
25555
+ /** Operator filter — HVAC modes exposed in the control UI (subset of the
25556
+ * model's supported modes). Empty / omitted ⇒ expose all. `off` is always
25557
+ * exposed (power). */
25558
+ enabledModes: array(string()).optional(),
25559
+ /** Operator filter — fan speeds exposed in the control UI (subset of
25560
+ * `GREE_FAN_MODES`). Empty / omitted ⇒ expose all. */
25561
+ enabledFanModes: array(string()).optional(),
25562
+ /** Operator filter — expose the vertical-swing toggle. Default true. */
25563
+ exposeVerticalSwing: boolean().optional(),
25564
+ /** Operator filter — expose the horizontal-swing toggle (model-permitting).
25565
+ * Default true. */
25566
+ exposeHorizontalSwing: boolean().optional()
25823
25567
  });
25824
25568
  /**
25825
25569
  * One Gree air conditioner as a CamStack `Thermostat`-type accessory child.
@@ -25859,22 +25603,21 @@ var GreeAcDevice = class extends BaseDevice$1 {
25859
25603
  */
25860
25604
  get features() {
25861
25605
  const caps = this.resolveAc()?.capabilities ?? getAcCapabilities();
25862
- const flags = [
25863
- DeviceFeature.ClimateFanMode,
25864
- DeviceFeature.ClimatePreset,
25865
- DeviceFeature.ClimateSwingVertical
25866
- ];
25867
- if (caps.horizontalSwing) flags.push(DeviceFeature.ClimateSwingHorizontal);
25606
+ const cfg = this.config.values;
25607
+ const flags = [DeviceFeature.ClimateFanMode, DeviceFeature.ClimatePreset];
25608
+ if (cfg.exposeVerticalSwing ?? true) flags.push(DeviceFeature.ClimateSwingVertical);
25609
+ if (caps.horizontalSwing && (cfg.exposeHorizontalSwing ?? true)) flags.push(DeviceFeature.ClimateSwingHorizontal);
25868
25610
  return flags;
25869
25611
  }
25870
25612
  greeMac;
25871
- brokerId;
25613
+ connectionKey;
25872
25614
  stateChangedUnsub = null;
25615
+ surfaceUnsub = null;
25873
25616
  constructor(ctx) {
25874
25617
  const persisted = greeAcSchema.parse(ctx.persistedConfig ?? {});
25875
25618
  super(ctx, greeAcSchema, { type: ctx.deviceMeta.type });
25876
25619
  this.greeMac = persisted.greeMac;
25877
- this.brokerId = persisted.brokerId;
25620
+ this.connectionKey = persisted.connectionKey;
25878
25621
  this.online = true;
25879
25622
  this.updateSourceInfo({
25880
25623
  id: this.greeMac,
@@ -25882,7 +25625,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25882
25625
  });
25883
25626
  }
25884
25627
  resolveAc() {
25885
- return greeConnections.getDevice(this.brokerId, this.greeMac);
25628
+ return greeConnections.getDevice(this.connectionKey, this.greeMac);
25886
25629
  }
25887
25630
  requireAc() {
25888
25631
  const ac = this.resolveAc();
@@ -25894,8 +25637,21 @@ var GreeAcDevice = class extends BaseDevice$1 {
25894
25637
  this.registerCaps();
25895
25638
  this.attachStateListener();
25896
25639
  this.recomputeSlices();
25640
+ this.surfaceUnsub = greeConnections.onSurface(this.connectionKey, () => {
25641
+ this.attachStateListener();
25642
+ this.recomputeSlices();
25643
+ });
25897
25644
  }
25898
25645
  async removeDevice() {
25646
+ this.detachStateListener();
25647
+ if (this.surfaceUnsub) {
25648
+ try {
25649
+ this.surfaceUnsub();
25650
+ } catch {}
25651
+ this.surfaceUnsub = null;
25652
+ }
25653
+ }
25654
+ detachStateListener() {
25899
25655
  if (this.stateChangedUnsub) {
25900
25656
  try {
25901
25657
  this.stateChangedUnsub();
@@ -25903,12 +25659,16 @@ var GreeAcDevice = class extends BaseDevice$1 {
25903
25659
  this.stateChangedUnsub = null;
25904
25660
  }
25905
25661
  }
25662
+ /** Attach the live `stateChanged` listener to the bound AC handle. Idempotent:
25663
+ * drops any prior listener first, so it is safe to call on every surface
25664
+ * (re)publish. No-op until the handle is actually bound. */
25906
25665
  attachStateListener() {
25907
25666
  const ac = this.resolveAc();
25908
25667
  if (!ac) {
25909
25668
  this.ctx.logger.debug("GreeAcDevice: handle not present; no live listener yet", { meta: { greeMac: this.greeMac } });
25910
25669
  return;
25911
25670
  }
25671
+ this.detachStateListener();
25912
25672
  const onState = () => {
25913
25673
  try {
25914
25674
  this.recomputeSlices();
@@ -25926,7 +25686,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25926
25686
  }
25927
25687
  registerCaps() {
25928
25688
  this.ctx.registerNativeCap(climateControlCapability, {
25929
- getStatus: async () => this.runtimeState.getCapState(CLIMATE_CAP) ?? CLIMATE_COLD_START,
25689
+ getStatus: async () => this.runtimeState.getCapState(CLIMATE_CAP) ?? this.applyClimateFilters(CLIMATE_COLD_START),
25930
25690
  setMode: async ({ mode }) => {
25931
25691
  const ac = this.requireAc();
25932
25692
  const libMode = capModeToLibMode(mode);
@@ -25973,7 +25733,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25973
25733
  await ac.setSwingHorizontal(boolToHorizontalSwing(on));
25974
25734
  }
25975
25735
  });
25976
- this.runtimeState.setCapState(CLIMATE_CAP, CLIMATE_COLD_START);
25736
+ this.runtimeState.setCapState(CLIMATE_CAP, this.applyClimateFilters(CLIMATE_COLD_START));
25977
25737
  this.ctx.registerNativeCap(fanControlCapability, {
25978
25738
  getStatus: async () => this.runtimeState.getCapState(FAN_CAP) ?? FAN_COLD_START,
25979
25739
  setPercentage: async ({ percentage }) => {
@@ -26020,7 +25780,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
26020
25780
  swingHorizontal: ac.capabilities.horizontalSwing ? horizontalSwingToBool(ac.swingHorizontal) : null,
26021
25781
  lastFetchedAt: now
26022
25782
  };
26023
- this.runtimeState.setCapState(CLIMATE_CAP, climateSlice);
25783
+ this.runtimeState.setCapState(CLIMATE_CAP, this.applyClimateFilters(climateSlice));
26024
25784
  const fanSlice = {
26025
25785
  percentage: fanSpeedToPercentage(ac.fanSpeed),
26026
25786
  percentageStep: GREE_FAN_PERCENTAGE_STEP,
@@ -26032,7 +25792,97 @@ var GreeAcDevice = class extends BaseDevice$1 {
26032
25792
  };
26033
25793
  this.runtimeState.setCapState(FAN_CAP, fanSlice);
26034
25794
  }
25795
+ /**
25796
+ * Apply the operator's per-AC exposure filters to a climate slice: narrow
25797
+ * `availableModes` / `availableFanModes` to the enabled subsets (empty ⇒ all;
25798
+ * `off` is always kept) and null out a swing axis the operator has hidden.
25799
+ * Pure — returns a new slice, never mutates the input.
25800
+ */
25801
+ applyClimateFilters(slice) {
25802
+ const cfg = this.config.values;
25803
+ const enabledModes = cfg.enabledModes;
25804
+ const enabledFanModes = cfg.enabledFanModes;
25805
+ const exposeV = cfg.exposeVerticalSwing ?? true;
25806
+ const exposeH = cfg.exposeHorizontalSwing ?? true;
25807
+ const availableModes = enabledModes && enabledModes.length > 0 ? slice.availableModes.filter((m) => m === "off" || enabledModes.includes(m)) : slice.availableModes;
25808
+ const availableFanModes = enabledFanModes && enabledFanModes.length > 0 ? slice.availableFanModes.filter((f) => enabledFanModes.includes(f)) : slice.availableFanModes;
25809
+ return {
25810
+ ...slice,
25811
+ availableModes,
25812
+ availableFanModes,
25813
+ swingVertical: exposeV ? slice.swingVertical : null,
25814
+ swingHorizontal: exposeH ? slice.swingHorizontal : null
25815
+ };
25816
+ }
25817
+ /**
25818
+ * Per-AC exposure form (device-details settings). Lets the operator pick
25819
+ * which modes / fan speeds / swing axes appear in the AC control UI. An empty
25820
+ * multiselect means "expose all". The horizontal-swing toggle is only offered
25821
+ * when the model advertises horizontal swing.
25822
+ */
25823
+ getSettingsUISchema() {
25824
+ const supportsHSwing = (this.resolveAc()?.capabilities ?? getAcCapabilities()).horizontalSwing;
25825
+ const cfg = this.config.values;
25826
+ const fields = [
25827
+ {
25828
+ type: "multiselect",
25829
+ key: "enabledModes",
25830
+ label: "Modes",
25831
+ description: "Modes shown in the control UI. Empty = all. “Off” is always available.",
25832
+ options: SUPPORTED_CAP_MODES.map((m) => ({
25833
+ value: m,
25834
+ label: titleCase(m)
25835
+ }))
25836
+ },
25837
+ {
25838
+ type: "multiselect",
25839
+ key: "enabledFanModes",
25840
+ label: "Fan speeds",
25841
+ description: "Fan speeds shown in the control UI. Empty = all.",
25842
+ options: GREE_FAN_MODES.map((f) => ({
25843
+ value: f,
25844
+ label: titleCase(f)
25845
+ }))
25846
+ },
25847
+ {
25848
+ type: "boolean",
25849
+ style: "switch",
25850
+ key: "exposeVerticalSwing",
25851
+ label: "Vertical swing",
25852
+ default: true
25853
+ }
25854
+ ];
25855
+ if (supportsHSwing) fields.push({
25856
+ type: "boolean",
25857
+ style: "switch",
25858
+ key: "exposeHorizontalSwing",
25859
+ label: "Horizontal swing",
25860
+ default: true
25861
+ });
25862
+ return hydrateSchema({ sections: [{
25863
+ id: "exposure",
25864
+ title: "Exposed controls",
25865
+ description: "Choose which modes, fan speeds and swing axes appear in the air-conditioner control UI.",
25866
+ fields
25867
+ }] }, {
25868
+ enabledModes: cfg.enabledModes ?? [],
25869
+ enabledFanModes: cfg.enabledFanModes ?? [],
25870
+ exposeVerticalSwing: cfg.exposeVerticalSwing ?? true,
25871
+ exposeHorizontalSwing: cfg.exposeHorizontalSwing ?? true
25872
+ });
25873
+ }
25874
+ async applySettingsPatch(patch) {
25875
+ const typed = greeAcSchema.partial().parse(patch);
25876
+ await this.config.setAll(typed);
25877
+ this.recomputeSlices();
25878
+ await this.refreshFeatures();
25879
+ }
26035
25880
  };
25881
+ /** 'fan_only' → 'Fan only', 'medium_low' → 'Medium low'. Pure. */
25882
+ function titleCase(value) {
25883
+ const spaced = value.replace(/_/g, " ");
25884
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
25885
+ }
26036
25886
  //#endregion
26037
25887
  //#region src/devices/gree-toggle-device.ts
26038
25888
  var SWITCH_CAP = "switch";
@@ -26042,9 +25892,10 @@ var SWITCH_COLD_START = {
26042
25892
  };
26043
25893
  /**
26044
25894
  * Persisted config every Gree toggle accessory child carries. Mirrors
26045
- * {@link greeAcSchema}: `greeMac` resolves the bound handle, `brokerId` selects
26046
- * the discovery scope, `system`/`integrationId` are provenance. `toggle` selects
26047
- * which of the four boolean device flags this child drives.
25895
+ * {@link greeAcSchema}: `greeMac` resolves the bound handle, `connectionKey`
25896
+ * selects the parent AC device's live connection, `system`/`integrationId` are
25897
+ * provenance. `toggle` selects which of the four boolean device flags this child
25898
+ * drives.
26048
25899
  */
26049
25900
  var greeToggleSchema = object({
26050
25901
  toggle: _enum([
@@ -26054,7 +25905,7 @@ var greeToggleSchema = object({
26054
25905
  "freshAir"
26055
25906
  ]).describe("Which Gree boolean flag"),
26056
25907
  greeMac: string().min(1).describe("Gree AC MAC address"),
26057
- brokerId: string().min(1).describe("Registry broker id"),
25908
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
26058
25909
  system: literal("gree").optional(),
26059
25910
  integrationId: string().optional()
26060
25911
  });
@@ -26072,14 +25923,15 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26072
25923
  features = [];
26073
25924
  toggle;
26074
25925
  greeMac;
26075
- brokerId;
25926
+ connectionKey;
26076
25927
  stateChangedUnsub = null;
25928
+ surfaceUnsub = null;
26077
25929
  constructor(ctx) {
26078
25930
  const persisted = greeToggleSchema.parse(ctx.persistedConfig ?? {});
26079
25931
  super(ctx, greeToggleSchema, { type: ctx.deviceMeta.type });
26080
25932
  this.toggle = persisted.toggle;
26081
25933
  this.greeMac = persisted.greeMac;
26082
- this.brokerId = persisted.brokerId;
25934
+ this.connectionKey = persisted.connectionKey;
26083
25935
  this.online = true;
26084
25936
  this.updateSourceInfo({
26085
25937
  id: `${this.greeMac}:${this.toggle}`,
@@ -26087,7 +25939,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26087
25939
  });
26088
25940
  }
26089
25941
  resolveAc() {
26090
- return greeConnections.getDevice(this.brokerId, this.greeMac);
25942
+ return greeConnections.getDevice(this.connectionKey, this.greeMac);
26091
25943
  }
26092
25944
  requireAc() {
26093
25945
  const ac = this.resolveAc();
@@ -26125,8 +25977,21 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26125
25977
  this.registerCap();
26126
25978
  this.attachStateListener();
26127
25979
  this.recomputeSlice();
25980
+ this.surfaceUnsub = greeConnections.onSurface(this.connectionKey, () => {
25981
+ this.attachStateListener();
25982
+ this.recomputeSlice();
25983
+ });
26128
25984
  }
26129
25985
  async removeDevice() {
25986
+ this.detachStateListener();
25987
+ if (this.surfaceUnsub) {
25988
+ try {
25989
+ this.surfaceUnsub();
25990
+ } catch {}
25991
+ this.surfaceUnsub = null;
25992
+ }
25993
+ }
25994
+ detachStateListener() {
26130
25995
  if (this.stateChangedUnsub) {
26131
25996
  try {
26132
25997
  this.stateChangedUnsub();
@@ -26143,6 +26008,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26143
26008
  } });
26144
26009
  return;
26145
26010
  }
26011
+ this.detachStateListener();
26146
26012
  const onState = () => {
26147
26013
  try {
26148
26014
  this.recomputeSlice();
@@ -26187,61 +26053,129 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26187
26053
  //#endregion
26188
26054
  //#region src/devices/gree-container-device.ts
26189
26055
  /**
26190
- * Persisted config for a Gree Container (the parent of one AC). `greeMac`
26191
- * resolves the bound handle from the connection resolver; `greeIp` is the last
26192
- * known LAN address (re-bound on each scope start); `brokerId` selects the
26193
- * discovery scope; `integrationId` is stamped onto the child; `system`/`name`
26194
- * are provenance.
26195
- */
26196
- var greeContainerSchema = object({
26197
- greeMac: string().min(1).describe("Gree AC MAC address"),
26198
- greeIp: string().optional().describe("Last known LAN IP"),
26199
- brokerId: string().min(1).describe("Registry broker id"),
26200
- integrationId: string().optional(),
26201
- system: literal("gree").optional(),
26202
- name: string().optional()
26203
- });
26204
- /**
26205
- * Parent Container device for a single Gree AC. Owns no control caps itself — it
26206
- * declares `getAccessoryChildren()` so the kernel auto-spawns the single
26207
- * {@link GreeAcDevice} accessory child (Thermostat) and reconciles it across
26208
- * reboots. Mirrors `DreoContainerDevice`.
26056
+ * Standalone-mode parent Container device for a single Gree AC (Reolink /
26057
+ * Ecowitt pattern). The operator-supplied CONNECTION lives on THIS device's
26058
+ * config; the device OWNS exactly one live `@apocaliss92/nodegree` client (via a
26059
+ * {@link GreeIntegrationManager}) keyed on its own `connectionKey` and published
26060
+ * on the in-process {@link greeConnections} resolver so its AC + toggle accessory
26061
+ * children read the live bound handle. There is NO broker and NO device-adoption
26062
+ * cap.
26209
26063
  *
26210
- * A Gree AC = one Thermostat child carrying both `climate-control` and
26211
- * `fan-control` caps, plus presence-gated `DeviceType.Switch` children for the
26212
- * panel-light / X-Fan / health / fresh-air boolean flags (mirroring the HA Gree
26213
- * integration's separate switch entities). Capability presence is resolved from
26214
- * the bound handle's `AcCapabilities` (or model defaults when no live handle).
26064
+ * It owns no control caps itself it declares `getAccessoryChildren()` so the
26065
+ * kernel auto-spawns the single {@link GreeAcDevice} accessory child (Thermostat,
26066
+ * carrying `climate-control` + `fan-control` with the swing entities) plus
26067
+ * presence-gated {@link DeviceType.Switch} children for the panel-light / X-Fan /
26068
+ * health / fresh-air boolean flags (mirroring the HA Gree integration).
26069
+ * Capability presence is resolved from the bound handle's `AcCapabilities` (or
26070
+ * model defaults when no live handle).
26215
26071
  */
26216
26072
  var GreeContainerDevice = class extends BaseDevice$1 {
26217
26073
  features = [DeviceFeature.Resyncable];
26218
26074
  greeMac;
26219
- brokerId;
26075
+ connectionKey;
26220
26076
  integrationId;
26077
+ connection;
26078
+ manager = null;
26221
26079
  constructor(ctx) {
26222
- const cfg = greeContainerSchema.parse(ctx.persistedConfig ?? {});
26223
- super(ctx, greeContainerSchema, { type: DeviceType.Container });
26080
+ const cfg = greeAcDeviceSchema.parse(ctx.persistedConfig ?? {});
26081
+ super(ctx, greeAcDeviceSchema, { type: DeviceType.Container });
26224
26082
  this.greeMac = cfg.greeMac;
26225
- this.brokerId = cfg.brokerId;
26083
+ this.connectionKey = cfg.connectionKey;
26226
26084
  this.integrationId = cfg.integrationId;
26227
- this.online = greeConnections.getDevice(this.brokerId, this.greeMac) !== null;
26085
+ this.connection = cfg.connection;
26086
+ this.online = greeConnections.getDevice(this.connectionKey, this.greeMac) !== null;
26228
26087
  this.updateSourceInfo({
26229
26088
  id: this.greeMac,
26230
26089
  system: "gree"
26231
26090
  });
26232
26091
  }
26092
+ /** Adopt a pre-built manager (created by `onCreateDevice` so the live client
26093
+ * binds once at create time) — mirrors `EcowittGatewayDevice.adoptManager`. */
26094
+ adoptManager(manager) {
26095
+ this.manager = manager;
26096
+ }
26097
+ async onActivate() {
26098
+ await super.onActivate();
26099
+ if (this.manager === null) this.ensureManager().catch((err) => {
26100
+ this.ctx.logger.warn("Gree AC initial bind failed", { meta: {
26101
+ greeMac: this.greeMac,
26102
+ error: errMsg(err)
26103
+ } });
26104
+ });
26105
+ }
26106
+ async removeDevice() {
26107
+ const mgr = this.manager;
26108
+ this.manager = null;
26109
+ if (mgr) try {
26110
+ await mgr.stop();
26111
+ } catch (err) {
26112
+ this.ctx.logger.warn("Gree AC: client stop failed", { meta: {
26113
+ greeMac: this.greeMac,
26114
+ error: errMsg(err)
26115
+ } });
26116
+ }
26117
+ }
26118
+ async applySettingsPatch(patch) {
26119
+ if (!("connection" in patch)) return;
26120
+ const parsed = greeAcDeviceSchema.shape.connection.parse(patch["connection"]);
26121
+ await this.config.setAll({ connection: parsed });
26122
+ this.connection = parsed;
26123
+ const mgr = this.manager;
26124
+ if (mgr) try {
26125
+ await mgr.applyConnection(parsed);
26126
+ } catch (err) {
26127
+ this.ctx.logger.warn("Gree AC: applyConnection failed", { meta: {
26128
+ greeMac: this.greeMac,
26129
+ error: errMsg(err)
26130
+ } });
26131
+ }
26132
+ else await this.ensureManager().catch((err) => {
26133
+ this.ctx.logger.warn("Gree AC re-bind after settings change failed", { meta: {
26134
+ greeMac: this.greeMac,
26135
+ error: errMsg(err)
26136
+ } });
26137
+ });
26138
+ }
26139
+ /** Build + start the per-device nodegree client (if not already running). */
26140
+ async ensureManager() {
26141
+ if (this.manager !== null) return;
26142
+ const mgr = new GreeIntegrationManager({
26143
+ id: this.connectionKey,
26144
+ name: this.name,
26145
+ connection: this.connection,
26146
+ logger: this.ctx.logger,
26147
+ onConnected: () => this.setAcOnline(true),
26148
+ onDisconnected: () => this.setAcOnline(false),
26149
+ surfaceSink: greeConnections,
26150
+ expectMac: this.greeMac
26151
+ });
26152
+ this.manager = mgr;
26153
+ await mgr.start();
26154
+ }
26155
+ setAcOnline(online) {
26156
+ if (this.online === online) return;
26157
+ this.markOnline(online);
26158
+ this.setChildrenOnline(online).catch(() => {});
26159
+ }
26160
+ async setChildrenOnline(online) {
26161
+ const children = await this.ctx.devices.getChildren(this.id).catch(() => []);
26162
+ for (const child of children) {
26163
+ if (child.online === online) continue;
26164
+ child.markOnline(online);
26165
+ }
26166
+ }
26233
26167
  getAccessoryChildren() {
26234
26168
  return [{
26235
26169
  stableIdSuffix: "ac",
26236
26170
  meta: {
26237
- type: DeviceType.Thermostat,
26171
+ type: DeviceType.Climate,
26238
26172
  name: this.name,
26239
26173
  linkDeviceId: this.id,
26240
26174
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
26241
26175
  },
26242
26176
  config: {
26243
26177
  greeMac: this.greeMac,
26244
- brokerId: this.brokerId,
26178
+ connectionKey: this.connectionKey,
26245
26179
  system: "gree",
26246
26180
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
26247
26181
  },
@@ -26255,7 +26189,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26255
26189
  * defaults when no live handle is bound yet).
26256
26190
  */
26257
26191
  toggleChildren() {
26258
- const caps = greeConnections.getDevice(this.brokerId, this.greeMac)?.capabilities ?? DEFAULT_AC_CAPABILITIES;
26192
+ const caps = greeConnections.getDevice(this.connectionKey, this.greeMac)?.capabilities ?? DEFAULT_AC_CAPABILITIES;
26259
26193
  return [
26260
26194
  {
26261
26195
  toggle: "light",
@@ -26291,7 +26225,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26291
26225
  const childConfig = {
26292
26226
  toggle: d.toggle,
26293
26227
  greeMac: this.greeMac,
26294
- brokerId: this.brokerId,
26228
+ connectionKey: this.connectionKey,
26295
26229
  system: "gree",
26296
26230
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
26297
26231
  };
@@ -26306,284 +26240,135 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26306
26240
  };
26307
26241
  //#endregion
26308
26242
  //#region src/addon.ts
26309
- /** Default multi-broker config — a fresh install starts with no scopes. */
26310
- var DEFAULTS = { brokers: [] };
26311
26243
  /**
26312
- * Gree device-provider addon (multi-scope).
26244
+ * Gree air-conditioner device-provider addon — `mode: standalone` (the Reolink /
26245
+ * Ecowitt pattern). No broker, no device-adoption cap. An AC is added as a DEVICE
26246
+ * via the manual-creation form: the CONNECTION (host IP + optional broadcastAddr
26247
+ * + UDP tuning) lives on the AC DEVICE config. `onCreateDevice` runs a DIRECTED
26248
+ * bind against the given host to learn the AC's durable identity (MAC) and prove
26249
+ * reachability, then creates a {@link GreeContainerDevice} that OWNS its own live
26250
+ * `@apocaliss92/nodegree` client and fans out its Thermostat + toggle accessory
26251
+ * children (climate-control with swing entities + the panel-light / X-Fan /
26252
+ * health / fresh-air switches — all preserved from the broker model).
26313
26253
  *
26314
- * Wraps the `@apocaliss92/nodegree` local-UDP client. One registered LAN
26315
- * discovery scope = one "broker"; each adopted AC becomes a
26316
- * {@link DeviceType.Container} parent that fans out a single {@link DeviceType.Thermostat}
26317
- * accessory child carrying `climate-control` + `fan-control`. Modelled directly
26318
- * on the Dreo / Dreame device-provider template: `broker` + `device-adoption` cap
26319
- * providers for connection + adoption, the `device-provider` cap from the base
26320
- * class, and an in-process connection resolver (`greeConnections`) the device
26321
- * classes read.
26254
+ * `instanceMode: multiple` many ACs may be added. Placement: any-node — Gree is
26255
+ * LOCAL UDP; the addon runs on whichever node shares the AC's subnet.
26322
26256
  *
26323
- * Placement: any-node Gree is LOCAL UDP. Discovery (broadcast) and per-AC AES
26324
- * control packets must reach the AC on the LAN, so the addon is eligible to run
26325
- * on whichever node shares the AC's subnet (vs hub-only for the cloud providers).
26326
- * The operator points each scope's `broadcastAddr` at the right subnet.
26257
+ * LAN UDP-broadcast auto-discovery is implemented via the `device-provider`
26258
+ * discovery surface (`supportsDiscovery`/`discoverDevices`/`adoptDiscoveredDevice`)
26259
+ * the same one ONVIF uses and the aggregated "Scan network" modal fans out to.
26260
+ * Manual add-one-AC-by-IP (directed bind) remains available in parallel.
26327
26261
  */
26328
26262
  var GreeProviderAddon = class extends BaseDeviceProvider {
26329
26263
  addonId = "provider-gree";
26330
26264
  providerName = "Gree";
26331
26265
  deviceClasses = { [DeviceType.Container]: GreeContainerDevice };
26332
- registry = null;
26333
26266
  constructor() {
26334
- super({ ...DEFAULTS });
26267
+ super({});
26335
26268
  }
26336
- async onInitialize() {
26337
- const regs = await super.onInitialize();
26338
- this.registry = new GreeBrokerRegistry(this.ctx.logger);
26339
- this.registry.setOnBrokerConnected((brokerId) => {
26340
- this.ctx.logger.info("Gree: broker connected", { meta: { brokerId } });
26341
- this.setBrokerDevicesOnline(brokerId, true);
26269
+ async supportsDiscovery() {
26270
+ return true;
26271
+ }
26272
+ async getDiscoveryParamsSchema() {
26273
+ return buildDiscoveryParamsFormSchema();
26274
+ }
26275
+ async discoverDevices(input) {
26276
+ const broadcastAddress = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"].trim() : void 0;
26277
+ const timeoutMs = typeof input?.params?.["timeoutMs"] === "number" ? input.params["timeoutMs"] : void 0;
26278
+ const responders = await discoverGreeDevices({
26279
+ ...broadcastAddress ? { broadcastAddress } : {},
26280
+ ...timeoutMs ? { timeoutMs } : {}
26342
26281
  });
26343
- this.registry.setOnBrokerDisconnected((brokerId) => {
26344
- this.setBrokerDevicesOnline(brokerId, false);
26282
+ this.ctx.logger.info("Gree discovery complete", { meta: {
26283
+ count: responders.length,
26284
+ broadcastAddress: broadcastAddress ?? "local"
26285
+ } });
26286
+ return responders.map((d) => {
26287
+ const displayName = d.name.length > 0 ? d.name : d.mac;
26288
+ return {
26289
+ stableId: `gree:${macKey(d.mac)}`,
26290
+ type: DeviceType.Container,
26291
+ suggestedName: displayName,
26292
+ prefilledConfig: {
26293
+ name: displayName,
26294
+ host: d.ip,
26295
+ ...d.model !== void 0 ? { model: d.model } : {}
26296
+ }
26297
+ };
26345
26298
  });
26346
- await this.registry.restore(this.config.brokers);
26347
- this.ctx.logger.info("Gree: provider initialised", { meta: { brokerCount: this.config.brokers.length } });
26348
- await this.reconcileIntegrationsToBrokers();
26349
- this.subscribeIntegrationLifecycle();
26350
- return [
26351
- ...regs,
26352
- {
26353
- capability: brokerCapability,
26354
- provider: this.buildBrokerProvider()
26355
- },
26356
- {
26357
- capability: deviceAdoptionCapability,
26358
- provider: this.buildAdoptionProvider()
26359
- }
26360
- ];
26361
26299
  }
26362
- /** Reconcile the live registry against the persisted `brokers` array after a
26363
- * settings write (UI save or `broker.*` cap). Mirrors the Dreo addon. */
26364
- async onConfigChanged() {
26365
- const reg = this.registry;
26366
- if (!reg) return;
26367
- const persisted = this.config.brokers;
26368
- const liveIds = new Set(reg.list().map((b) => b.id));
26369
- const persistedIds = new Set(persisted.map((e) => e.id));
26370
- for (const liveId of liveIds) if (!persistedIds.has(liveId)) try {
26371
- await reg.removeEntry(liveId);
26372
- } catch (err) {
26373
- this.ctx.logger.warn("Gree onConfigChanged: removeEntry failed", { meta: {
26374
- brokerId: liveId,
26375
- error: errMsg(err)
26376
- } });
26377
- }
26378
- for (const entry of persisted) {
26379
- if (entry.id && liveIds.has(entry.id)) {
26380
- if (sameConnection(entry.connection, reg.getConnection(entry.id))) continue;
26381
- try {
26382
- await reg.updateEntry(entry.id, entry.connection);
26383
- } catch (err) {
26384
- this.ctx.logger.warn("Gree onConfigChanged: updateEntry failed", { meta: {
26385
- brokerId: entry.id,
26386
- error: errMsg(err)
26387
- } });
26388
- }
26389
- continue;
26390
- }
26391
- try {
26392
- const created = await reg.createEntry(entry.name, entry.connection);
26393
- if (created.id !== entry.id) {
26394
- const next = persisted.map((b) => b === entry ? {
26395
- ...b,
26396
- id: created.id
26397
- } : b);
26398
- await this.updateGlobalSettings({ brokers: next });
26399
- }
26400
- } catch (err) {
26401
- this.ctx.logger.warn("Gree onConfigChanged: failed to start new broker", { meta: {
26402
- brokerName: entry.name,
26403
- error: errMsg(err)
26404
- } });
26405
- }
26406
- }
26300
+ async adoptDiscoveredDevice(input) {
26301
+ return this.createDevice({
26302
+ type: DeviceType.Container,
26303
+ config: input.candidate.prefilledConfig
26304
+ });
26407
26305
  }
26408
- async onShutdown() {
26409
- try {
26410
- await this.registry?.shutdown();
26411
- } catch (err) {
26412
- this.ctx.logger.warn("Gree: provider shutdown error", { meta: { error: errMsg(err) } });
26413
- }
26414
- this.registry = null;
26415
- await super.onShutdown();
26306
+ async supportsManualCreation() {
26307
+ return true;
26416
26308
  }
26417
- /** Stable id, broker-scoped so two scopes exposing the same MAC don't collide. */
26418
- generateStableId(_type, config) {
26419
- return `gree:${String(config?.["brokerId"] ?? "unknown")}:${String(config?.["greeMac"] ?? Date.now())}`;
26420
- }
26421
- buildBrokerProvider() {
26422
- return buildGreeBrokerProvider({
26423
- ownerAddonId: this.ctx.id,
26424
- registry: this.requireRegistry(),
26425
- getBrokers: () => this.config.brokers,
26426
- persistBrokers: (brokers) => this.updateGlobalSettings({ brokers: [...brokers] }),
26427
- cascadeRemoveDevices: (brokerId) => this.cascadeRemoveDevicesForBroker(brokerId),
26309
+ async onGetCreationSchema(type) {
26310
+ if (type !== DeviceType.Container) return null;
26311
+ return buildConnectionFormSchema();
26312
+ }
26313
+ async onCreateDevice(type, config) {
26314
+ if (type !== DeviceType.Container) throw new Error(`Gree provider does not support device type: ${type}`);
26315
+ const name = typeof config["name"] === "string" ? config["name"].trim() : "";
26316
+ if (!name) throw new Error("Air conditioner name is required");
26317
+ const connection = settingsToGreeConfig(config);
26318
+ if (connection.host.trim().length === 0) throw new Error("Air conditioner IP address is required");
26319
+ const bound = await bindOnce({
26320
+ connection,
26428
26321
  logger: this.ctx.logger
26429
26322
  });
26430
- }
26431
- async cascadeRemoveDevicesForBroker(brokerId) {
26432
- const reg = this.ctx.kernel.deviceRegistry;
26433
- const devices = this.ctx.kernel.devices;
26434
- if (!reg || !devices) return;
26435
- await cascadeRemoveDevicesForBroker({
26436
- reg,
26437
- devices,
26438
- addonId: this.addonId,
26439
- brokerId,
26440
- logger: this.ctx.logger
26323
+ const connectionKey = `gree:${macKey(bound.mac)}`;
26324
+ const deviceConfig = {
26325
+ greeMac: macKey(bound.mac),
26326
+ greeIp: bound.ip,
26327
+ connectionKey,
26328
+ connection,
26329
+ system: "gree",
26330
+ name
26331
+ };
26332
+ const manager = new GreeIntegrationManager({
26333
+ id: connectionKey,
26334
+ name,
26335
+ connection,
26336
+ logger: this.ctx.logger,
26337
+ onConnected: () => void 0,
26338
+ onDisconnected: () => void 0,
26339
+ surfaceSink: greeConnections,
26340
+ expectMac: bound.mac
26441
26341
  });
26442
- }
26443
- /**
26444
- * Link each surviving integration (carrying `{ brokerId }` in its settings) to
26445
- * its broker so the generic `device-adoption` cap resolves the scope, and
26446
- * cascade-clean brokers (+ their adopted devices) whose spawning integration
26447
- * was deleted. Idempotent; runs on boot + on every integration lifecycle event.
26448
- * Guarded so a failure never fails init.
26449
- */
26450
- async reconcileIntegrationsToBrokers() {
26451
26342
  try {
26452
- const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id);
26453
- const surviving = new Set(mine.map((i) => i.id));
26454
- for (const integration of mine) {
26455
- const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
26456
- const brokerId = typeof settings["brokerId"] === "string" ? settings["brokerId"] : void 0;
26457
- if (brokerId === void 0) continue;
26458
- if (!this.config.brokers.some((b) => b.id === brokerId)) {
26459
- this.ctx.logger.warn("Gree integration→broker: linked broker not found", { meta: {
26460
- integrationId: integration.id,
26461
- brokerId
26462
- } });
26463
- continue;
26464
- }
26465
- this.requireRegistry().linkIntegration(integration.id, brokerId);
26466
- }
26467
- const toRemove = this.config.brokers.filter((b) => b.integrationId !== void 0 && !surviving.has(b.integrationId)).map((b) => b.id);
26468
- if (toRemove.length === 0) return;
26469
- for (const id of toRemove) try {
26470
- await this.requireRegistry().removeEntry(id);
26471
- await this.cascadeRemoveDevicesForBroker(id);
26472
- } catch (err) {
26473
- this.ctx.logger.warn("Gree integration→broker: broker cleanup failed", { meta: {
26474
- brokerId: id,
26475
- error: errMsg(err)
26476
- } });
26477
- }
26478
- const nextBrokers = this.config.brokers.filter((b) => !toRemove.includes(b.id));
26479
- await this.updateGlobalSettings({ brokers: nextBrokers });
26343
+ await manager.start();
26480
26344
  } catch (err) {
26481
- this.ctx.logger.warn("Gree integration→broker reconcile failed", { meta: { error: errMsg(err) } });
26345
+ this.ctx.logger.warn("Gree create: initial client start failed — device kept", { meta: {
26346
+ connectionKey,
26347
+ error: errMsg(err)
26348
+ } });
26482
26349
  }
26483
- }
26484
- subscribeIntegrationLifecycle() {
26485
- const handler = (event) => {
26486
- const addonId = event.data["addonId"];
26487
- if (typeof addonId === "string" && addonId !== this.ctx.id) return;
26488
- this.reconcileIntegrationsToBrokers();
26489
- };
26490
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
26491
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
26492
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
26493
- }
26494
- buildAdoptionProvider() {
26495
- return buildGreeAdoptionProvider({
26496
- registry: greeConnections,
26497
- logger: this.ctx.logger,
26498
- getBrokerIdForIntegration: async (id) => {
26499
- const brokerId = (await this.ctx.api.integrations.getSettings.query({ id }))["brokerId"];
26500
- if (typeof brokerId !== "string") throw new Error(`integration ${id} has no linked brokerId`);
26501
- return brokerId;
26502
- },
26503
- listAdoptedGree: async () => {
26504
- const reg = this.ctx.kernel.deviceRegistry;
26505
- const devices = this.ctx.kernel.devices;
26506
- if (!reg || !devices) return [];
26507
- const out = [];
26508
- for (const d of reg.getAllForAddon(this.addonId)) {
26509
- if (d.parentDeviceId !== null) continue;
26510
- const config = await devices.loadConfig(d.id).catch(() => ({}));
26511
- out.push({
26512
- id: d.id,
26513
- config
26514
- });
26515
- }
26516
- return out;
26517
- },
26518
- adoptDevice: async ({ greeMac, greeIp, brokerId, integrationId, name }) => {
26519
- const devices = this.ctx.kernel.devices;
26520
- if (!devices) throw new Error("gree adopt: kernel.devices unavailable");
26521
- const config = {
26522
- greeMac,
26523
- greeIp,
26524
- brokerId,
26525
- system: "gree",
26526
- integrationId,
26527
- name
26528
- };
26529
- const stableId = this.generateStableId(DeviceType.Container, config);
26530
- const device = await devices.create(stableId, GreeContainerDevice, config, null, {
26531
- type: DeviceType.Container,
26532
- name,
26533
- integrationId
26534
- });
26535
- const children = await devices.getChildren(device.id);
26536
- return {
26537
- deviceId: device.id,
26538
- accessoryDeviceIds: children.map((c) => c.id)
26539
- };
26540
- },
26541
- removeDevice: async (id) => {
26542
- await this.ctx.kernel.devices?.remove(id);
26350
+ return {
26351
+ meta: {
26352
+ type: DeviceType.Container,
26353
+ name
26543
26354
  },
26544
- findDeviceConfig: async (id) => {
26545
- const devices = this.ctx.kernel.devices;
26546
- if (!devices) return null;
26547
- return devices.loadConfig(id).catch(() => null);
26355
+ config: deviceConfig,
26356
+ onAfterCreate: async (device) => {
26357
+ if (device instanceof GreeContainerDevice) device.adoptManager(manager);
26548
26358
  }
26549
- });
26550
- }
26551
- globalSettingsSchema() {
26552
- return this.schema({ sections: [{
26553
- id: "gree-broker",
26554
- title: "Gree discovery scope",
26555
- description: "Gree scopes are managed in the External systems → Brokers tab.",
26556
- columns: 1,
26557
- fields: [{
26558
- type: "info",
26559
- key: "brokerHelp",
26560
- label: "Scopes are managed separately",
26561
- content: "This integration links to a Gree LAN discovery scope. Add, edit, or remove scopes from External systems → Brokers. Gree is local-only (UDP) — no cloud account is needed; a scope just stores the broadcast address + UDP settings."
26562
- }]
26563
- }] });
26564
- }
26565
- async supportsManualCreation() {
26566
- return false;
26567
- }
26568
- async onGetCreationSchema(_type) {
26569
- return null;
26570
- }
26571
- async onCreateDevice(_type, _config) {
26572
- throw new Error("Gree devices are adopted via LAN discovery, not created manually");
26573
- }
26574
- setBrokerDevicesOnline(brokerId, online) {
26575
- const reg = this.ctx.kernel.deviceRegistry;
26576
- if (!reg) return;
26577
- const n = setBrokerDevicesOnline(reg.getAllForAddon(this.addonId), brokerId, online);
26578
- if (n > 0) this.ctx.logger.info("Gree: broker devices " + (online ? "online" : "offline"), { meta: {
26579
- brokerId,
26580
- count: n
26581
- } });
26359
+ };
26582
26360
  }
26583
- requireRegistry() {
26584
- if (!this.registry) throw new Error("Gree provider not initialised");
26585
- return this.registry;
26361
+ /**
26362
+ * AC stableId derived from the AC's durable MAC identity (resolved by the
26363
+ * create-time bind), NOT a broker id. Re-adding the same physical AC reuses its
26364
+ * persisted row. Falls back to a timestamp only when no MAC is resolvable
26365
+ * (should not happen — `onCreateDevice` binds first).
26366
+ */
26367
+ generateStableId(_type, config) {
26368
+ const mac = config?.["greeMac"];
26369
+ if (typeof mac === "string" && mac.length > 0) return `gree:${macKey(mac)}`;
26370
+ return `gree:${Date.now()}`;
26586
26371
  }
26587
26372
  };
26588
26373
  //#endregion
26589
- export { GreeProviderAddon, greeAddonConfigSchema as _, boolToVerticalSwing as a, horizontalSwingToBool as c, oscillatingToVerticalSwing as d, percentageToFanSpeed as f, buildConnectionFormSchema as g, buildGreeCandidates as h, boolToHorizontalSwing as i, isAutoFan as l, verticalSwingToBool as m, GREE_FAN_PERCENTAGE_STEP as n, capModeToLibMode as o, swingToOscillating as p, SUPPORTED_CAP_MODES as r, fanSpeedToPercentage as s, ADVERTISED_CAP_MODES as t, libModeToCapMode as u, greeConfigSchema as v, toNodegreeOptions as y };
26374
+ export { DeviceType as C, GreeProviderAddon, toNodegreeOptions as S, sameConnection as _, boolToVerticalSwing as a, greeConfigSchema as b, horizontalSwingToBool as c, oscillatingToVerticalSwing as d, percentageToFanSpeed as f, resolveBroadcastTarget as g, bindOnce as h, boolToHorizontalSwing as i, isAutoFan as l, verticalSwingToBool as m, GREE_FAN_PERCENTAGE_STEP as n, capModeToLibMode as o, swingToOscillating as p, SUPPORTED_CAP_MODES as r, fanSpeedToPercentage as s, ADVERTISED_CAP_MODES as t, libModeToCapMode as u, buildConnectionFormSchema as v, settingsToGreeConfig as x, greeAcDeviceSchema as y };