@camstack/addon-provider-gree 0.1.8 → 0.1.9

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
@@ -14783,83 +14783,34 @@ var GetStateInputSchema = object({
14783
14783
  * HA: entity_id (returns the cached entity state). */
14784
14784
  key: string()
14785
14785
  });
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
- };
14786
+ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, BrokerInfoSchema$1.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
14787
+ kind: "mutation",
14788
+ auth: "admin"
14789
+ }), method(RemoveInputSchema, _void(), {
14790
+ kind: "mutation",
14791
+ auth: "admin"
14792
+ }), method(GetInputSchema, TestConnectionResultSchema, {
14793
+ kind: "mutation",
14794
+ auth: "admin"
14795
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
14796
+ id: string(),
14797
+ settings: SettingsRecordSchema$1
14798
+ }), _void(), {
14799
+ kind: "mutation",
14800
+ auth: "admin"
14801
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
14802
+ kind: "mutation",
14803
+ auth: "admin"
14804
+ }), method(PublishInputSchema, unknown(), {
14805
+ kind: "mutation",
14806
+ auth: "admin"
14807
+ }), method(SubscribeInputSchema, SubscribeResultSchema, {
14808
+ kind: "mutation",
14809
+ auth: "admin"
14810
+ }), method(UnsubscribeInputSchema, _void(), {
14811
+ kind: "mutation",
14812
+ auth: "admin"
14813
+ }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
14863
14814
  DeviceType.Camera;
14864
14815
  /**
14865
14816
  * `custom-model-registry` — collection cap exposing operator-registered
@@ -15095,36 +15046,19 @@ var ResyncResultSchema = object({
15095
15046
  * provider re-derived the device. 0/absent for a normal incremental re-sync. */
15096
15047
  removedChildren: number().int().nonnegative().optional()
15097
15048
  });
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
- };
15049
+ 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, {
15050
+ kind: "mutation",
15051
+ auth: "admin"
15052
+ }), method(AdoptInputSchema, AdoptResultSchema, {
15053
+ kind: "mutation",
15054
+ auth: "admin"
15055
+ }), method(ReleaseInputSchema, _void(), {
15056
+ kind: "mutation",
15057
+ auth: "admin"
15058
+ }), method(ResyncInputSchema, ResyncResultSchema, {
15059
+ kind: "mutation",
15060
+ auth: "admin"
15061
+ });
15128
15062
  /**
15129
15063
  * `device-export` — collection cap for addons that export camstack
15130
15064
  * devices to external ecosystems (HomeAssistant via MQTT discovery,
@@ -18314,6 +18248,17 @@ var AvailableIntegrationTypeSchema = object({
18314
18248
  iconUrl: string().nullable(),
18315
18249
  color: string(),
18316
18250
  instanceMode: string(),
18251
+ /**
18252
+ * Integration wizard `mode` (LOCKED MODEL): `standalone` (create
18253
+ * immediately then add devices, no config step/button), `account` (config
18254
+ * step), or `broker` (broker step). Derived server-side by
18255
+ * `getAvailableTypes` when the addon manifest omits an explicit `mode`.
18256
+ */
18257
+ mode: _enum([
18258
+ "standalone",
18259
+ "account",
18260
+ "broker"
18261
+ ]),
18317
18262
  discoveryMode: string(),
18318
18263
  /**
18319
18264
  * Which integration-marker cap the addon declared, so the wizard can
@@ -24032,6 +23977,158 @@ object({
24032
23977
  schemaVersion: literal(1)
24033
23978
  });
24034
23979
  //#endregion
23980
+ //#region src/config.ts
23981
+ /**
23982
+ * The AES negotiation modes the wrapped `@apocaliss92/nodegree` client accepts.
23983
+ * Mirrors the library's `encryption` union — kept local so the addon validates
23984
+ * the operator-supplied value at the system boundary without importing a runtime
23985
+ * value the library does not export. `auto` tries V2 (GCM) then V1 (ECB).
23986
+ */
23987
+ var GREE_ENCRYPTION_MODES = [
23988
+ "auto",
23989
+ "v1",
23990
+ "v2"
23991
+ ];
23992
+ var GreeEncryptionSchema = _enum(GREE_ENCRYPTION_MODES);
23993
+ /**
23994
+ * Operator-supplied CONNECTION for ONE Gree air conditioner (standalone mode —
23995
+ * the Reolink / Ecowitt pattern). Gree is LOCAL-ONLY (a directed-broadcast bind
23996
+ * handshake + per-device AES control over UDP), so the connection carries no
23997
+ * credentials — only the AC's LAN address and the UDP tuning knobs. The
23998
+ * `broadcastAddr` is the directed target the bind scan is aimed at: point it at
23999
+ * the AC's own IP (unicast-directed) or the subnet broadcast (e.g.
24000
+ * `192.168.1.255`). Empty = the library's default global broadcast.
24001
+ */
24002
+ var greeConfigSchema = object({
24003
+ /** The AC's LAN IP address — the directed-bind target. Required for a manual
24004
+ * standalone add (the operator types it). */
24005
+ host: string().default("").describe("Air conditioner LAN IP address"),
24006
+ /** Directed broadcast address for the bind scan (e.g. `192.168.1.255`). Empty =
24007
+ * the library's default global broadcast; when a `host` is set the scan is
24008
+ * aimed at it directly. */
24009
+ broadcastAddr: string().default("").describe("Directed broadcast address for the bind scan"),
24010
+ /** UDP request timeout in ms. */
24011
+ timeoutMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(500).max(3e4).default(3e3)).describe("UDP request timeout (ms)"),
24012
+ /** Retry count per UDP request. */
24013
+ retries: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(0).max(10).default(3)).describe("Retries per UDP request"),
24014
+ /** AES negotiation mode. */
24015
+ encryption: GreeEncryptionSchema.default("auto").describe("AES cipher negotiation")
24016
+ });
24017
+ /**
24018
+ * Build the `NodegreeOptions` the wrapped client constructor expects from the
24019
+ * validated connection. Pure: same config in → same options out. `host` and
24020
+ * `broadcastAddr` are NOT constructor options — they are passed to `discover()`
24021
+ * per scan — so they are intentionally omitted here.
24022
+ */
24023
+ function toNodegreeOptions(config) {
24024
+ return {
24025
+ timeoutMs: config.timeoutMs,
24026
+ retries: config.retries,
24027
+ encryption: config.encryption
24028
+ };
24029
+ }
24030
+ /**
24031
+ * Coerce a loose settings blob (the manual-creation form values, or a persisted
24032
+ * `connection` blob) through the connection schema, applying all defaults.
24033
+ * Throws a `ZodError` on invalid input so the caller surfaces a clear error at
24034
+ * the system boundary.
24035
+ */
24036
+ function settingsToGreeConfig(settings) {
24037
+ return greeConfigSchema.parse(settings ?? {});
24038
+ }
24039
+ /**
24040
+ * Persisted config for a Gree AC {@link import('@camstack/types').DeviceType.Container}
24041
+ * device. The operator-supplied CONNECTION lives directly on the device (Reolink
24042
+ * pattern) — there is no broker registry. The device owns its own live
24043
+ * `@apocaliss92/nodegree` client keyed on its own `connectionKey`; its AC +
24044
+ * toggle accessory children resolve the bound handle via that key.
24045
+ *
24046
+ * `greeMac` is the durable device identity (resolved by the create-time bind);
24047
+ * `greeIp` is the last-known LAN address; `connectionKey` is the per-device
24048
+ * connection-resolver key (the device's own stableId — decoupled from any
24049
+ * broker); `connection` is the operator's UDP settings; `system`/`name` are
24050
+ * provenance.
24051
+ */
24052
+ var greeAcDeviceSchema = object({
24053
+ /** Durable AC identity (MAC, lowercased) resolved by the create-time bind. */
24054
+ greeMac: string().min(1).describe("Gree AC MAC address"),
24055
+ /** Last-known LAN IP (re-bound on each activate). */
24056
+ greeIp: string().optional().describe("Last known LAN IP"),
24057
+ /** Per-device connection-resolver key (the device's own stableId). */
24058
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
24059
+ /** The operator-supplied UDP connection settings the device's client dials. */
24060
+ connection: greeConfigSchema,
24061
+ system: literal("gree").optional(),
24062
+ integrationId: string().optional(),
24063
+ name: string().optional()
24064
+ });
24065
+ /**
24066
+ * Hand-written connection form for the AC device-creation UI (standalone mode —
24067
+ * Reolink pattern). The connection lives on the AC DEVICE config, not in a broker
24068
+ * registry. A `name` field is included so the operator names the AC at creation
24069
+ * time (mirrors the Reolink / Ecowitt creation form).
24070
+ */
24071
+ function buildConnectionFormSchema() {
24072
+ return { sections: [{
24073
+ id: "identity",
24074
+ title: "Air conditioner",
24075
+ description: "Gree air conditioners are controlled directly over your LAN (no cloud). Enter the AC's IP address; CamStack binds to it over UDP.",
24076
+ columns: 1,
24077
+ fields: [{
24078
+ type: "text",
24079
+ key: "name",
24080
+ label: "Name",
24081
+ required: true,
24082
+ placeholder: "Living room AC"
24083
+ }, {
24084
+ type: "text",
24085
+ key: "host",
24086
+ label: "IP address",
24087
+ required: true,
24088
+ placeholder: "192.168.1.50"
24089
+ }]
24090
+ }, {
24091
+ id: "advanced",
24092
+ title: "Advanced (UDP)",
24093
+ columns: 2,
24094
+ fields: [
24095
+ {
24096
+ type: "text",
24097
+ key: "broadcastAddr",
24098
+ label: "Broadcast address (optional)",
24099
+ required: false,
24100
+ placeholder: "192.168.1.255"
24101
+ },
24102
+ {
24103
+ type: "number",
24104
+ key: "timeoutMs",
24105
+ label: "UDP timeout (ms)",
24106
+ min: 500,
24107
+ max: 3e4,
24108
+ default: 3e3
24109
+ },
24110
+ {
24111
+ type: "number",
24112
+ key: "retries",
24113
+ label: "Retries",
24114
+ min: 0,
24115
+ max: 10,
24116
+ default: 3
24117
+ },
24118
+ {
24119
+ type: "select",
24120
+ key: "encryption",
24121
+ label: "Encryption",
24122
+ default: "auto",
24123
+ options: GREE_ENCRYPTION_MODES.map((m) => ({
24124
+ value: m,
24125
+ label: m.toUpperCase()
24126
+ }))
24127
+ }
24128
+ ]
24129
+ }] };
24130
+ }
24131
+ //#endregion
24035
24132
  //#region ../../node_modules/@apocaliss92/nodegree/dist/index.js
24036
24133
  var GreeError = class extends Error {
24037
24134
  constructor(message) {
@@ -24713,191 +24810,79 @@ var Nodegree = class {
24713
24810
  }
24714
24811
  };
24715
24812
  //#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
- };
24813
+ //#region src/gree-gateway.ts
24814
+ /** Lowercase a MAC for stable map keying (Gree echoes mixed-case MACs). */
24815
+ function macKey(mac) {
24816
+ return mac.toLowerCase();
24760
24817
  }
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
24818
  /**
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.
24819
+ * Per-connection registry that device classes use to reach a bound Gree AC handle
24820
+ * (and the bind-scan rows) for a given AC device (standalone mode — the
24821
+ * connection lives on the AC device, not a broker).
24822
+ *
24823
+ * The kernel constructs device classes with only a `DeviceContext` — it cannot
24824
+ * thread the handle in as a constructor arg. Like the Ecowitt / Dreame addon's
24825
+ * facade resolver, we keep it simple and in-process: the AC device's integration
24826
+ * manager owns the connection surface per `connectionKey` (the device's own
24827
+ * stableId) and publishes it here; the AC's accessory children resolve their live
24828
+ * `AcDevice` handle by `(connectionKey, mac)`.
24776
24829
  */
24777
- function settingsToGreeConfig(settings) {
24778
- return greeConfigSchema.parse(settings ?? {});
24830
+ var GreeConnectionResolver = class {
24831
+ #surfaces = /* @__PURE__ */ new Map();
24832
+ /** Publish or remove the connection surface for a connection key. `null` removes. */
24833
+ set(connectionKey, surface) {
24834
+ if (surface === null) {
24835
+ this.#surfaces.delete(connectionKey);
24836
+ return;
24837
+ }
24838
+ this.#surfaces.set(connectionKey, surface);
24839
+ }
24840
+ /** The bind-scan rows for a connection key, or empty when unknown. */
24841
+ discovered(connectionKey) {
24842
+ return this.#surfaces.get(connectionKey)?.discovered ?? [];
24843
+ }
24844
+ /** True when the connection has a published surface (i.e. it is bound). */
24845
+ has(connectionKey) {
24846
+ return this.#surfaces.has(connectionKey);
24847
+ }
24848
+ /** The active connection keys (one entry per published surface). */
24849
+ list() {
24850
+ return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
24851
+ }
24852
+ /**
24853
+ * Resolve the bound {@link AcDevice} handle for a `(connectionKey, mac)` pair,
24854
+ * or null when the connection is unknown or the AC has not been bound.
24855
+ */
24856
+ getDevice(connectionKey, mac) {
24857
+ return this.#surfaces.get(connectionKey)?.handles.get(macKey(mac)) ?? null;
24858
+ }
24859
+ /** Remove all registered surfaces (called on full shutdown). */
24860
+ clear() {
24861
+ this.#surfaces.clear();
24862
+ }
24863
+ };
24864
+ /** The single in-process per-connection resolver shared between the AC device's
24865
+ * manager and its accessory children. */
24866
+ var greeConnections = new GreeConnectionResolver();
24867
+ //#endregion
24868
+ //#region src/gree-integration-manager.ts
24869
+ function defaultFacade(config) {
24870
+ return new Nodegree(toNodegreeOptions(config));
24779
24871
  }
24872
+ var DEFAULT_POLL_INTERVAL_MS = 3e4;
24780
24873
  /**
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
- //#region src/gree-gateway.ts
24834
- /** Lowercase a MAC for stable map keying (Gree echoes mixed-case MACs). */
24835
- function macKey$2(mac) {
24836
- return mac.toLowerCase();
24837
- }
24838
- /**
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.
24841
- *
24842
- * 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)`.
24847
- */
24848
- var GreeConnectionResolver = class {
24849
- #surfaces = /* @__PURE__ */ new Map();
24850
- /** Publish or remove the connection surface for a broker id. `null` removes. */
24851
- set(brokerId, surface) {
24852
- if (surface === null) {
24853
- this.#surfaces.delete(brokerId);
24854
- return;
24855
- }
24856
- this.#surfaces.set(brokerId, surface);
24857
- }
24858
- /** The discovery rows for a broker id, or empty when unknown. */
24859
- discovered(brokerId) {
24860
- return this.#surfaces.get(brokerId)?.discovered ?? [];
24861
- }
24862
- /** True when the broker has a published surface (i.e. its scope is active). */
24863
- has(brokerId) {
24864
- return this.#surfaces.has(brokerId);
24865
- }
24866
- /** The active broker ids (one entry per published surface). */
24867
- list() {
24868
- return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
24869
- }
24870
- /**
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.
24873
- */
24874
- getDevice(brokerId, mac) {
24875
- return this.#surfaces.get(brokerId)?.handles.get(macKey$2(mac)) ?? null;
24876
- }
24877
- /** Remove all registered surfaces (called on full shutdown). */
24878
- clear() {
24879
- this.#surfaces.clear();
24880
- }
24881
- };
24882
- /** The single in-process per-broker connection resolver shared between the
24883
- * manager and device instances. */
24884
- var greeConnections = new GreeConnectionResolver();
24885
- //#endregion
24886
- //#region src/gree-integration-manager.ts
24887
- function defaultFacade(config) {
24888
- return new Nodegree(toNodegreeOptions(config));
24889
- }
24890
- var DEFAULT_POLL_INTERVAL_MS = 3e4;
24891
- /**
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.
24898
- *
24899
- * Mirrors `DreameIntegrationManager`. Binding is best-effort per AC so one
24900
- * offline unit does not fail the whole scope.
24874
+ * Wraps exactly one `@apocaliss92/nodegree` facade bound to a SINGLE Gree AC
24875
+ * (standalone mode the connection lives on the AC device). {@link start} runs a
24876
+ * DIRECTED bind scan aimed at the configured `host` (falling back to the
24877
+ * `broadcastAddr` / global broadcast), matches the responder for that host,
24878
+ * binds (creates) its {@link AcDevice} handle, publishes the connection surface
24879
+ * on the shared resolver keyed by the device's `connectionKey`, and starts
24880
+ * polling. {@link stop} closes the facade; {@link applyConnection} does both
24881
+ * atomically when the operator changes the connection.
24882
+ *
24883
+ * {@link bindOnce} is the create-time helper: a static one-shot directed scan
24884
+ * that returns the AC's durable identity (MAC/ip/name) WITHOUT holding a handle,
24885
+ * so `onCreateDevice` can persist the identity before the device exists.
24901
24886
  */
24902
24887
  var GreeIntegrationManager = class {
24903
24888
  #id;
@@ -24909,6 +24894,8 @@ var GreeIntegrationManager = class {
24909
24894
  #surfaceSink;
24910
24895
  #pollIntervalMs;
24911
24896
  #makeFacade;
24897
+ /** When set, only the responder for this MAC is bound (standalone: one AC). */
24898
+ #expectMac;
24912
24899
  #facade = null;
24913
24900
  #handles = /* @__PURE__ */ new Map();
24914
24901
  #status = "disconnected";
@@ -24925,6 +24912,7 @@ var GreeIntegrationManager = class {
24925
24912
  this.#surfaceSink = options.surfaceSink;
24926
24913
  this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
24927
24914
  this.#makeFacade = options.makeFacade ?? defaultFacade;
24915
+ this.#expectMac = options.expectMac !== void 0 ? macKey(options.expectMac) : null;
24928
24916
  }
24929
24917
  /** The connection settings the manager is currently configured with. */
24930
24918
  getConnection() {
@@ -24938,6 +24926,7 @@ var GreeIntegrationManager = class {
24938
24926
  kind: "gree",
24939
24927
  status: this.#status,
24940
24928
  info: {
24929
+ host: this.#connection.host,
24941
24930
  broadcastAddr: this.#connection.broadcastAddr,
24942
24931
  deviceCount: this.#deviceCount
24943
24932
  },
@@ -24946,14 +24935,14 @@ var GreeIntegrationManager = class {
24946
24935
  };
24947
24936
  }
24948
24937
  /**
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}.
24938
+ * Build the facade, run a DIRECTED bind scan (aimed at `host`), bind the
24939
+ * responder for the expected AC, and publish the connection surface. Sets
24940
+ * `connected` on a successful bind. Surfaces a clear bind error on
24941
+ * {@link GreeAuthError}.
24953
24942
  */
24954
24943
  async start() {
24955
24944
  if (this.#facade !== null) {
24956
- this.#logger.warn("GreeIntegrationManager: start() called while running — stop first", { tags: { brokerId: this.#id } });
24945
+ this.#logger.warn("GreeIntegrationManager: start() called while running — stop first", { tags: { connectionKey: this.#id } });
24957
24946
  return;
24958
24947
  }
24959
24948
  this.#status = "connecting";
@@ -24962,18 +24951,19 @@ var GreeIntegrationManager = class {
24962
24951
  this.#facade = facade;
24963
24952
  try {
24964
24953
  const discovered = await facade.discover(this.#discoverOpts());
24954
+ const matched = this.#selectResponders(discovered);
24965
24955
  const handles = /* @__PURE__ */ new Map();
24966
- for (const dev of discovered) try {
24956
+ for (const dev of matched) try {
24967
24957
  const ac = await facade.createAc({
24968
24958
  ip: dev.ip,
24969
24959
  port: dev.port,
24970
24960
  mac: dev.mac
24971
24961
  });
24972
24962
  if (this.#pollIntervalMs > 0) ac.startPolling(this.#pollIntervalMs);
24973
- handles.set(macKey$2(dev.mac), ac);
24963
+ handles.set(macKey(dev.mac), ac);
24974
24964
  } catch (err) {
24975
24965
  this.#logger.warn("GreeIntegrationManager: bind (createAc) failed", {
24976
- tags: { brokerId: this.#id },
24966
+ tags: { connectionKey: this.#id },
24977
24967
  meta: {
24978
24968
  mac: dev.mac,
24979
24969
  ip: dev.ip,
@@ -24983,22 +24973,24 @@ var GreeIntegrationManager = class {
24983
24973
  }
24984
24974
  this.#handles = handles;
24985
24975
  const surface = {
24986
- discovered,
24976
+ discovered: matched,
24987
24977
  handles
24988
24978
  };
24989
24979
  this.#surfaceSink.set(this.#id, surface);
24990
24980
  this.#deviceCount = handles.size;
24991
- this.#status = "connected";
24992
- this.#error = null;
24981
+ this.#status = handles.size > 0 ? "connected" : "error";
24982
+ this.#error = handles.size > 0 ? null : "AC not reachable — bind returned no handle";
24993
24983
  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
- });
24984
+ if (handles.size > 0) {
24985
+ this.#onConnected(this.#id);
24986
+ this.#logger.info("GreeIntegrationManager: connected", {
24987
+ tags: { connectionKey: this.#id },
24988
+ meta: {
24989
+ discovered: discovered.length,
24990
+ bound: handles.size
24991
+ }
24992
+ });
24993
+ } else this.#onDisconnected(this.#id);
25002
24994
  } catch (err) {
25003
24995
  this.#status = "error";
25004
24996
  this.#error = err instanceof GreeAuthError ? "device bind failed — check the AC is reachable" : errMsg(err);
@@ -25006,7 +24998,7 @@ var GreeIntegrationManager = class {
25006
24998
  this.#surfaceSink.set(this.#id, null);
25007
24999
  this.#onDisconnected(this.#id);
25008
25000
  this.#logger.warn("GreeIntegrationManager: scan failed", {
25009
- tags: { brokerId: this.#id },
25001
+ tags: { connectionKey: this.#id },
25010
25002
  meta: { error: this.#error }
25011
25003
  });
25012
25004
  throw err;
@@ -25026,7 +25018,7 @@ var GreeIntegrationManager = class {
25026
25018
  await facade.close();
25027
25019
  } catch (err) {
25028
25020
  this.#logger.warn("GreeIntegrationManager: facade close failed", {
25029
- tags: { brokerId: this.#id },
25021
+ tags: { connectionKey: this.#id },
25030
25022
  meta: { error: errMsg(err) }
25031
25023
  });
25032
25024
  }
@@ -25038,534 +25030,87 @@ var GreeIntegrationManager = class {
25038
25030
  this.#connection = conn;
25039
25031
  await this.start();
25040
25032
  }
25033
+ /** Keep only the responder(s) this manager should bind: the one matching the
25034
+ * expected MAC when set, else the one matching the configured host, else all. */
25035
+ #selectResponders(discovered) {
25036
+ if (this.#expectMac !== null) return discovered.filter((d) => macKey(d.mac) === this.#expectMac);
25037
+ const host = this.#connection.host.trim();
25038
+ if (host.length > 0) {
25039
+ const byHost = discovered.filter((d) => d.ip === host);
25040
+ if (byHost.length > 0) return byHost;
25041
+ }
25042
+ return [...discovered];
25043
+ }
25041
25044
  #discoverOpts() {
25042
25045
  const out = { timeoutMs: this.#connection.timeoutMs };
25043
- if (this.#connection.broadcastAddr.length > 0) out.broadcastAddr = this.#connection.broadcastAddr;
25046
+ const broadcast = resolveBroadcastTarget(this.#connection);
25047
+ if (broadcast.length > 0) out.broadcastAddr = broadcast;
25044
25048
  return out;
25045
25049
  }
25046
25050
  };
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;
25051
- }
25052
- //#endregion
25053
- //#region src/gree-broker-registry.ts
25054
- /**
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`.
25059
- */
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
- }));
25081
- }
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);
25134
- 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);
25147
- return {
25148
- id,
25149
- name: mgr.getInfo().name,
25150
- connection
25151
- };
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
25051
  /**
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).
25052
+ * Resolve the directed-scan target for a connection: the explicit
25053
+ * `broadcastAddr` when set, else the AC `host` (unicast-directed scan), else the
25054
+ * empty string (library default global broadcast).
25218
25055
  */
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();
25056
+ function resolveBroadcastTarget(connection) {
25057
+ const broadcast = connection.broadcastAddr.trim();
25058
+ if (broadcast.length > 0) return broadcast;
25059
+ return connection.host.trim();
25331
25060
  }
25332
25061
  /**
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).
25062
+ * Create-time one-shot directed bind: build a throwaway facade, run a directed
25063
+ * scan (aimed at the connection's `host`/`broadcastAddr`), bind the matching
25064
+ * responder to prove the AC is reachable and learn its durable identity, then
25065
+ * close the facade. Returns the AC's identity (MAC/ip/name/model). Throws when no
25066
+ * AC responds or the bind fails — surfaced to the operator in the Add modal.
25067
+ *
25068
+ * The returned identity is what {@link import('./config.js').greeAcDeviceSchema}
25069
+ * persists on the new device; the live per-device manager re-binds on activate.
25383
25070
  */
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)
25071
+ async function bindOnce(input) {
25072
+ const { connection, logger } = input;
25073
+ const facade = (input.makeFacade ?? defaultFacade)(connection);
25074
+ try {
25075
+ const broadcast = resolveBroadcastTarget(connection);
25076
+ const opts = { timeoutMs: connection.timeoutMs };
25077
+ if (broadcast.length > 0) opts.broadcastAddr = broadcast;
25078
+ const discovered = await facade.discover(opts);
25079
+ const host = connection.host.trim();
25080
+ const target = host.length > 0 ? discovered.find((d) => d.ip === host) ?? discovered[0] : discovered[0];
25081
+ if (target === void 0) throw new Error(host.length > 0 ? `no Gree AC responded at ${host}` : "no Gree AC responded to the discovery scan");
25082
+ const ac = await facade.createAc({
25083
+ ip: target.ip,
25084
+ port: target.port,
25085
+ mac: target.mac
25390
25086
  });
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;
25534
25087
  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
- }
25088
+ ac.stopPolling();
25089
+ } catch {}
25090
+ return {
25091
+ mac: target.mac,
25092
+ ip: target.ip,
25093
+ port: target.port,
25094
+ name: target.name.length > 0 ? target.name : target.mac,
25095
+ ...target.model !== void 0 ? { model: target.model } : {}
25096
+ };
25097
+ } catch (err) {
25098
+ const message = err instanceof GreeAuthError ? "device bind failed — check the AC is reachable and the encryption mode" : errMsg(err);
25099
+ logger.warn("gree bindOnce failed", { meta: {
25100
+ host: connection.host,
25101
+ error: message
25102
+ } });
25103
+ throw new Error(message);
25104
+ } finally {
25105
+ try {
25106
+ await facade.close();
25107
+ } catch {}
25546
25108
  }
25547
- return removed;
25548
25109
  }
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;
25110
+ /** True when two configs are the "same connection" (no facade rebuild needed). */
25111
+ function sameConnection(a, b) {
25112
+ if (a === null || b === null) return false;
25113
+ return a.host === b.host && a.broadcastAddr === b.broadcastAddr && a.timeoutMs === b.timeoutMs && a.retries === b.retries && a.encryption === b.encryption;
25569
25114
  }
25570
25115
  //#endregion
25571
25116
  //#region src/gree-domain-mapping.ts
@@ -25813,12 +25358,13 @@ var FAN_COLD_START = {
25813
25358
  };
25814
25359
  /**
25815
25360
  * 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.
25361
+ * bound handle from the connection resolver; `connectionKey` selects the parent
25362
+ * AC device's live connection (its own stableId); `system`/`integrationId` are
25363
+ * provenance.
25818
25364
  */
25819
25365
  var greeAcSchema = object({
25820
25366
  greeMac: string().min(1).describe("Gree AC MAC address"),
25821
- brokerId: string().min(1).describe("Registry broker id"),
25367
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
25822
25368
  system: literal("gree").optional(),
25823
25369
  integrationId: string().optional()
25824
25370
  });
@@ -25869,13 +25415,13 @@ var GreeAcDevice = class extends BaseDevice$1 {
25869
25415
  return flags;
25870
25416
  }
25871
25417
  greeMac;
25872
- brokerId;
25418
+ connectionKey;
25873
25419
  stateChangedUnsub = null;
25874
25420
  constructor(ctx) {
25875
25421
  const persisted = greeAcSchema.parse(ctx.persistedConfig ?? {});
25876
25422
  super(ctx, greeAcSchema, { type: ctx.deviceMeta.type });
25877
25423
  this.greeMac = persisted.greeMac;
25878
- this.brokerId = persisted.brokerId;
25424
+ this.connectionKey = persisted.connectionKey;
25879
25425
  this.online = true;
25880
25426
  this.updateSourceInfo({
25881
25427
  id: this.greeMac,
@@ -25883,7 +25429,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25883
25429
  });
25884
25430
  }
25885
25431
  resolveAc() {
25886
- return greeConnections.getDevice(this.brokerId, this.greeMac);
25432
+ return greeConnections.getDevice(this.connectionKey, this.greeMac);
25887
25433
  }
25888
25434
  requireAc() {
25889
25435
  const ac = this.resolveAc();
@@ -26043,9 +25589,10 @@ var SWITCH_COLD_START = {
26043
25589
  };
26044
25590
  /**
26045
25591
  * 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.
25592
+ * {@link greeAcSchema}: `greeMac` resolves the bound handle, `connectionKey`
25593
+ * selects the parent AC device's live connection, `system`/`integrationId` are
25594
+ * provenance. `toggle` selects which of the four boolean device flags this child
25595
+ * drives.
26049
25596
  */
26050
25597
  var greeToggleSchema = object({
26051
25598
  toggle: _enum([
@@ -26055,7 +25602,7 @@ var greeToggleSchema = object({
26055
25602
  "freshAir"
26056
25603
  ]).describe("Which Gree boolean flag"),
26057
25604
  greeMac: string().min(1).describe("Gree AC MAC address"),
26058
- brokerId: string().min(1).describe("Registry broker id"),
25605
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
26059
25606
  system: literal("gree").optional(),
26060
25607
  integrationId: string().optional()
26061
25608
  });
@@ -26073,14 +25620,14 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26073
25620
  features = [];
26074
25621
  toggle;
26075
25622
  greeMac;
26076
- brokerId;
25623
+ connectionKey;
26077
25624
  stateChangedUnsub = null;
26078
25625
  constructor(ctx) {
26079
25626
  const persisted = greeToggleSchema.parse(ctx.persistedConfig ?? {});
26080
25627
  super(ctx, greeToggleSchema, { type: ctx.deviceMeta.type });
26081
25628
  this.toggle = persisted.toggle;
26082
25629
  this.greeMac = persisted.greeMac;
26083
- this.brokerId = persisted.brokerId;
25630
+ this.connectionKey = persisted.connectionKey;
26084
25631
  this.online = true;
26085
25632
  this.updateSourceInfo({
26086
25633
  id: `${this.greeMac}:${this.toggle}`,
@@ -26088,7 +25635,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26088
25635
  });
26089
25636
  }
26090
25637
  resolveAc() {
26091
- return greeConnections.getDevice(this.brokerId, this.greeMac);
25638
+ return greeConnections.getDevice(this.connectionKey, this.greeMac);
26092
25639
  }
26093
25640
  requireAc() {
26094
25641
  const ac = this.resolveAc();
@@ -26188,49 +25735,117 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26188
25735
  //#endregion
26189
25736
  //#region src/devices/gree-container-device.ts
26190
25737
  /**
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`.
25738
+ * Standalone-mode parent Container device for a single Gree AC (Reolink /
25739
+ * Ecowitt pattern). The operator-supplied CONNECTION lives on THIS device's
25740
+ * config; the device OWNS exactly one live `@apocaliss92/nodegree` client (via a
25741
+ * {@link GreeIntegrationManager}) keyed on its own `connectionKey` and published
25742
+ * on the in-process {@link greeConnections} resolver so its AC + toggle accessory
25743
+ * children read the live bound handle. There is NO broker and NO device-adoption
25744
+ * cap.
26210
25745
  *
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).
25746
+ * It owns no control caps itself it declares `getAccessoryChildren()` so the
25747
+ * kernel auto-spawns the single {@link GreeAcDevice} accessory child (Thermostat,
25748
+ * carrying `climate-control` + `fan-control` with the swing entities) plus
25749
+ * presence-gated {@link DeviceType.Switch} children for the panel-light / X-Fan /
25750
+ * health / fresh-air boolean flags (mirroring the HA Gree integration).
25751
+ * Capability presence is resolved from the bound handle's `AcCapabilities` (or
25752
+ * model defaults when no live handle).
26216
25753
  */
26217
25754
  var GreeContainerDevice = class extends BaseDevice$1 {
26218
25755
  features = [DeviceFeature.Resyncable];
26219
25756
  greeMac;
26220
- brokerId;
25757
+ connectionKey;
26221
25758
  integrationId;
25759
+ connection;
25760
+ manager = null;
26222
25761
  constructor(ctx) {
26223
- const cfg = greeContainerSchema.parse(ctx.persistedConfig ?? {});
26224
- super(ctx, greeContainerSchema, { type: DeviceType.Container });
25762
+ const cfg = greeAcDeviceSchema.parse(ctx.persistedConfig ?? {});
25763
+ super(ctx, greeAcDeviceSchema, { type: DeviceType.Container });
26225
25764
  this.greeMac = cfg.greeMac;
26226
- this.brokerId = cfg.brokerId;
25765
+ this.connectionKey = cfg.connectionKey;
26227
25766
  this.integrationId = cfg.integrationId;
26228
- this.online = greeConnections.getDevice(this.brokerId, this.greeMac) !== null;
25767
+ this.connection = cfg.connection;
25768
+ this.online = greeConnections.getDevice(this.connectionKey, this.greeMac) !== null;
26229
25769
  this.updateSourceInfo({
26230
25770
  id: this.greeMac,
26231
25771
  system: "gree"
26232
25772
  });
26233
25773
  }
25774
+ /** Adopt a pre-built manager (created by `onCreateDevice` so the live client
25775
+ * binds once at create time) — mirrors `EcowittGatewayDevice.adoptManager`. */
25776
+ adoptManager(manager) {
25777
+ this.manager = manager;
25778
+ }
25779
+ async onActivate() {
25780
+ await super.onActivate();
25781
+ if (this.manager === null) this.ensureManager().catch((err) => {
25782
+ this.ctx.logger.warn("Gree AC initial bind failed", { meta: {
25783
+ greeMac: this.greeMac,
25784
+ error: errMsg(err)
25785
+ } });
25786
+ });
25787
+ }
25788
+ async removeDevice() {
25789
+ const mgr = this.manager;
25790
+ this.manager = null;
25791
+ if (mgr) try {
25792
+ await mgr.stop();
25793
+ } catch (err) {
25794
+ this.ctx.logger.warn("Gree AC: client stop failed", { meta: {
25795
+ greeMac: this.greeMac,
25796
+ error: errMsg(err)
25797
+ } });
25798
+ }
25799
+ }
25800
+ async applySettingsPatch(patch) {
25801
+ if (!("connection" in patch)) return;
25802
+ const parsed = greeAcDeviceSchema.shape.connection.parse(patch["connection"]);
25803
+ await this.config.setAll({ connection: parsed });
25804
+ this.connection = parsed;
25805
+ const mgr = this.manager;
25806
+ if (mgr) try {
25807
+ await mgr.applyConnection(parsed);
25808
+ } catch (err) {
25809
+ this.ctx.logger.warn("Gree AC: applyConnection failed", { meta: {
25810
+ greeMac: this.greeMac,
25811
+ error: errMsg(err)
25812
+ } });
25813
+ }
25814
+ else await this.ensureManager().catch((err) => {
25815
+ this.ctx.logger.warn("Gree AC re-bind after settings change failed", { meta: {
25816
+ greeMac: this.greeMac,
25817
+ error: errMsg(err)
25818
+ } });
25819
+ });
25820
+ }
25821
+ /** Build + start the per-device nodegree client (if not already running). */
25822
+ async ensureManager() {
25823
+ if (this.manager !== null) return;
25824
+ const mgr = new GreeIntegrationManager({
25825
+ id: this.connectionKey,
25826
+ name: this.name,
25827
+ connection: this.connection,
25828
+ logger: this.ctx.logger,
25829
+ onConnected: () => this.setAcOnline(true),
25830
+ onDisconnected: () => this.setAcOnline(false),
25831
+ surfaceSink: greeConnections,
25832
+ expectMac: this.greeMac
25833
+ });
25834
+ this.manager = mgr;
25835
+ await mgr.start();
25836
+ }
25837
+ setAcOnline(online) {
25838
+ if (this.online === online) return;
25839
+ this.markOnline(online);
25840
+ this.setChildrenOnline(online).catch(() => {});
25841
+ }
25842
+ async setChildrenOnline(online) {
25843
+ const children = await this.ctx.devices.getChildren(this.id).catch(() => []);
25844
+ for (const child of children) {
25845
+ if (child.online === online) continue;
25846
+ child.markOnline(online);
25847
+ }
25848
+ }
26234
25849
  getAccessoryChildren() {
26235
25850
  return [{
26236
25851
  stableIdSuffix: "ac",
@@ -26242,7 +25857,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26242
25857
  },
26243
25858
  config: {
26244
25859
  greeMac: this.greeMac,
26245
- brokerId: this.brokerId,
25860
+ connectionKey: this.connectionKey,
26246
25861
  system: "gree",
26247
25862
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
26248
25863
  },
@@ -26256,7 +25871,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26256
25871
  * defaults when no live handle is bound yet).
26257
25872
  */
26258
25873
  toggleChildren() {
26259
- const caps = greeConnections.getDevice(this.brokerId, this.greeMac)?.capabilities ?? DEFAULT_AC_CAPABILITIES;
25874
+ const caps = greeConnections.getDevice(this.connectionKey, this.greeMac)?.capabilities ?? DEFAULT_AC_CAPABILITIES;
26260
25875
  return [
26261
25876
  {
26262
25877
  toggle: "light",
@@ -26292,7 +25907,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26292
25907
  const childConfig = {
26293
25908
  toggle: d.toggle,
26294
25909
  greeMac: this.greeMac,
26295
- brokerId: this.brokerId,
25910
+ connectionKey: this.connectionKey,
26296
25911
  system: "gree",
26297
25912
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
26298
25913
  };
@@ -26307,303 +25922,123 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26307
25922
  };
26308
25923
  //#endregion
26309
25924
  //#region src/addon.ts
26310
- /** Default multi-broker config — a fresh install starts with no scopes. */
26311
- var DEFAULTS = { brokers: [] };
26312
25925
  /**
26313
- * Gree device-provider addon (multi-scope).
25926
+ * Gree air-conditioner device-provider addon — `mode: standalone` (the Reolink /
25927
+ * Ecowitt pattern). No broker, no device-adoption cap. An AC is added as a DEVICE
25928
+ * via the manual-creation form: the CONNECTION (host IP + optional broadcastAddr
25929
+ * + UDP tuning) lives on the AC DEVICE config. `onCreateDevice` runs a DIRECTED
25930
+ * bind against the given host to learn the AC's durable identity (MAC) and prove
25931
+ * reachability, then creates a {@link GreeContainerDevice} that OWNS its own live
25932
+ * `@apocaliss92/nodegree` client and fans out its Thermostat + toggle accessory
25933
+ * children (climate-control with swing entities + the panel-light / X-Fan /
25934
+ * health / fresh-air switches — all preserved from the broker model).
26314
25935
  *
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.
25936
+ * `instanceMode: multiple` many ACs may be added. Placement: any-node — Gree is
25937
+ * LOCAL UDP; the addon runs on whichever node shares the AC's subnet.
26323
25938
  *
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.
25939
+ * LAN UDP-broadcast auto-discovery of multiple ACs is deliberately NOT
25940
+ * implemented here that is the later Discovery-cap phase. Phase 1 is
25941
+ * add-one-AC-by-IP with a directed bind.
26328
25942
  */
26329
25943
  var GreeProviderAddon = class extends BaseDeviceProvider {
26330
25944
  addonId = "provider-gree";
26331
25945
  providerName = "Gree";
26332
25946
  deviceClasses = { [DeviceType.Container]: GreeContainerDevice };
26333
- registry = null;
26334
25947
  constructor() {
26335
- super({ ...DEFAULTS });
26336
- }
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);
26343
- });
26344
- this.registry.setOnBrokerDisconnected((brokerId) => {
26345
- this.setBrokerDevicesOnline(brokerId, false);
26346
- });
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
- ];
25948
+ super({});
26362
25949
  }
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
- }
25950
+ async supportsDiscovery() {
25951
+ return false;
26408
25952
  }
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();
25953
+ async supportsManualCreation() {
25954
+ return true;
26417
25955
  }
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),
25956
+ async onGetCreationSchema(type) {
25957
+ if (type !== DeviceType.Container) return null;
25958
+ return buildConnectionFormSchema();
25959
+ }
25960
+ async onCreateDevice(type, config) {
25961
+ if (type !== DeviceType.Container) throw new Error(`Gree provider does not support device type: ${type}`);
25962
+ const name = typeof config["name"] === "string" ? config["name"].trim() : "";
25963
+ if (!name) throw new Error("Air conditioner name is required");
25964
+ const connection = settingsToGreeConfig(config);
25965
+ if (connection.host.trim().length === 0) throw new Error("Air conditioner IP address is required");
25966
+ const bound = await bindOnce({
25967
+ connection,
26429
25968
  logger: this.ctx.logger
26430
25969
  });
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
25970
+ const connectionKey = `gree:${macKey(bound.mac)}`;
25971
+ const deviceConfig = {
25972
+ greeMac: macKey(bound.mac),
25973
+ greeIp: bound.ip,
25974
+ connectionKey,
25975
+ connection,
25976
+ system: "gree",
25977
+ name
25978
+ };
25979
+ const manager = new GreeIntegrationManager({
25980
+ id: connectionKey,
25981
+ name,
25982
+ connection,
25983
+ logger: this.ctx.logger,
25984
+ onConnected: () => void 0,
25985
+ onDisconnected: () => void 0,
25986
+ surfaceSink: greeConnections,
25987
+ expectMac: bound.mac
26442
25988
  });
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
25989
  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 });
25990
+ await manager.start();
26481
25991
  } catch (err) {
26482
- this.ctx.logger.warn("Gree integration→broker reconcile failed", { meta: { error: errMsg(err) } });
25992
+ this.ctx.logger.warn("Gree create: initial client start failed — device kept", { meta: {
25993
+ connectionKey,
25994
+ error: errMsg(err)
25995
+ } });
26483
25996
  }
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);
25997
+ return {
25998
+ meta: {
25999
+ type: DeviceType.Container,
26000
+ name
26544
26001
  },
26545
- findDeviceConfig: async (id) => {
26546
- const devices = this.ctx.kernel.devices;
26547
- if (!devices) return null;
26548
- return devices.loadConfig(id).catch(() => null);
26002
+ config: deviceConfig,
26003
+ onAfterCreate: async (device) => {
26004
+ if (device instanceof GreeContainerDevice) device.adoptManager(manager);
26549
26005
  }
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
- } });
26006
+ };
26583
26007
  }
26584
- requireRegistry() {
26585
- if (!this.registry) throw new Error("Gree provider not initialised");
26586
- return this.registry;
26008
+ /**
26009
+ * AC stableId derived from the AC's durable MAC identity (resolved by the
26010
+ * create-time bind), NOT a broker id. Re-adding the same physical AC reuses its
26011
+ * persisted row. Falls back to a timestamp only when no MAC is resolvable
26012
+ * (should not happen — `onCreateDevice` binds first).
26013
+ */
26014
+ generateStableId(_type, config) {
26015
+ const mac = config?.["greeMac"];
26016
+ if (typeof mac === "string" && mac.length > 0) return `gree:${macKey(mac)}`;
26017
+ return `gree:${Date.now()}`;
26587
26018
  }
26588
26019
  };
26589
26020
  //#endregion
26590
26021
  exports.ADVERTISED_CAP_MODES = ADVERTISED_CAP_MODES;
26022
+ exports.DeviceType = DeviceType;
26591
26023
  exports.GREE_FAN_PERCENTAGE_STEP = GREE_FAN_PERCENTAGE_STEP;
26592
26024
  exports.GreeProviderAddon = GreeProviderAddon;
26593
26025
  exports.SUPPORTED_CAP_MODES = SUPPORTED_CAP_MODES;
26026
+ exports.bindOnce = bindOnce;
26594
26027
  exports.boolToHorizontalSwing = boolToHorizontalSwing;
26595
26028
  exports.boolToVerticalSwing = boolToVerticalSwing;
26596
26029
  exports.buildConnectionFormSchema = buildConnectionFormSchema;
26597
- exports.buildGreeCandidates = buildGreeCandidates;
26598
26030
  exports.capModeToLibMode = capModeToLibMode;
26599
26031
  exports.fanSpeedToPercentage = fanSpeedToPercentage;
26600
- exports.greeAddonConfigSchema = greeAddonConfigSchema;
26032
+ exports.greeAcDeviceSchema = greeAcDeviceSchema;
26601
26033
  exports.greeConfigSchema = greeConfigSchema;
26602
26034
  exports.horizontalSwingToBool = horizontalSwingToBool;
26603
26035
  exports.isAutoFan = isAutoFan;
26604
26036
  exports.libModeToCapMode = libModeToCapMode;
26605
26037
  exports.oscillatingToVerticalSwing = oscillatingToVerticalSwing;
26606
26038
  exports.percentageToFanSpeed = percentageToFanSpeed;
26039
+ exports.resolveBroadcastTarget = resolveBroadcastTarget;
26040
+ exports.sameConnection = sameConnection;
26041
+ exports.settingsToGreeConfig = settingsToGreeConfig;
26607
26042
  exports.swingToOscillating = swingToOscillating;
26608
26043
  exports.toNodegreeOptions = toNodegreeOptions;
26609
26044
  exports.verticalSwingToBool = verticalSwingToBool;