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