@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.mjs CHANGED
@@ -14782,83 +14782,34 @@ var GetStateInputSchema = object({
14782
14782
  * HA: entity_id (returns the cached entity state). */
14783
14783
  key: string()
14784
14784
  });
14785
- var brokerCapability = {
14786
- name: "broker",
14787
- scope: "system",
14788
- mode: "collection",
14789
- providerKind: "broker",
14790
- status: {
14791
- schema: RegistryStatusSchema,
14792
- kind: "poll"
14793
- },
14794
- methods: {
14795
- list: method(ListInputSchema, array(BrokerInfoSchema$1)),
14796
- get: method(GetInputSchema, BrokerInfoSchema$1.nullable()),
14797
- /** Enumerate which addon provides which broker kind(s) for the
14798
- * unified create picker. The auto-mount fans this array across
14799
- * every registered `broker` provider (array-output method), so the
14800
- * picker sees every kind from every provider in one call. */
14801
- listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
14802
- add: method(AddInputSchema, AddResultSchema, {
14803
- kind: "mutation",
14804
- auth: "admin"
14805
- }),
14806
- remove: method(RemoveInputSchema, _void(), {
14807
- kind: "mutation",
14808
- auth: "admin"
14809
- }),
14810
- testConnection: method(GetInputSchema, TestConnectionResultSchema, {
14811
- kind: "mutation",
14812
- auth: "admin"
14813
- }),
14814
- /** Read the persisted settings record for a broker (kind-specific
14815
- * shape). Admin-only — settings may contain secrets. Returns `null`
14816
- * when the broker id is unknown to the provider (the collection
14817
- * fallback may route a foreign id to the first provider). */
14818
- getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
14819
- /** Overwrite the persisted settings record. The kind-specific
14820
- * provider validates the shape and applies the change (reconnects
14821
- * if credentials changed). */
14822
- setSettings: method(object({
14823
- id: string(),
14824
- settings: SettingsRecordSchema$1
14825
- }), _void(), {
14826
- kind: "mutation",
14827
- auth: "admin"
14828
- }),
14829
- /** Returns the kind-specific connection config the consumer needs
14830
- * to open its own client (MQTT pattern: `{url, username, password,
14831
- * clientIdPrefix}`). HA providers MAY return the auth envelope
14832
- * but typical HA consumers use `publish` / `subscribe` instead.
14833
- * Returns `null` when the broker id is unknown to the provider. */
14834
- getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
14835
- getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
14836
- testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
14837
- kind: "mutation",
14838
- auth: "admin"
14839
- }),
14840
- publish: method(PublishInputSchema, unknown(), {
14841
- kind: "mutation",
14842
- auth: "admin"
14843
- }),
14844
- subscribe: method(SubscribeInputSchema, SubscribeResultSchema, {
14845
- kind: "mutation",
14846
- auth: "admin"
14847
- }),
14848
- unsubscribe: method(UnsubscribeInputSchema, _void(), {
14849
- kind: "mutation",
14850
- auth: "admin"
14851
- }),
14852
- /** Read the broker's cached state for a key. Returns `null` when
14853
- * unknown to the broker (never published / unknown entity). */
14854
- getState: method(GetStateInputSchema, unknown().nullable()),
14855
- /** Status method — explicit registration with a `z.void()` input so
14856
- * the codegen-generated tRPC router types its input as
14857
- * `{addonId?: string, nodeId?: string}` (system-scoped collection
14858
- * shape) instead of the device-scoped `{deviceId}` fallback. */
14859
- getStatus: method(_void(), RegistryStatusSchema)
14860
- }
14861
- };
14785
+ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, BrokerInfoSchema$1.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
14786
+ kind: "mutation",
14787
+ auth: "admin"
14788
+ }), method(RemoveInputSchema, _void(), {
14789
+ kind: "mutation",
14790
+ auth: "admin"
14791
+ }), method(GetInputSchema, TestConnectionResultSchema, {
14792
+ kind: "mutation",
14793
+ auth: "admin"
14794
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
14795
+ id: string(),
14796
+ settings: SettingsRecordSchema$1
14797
+ }), _void(), {
14798
+ kind: "mutation",
14799
+ auth: "admin"
14800
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
14801
+ kind: "mutation",
14802
+ auth: "admin"
14803
+ }), method(PublishInputSchema, unknown(), {
14804
+ kind: "mutation",
14805
+ auth: "admin"
14806
+ }), method(SubscribeInputSchema, SubscribeResultSchema, {
14807
+ kind: "mutation",
14808
+ auth: "admin"
14809
+ }), method(UnsubscribeInputSchema, _void(), {
14810
+ kind: "mutation",
14811
+ auth: "admin"
14812
+ }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
14862
14813
  DeviceType.Camera;
14863
14814
  /**
14864
14815
  * `custom-model-registry` — collection cap exposing operator-registered
@@ -15094,36 +15045,19 @@ var ResyncResultSchema = object({
15094
15045
  * provider re-derived the device. 0/absent for a normal incremental re-sync. */
15095
15046
  removedChildren: number().int().nonnegative().optional()
15096
15047
  });
15097
- var deviceAdoptionCapability = {
15098
- name: "device-adoption",
15099
- scope: "system",
15100
- mode: "singleton",
15101
- status: {
15102
- schema: AdoptionStatusSchema,
15103
- kind: "poll"
15104
- },
15105
- methods: {
15106
- listCandidateFilters: method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }),
15107
- listCandidates: method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }),
15108
- getCandidate: method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }),
15109
- refresh: method(object({ integrationId: string() }), AdoptionStatusSchema, {
15110
- kind: "mutation",
15111
- auth: "admin"
15112
- }),
15113
- adopt: method(AdoptInputSchema, AdoptResultSchema, {
15114
- kind: "mutation",
15115
- auth: "admin"
15116
- }),
15117
- release: method(ReleaseInputSchema, _void(), {
15118
- kind: "mutation",
15119
- auth: "admin"
15120
- }),
15121
- resync: method(ResyncInputSchema, ResyncResultSchema, {
15122
- kind: "mutation",
15123
- auth: "admin"
15124
- })
15125
- }
15126
- };
15048
+ 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, {
15049
+ kind: "mutation",
15050
+ auth: "admin"
15051
+ }), method(AdoptInputSchema, AdoptResultSchema, {
15052
+ kind: "mutation",
15053
+ auth: "admin"
15054
+ }), method(ReleaseInputSchema, _void(), {
15055
+ kind: "mutation",
15056
+ auth: "admin"
15057
+ }), method(ResyncInputSchema, ResyncResultSchema, {
15058
+ kind: "mutation",
15059
+ auth: "admin"
15060
+ });
15127
15061
  /**
15128
15062
  * `device-export` — collection cap for addons that export camstack
15129
15063
  * devices to external ecosystems (HomeAssistant via MQTT discovery,
@@ -18313,6 +18247,17 @@ var AvailableIntegrationTypeSchema = object({
18313
18247
  iconUrl: string().nullable(),
18314
18248
  color: string(),
18315
18249
  instanceMode: string(),
18250
+ /**
18251
+ * Integration wizard `mode` (LOCKED MODEL): `standalone` (create
18252
+ * immediately then add devices, no config step/button), `account` (config
18253
+ * step), or `broker` (broker step). Derived server-side by
18254
+ * `getAvailableTypes` when the addon manifest omits an explicit `mode`.
18255
+ */
18256
+ mode: _enum([
18257
+ "standalone",
18258
+ "account",
18259
+ "broker"
18260
+ ]),
18316
18261
  discoveryMode: string(),
18317
18262
  /**
18318
18263
  * Which integration-marker cap the addon declared, so the wizard can
@@ -24031,6 +23976,158 @@ object({
24031
23976
  schemaVersion: literal(1)
24032
23977
  });
24033
23978
  //#endregion
23979
+ //#region src/config.ts
23980
+ /**
23981
+ * The AES negotiation modes the wrapped `@apocaliss92/nodegree` client accepts.
23982
+ * Mirrors the library's `encryption` union — kept local so the addon validates
23983
+ * the operator-supplied value at the system boundary without importing a runtime
23984
+ * value the library does not export. `auto` tries V2 (GCM) then V1 (ECB).
23985
+ */
23986
+ var GREE_ENCRYPTION_MODES = [
23987
+ "auto",
23988
+ "v1",
23989
+ "v2"
23990
+ ];
23991
+ var GreeEncryptionSchema = _enum(GREE_ENCRYPTION_MODES);
23992
+ /**
23993
+ * Operator-supplied CONNECTION for ONE Gree air conditioner (standalone mode —
23994
+ * the Reolink / Ecowitt pattern). Gree is LOCAL-ONLY (a directed-broadcast bind
23995
+ * handshake + per-device AES control over UDP), so the connection carries no
23996
+ * credentials — only the AC's LAN address and the UDP tuning knobs. The
23997
+ * `broadcastAddr` is the directed target the bind scan is aimed at: point it at
23998
+ * the AC's own IP (unicast-directed) or the subnet broadcast (e.g.
23999
+ * `192.168.1.255`). Empty = the library's default global broadcast.
24000
+ */
24001
+ var greeConfigSchema = object({
24002
+ /** The AC's LAN IP address — the directed-bind target. Required for a manual
24003
+ * standalone add (the operator types it). */
24004
+ host: string().default("").describe("Air conditioner LAN IP address"),
24005
+ /** Directed broadcast address for the bind scan (e.g. `192.168.1.255`). Empty =
24006
+ * the library's default global broadcast; when a `host` is set the scan is
24007
+ * aimed at it directly. */
24008
+ broadcastAddr: string().default("").describe("Directed broadcast address for the bind scan"),
24009
+ /** UDP request timeout in ms. */
24010
+ timeoutMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(500).max(3e4).default(3e3)).describe("UDP request timeout (ms)"),
24011
+ /** Retry count per UDP request. */
24012
+ retries: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(0).max(10).default(3)).describe("Retries per UDP request"),
24013
+ /** AES negotiation mode. */
24014
+ encryption: GreeEncryptionSchema.default("auto").describe("AES cipher negotiation")
24015
+ });
24016
+ /**
24017
+ * Build the `NodegreeOptions` the wrapped client constructor expects from the
24018
+ * validated connection. Pure: same config in → same options out. `host` and
24019
+ * `broadcastAddr` are NOT constructor options — they are passed to `discover()`
24020
+ * per scan — so they are intentionally omitted here.
24021
+ */
24022
+ function toNodegreeOptions(config) {
24023
+ return {
24024
+ timeoutMs: config.timeoutMs,
24025
+ retries: config.retries,
24026
+ encryption: config.encryption
24027
+ };
24028
+ }
24029
+ /**
24030
+ * Coerce a loose settings blob (the manual-creation form values, or a persisted
24031
+ * `connection` blob) through the connection schema, applying all defaults.
24032
+ * Throws a `ZodError` on invalid input so the caller surfaces a clear error at
24033
+ * the system boundary.
24034
+ */
24035
+ function settingsToGreeConfig(settings) {
24036
+ return greeConfigSchema.parse(settings ?? {});
24037
+ }
24038
+ /**
24039
+ * Persisted config for a Gree AC {@link import('@camstack/types').DeviceType.Container}
24040
+ * device. The operator-supplied CONNECTION lives directly on the device (Reolink
24041
+ * pattern) — there is no broker registry. The device owns its own live
24042
+ * `@apocaliss92/nodegree` client keyed on its own `connectionKey`; its AC +
24043
+ * toggle accessory children resolve the bound handle via that key.
24044
+ *
24045
+ * `greeMac` is the durable device identity (resolved by the create-time bind);
24046
+ * `greeIp` is the last-known LAN address; `connectionKey` is the per-device
24047
+ * connection-resolver key (the device's own stableId — decoupled from any
24048
+ * broker); `connection` is the operator's UDP settings; `system`/`name` are
24049
+ * provenance.
24050
+ */
24051
+ var greeAcDeviceSchema = object({
24052
+ /** Durable AC identity (MAC, lowercased) resolved by the create-time bind. */
24053
+ greeMac: string().min(1).describe("Gree AC MAC address"),
24054
+ /** Last-known LAN IP (re-bound on each activate). */
24055
+ greeIp: string().optional().describe("Last known LAN IP"),
24056
+ /** Per-device connection-resolver key (the device's own stableId). */
24057
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
24058
+ /** The operator-supplied UDP connection settings the device's client dials. */
24059
+ connection: greeConfigSchema,
24060
+ system: literal("gree").optional(),
24061
+ integrationId: string().optional(),
24062
+ name: string().optional()
24063
+ });
24064
+ /**
24065
+ * Hand-written connection form for the AC device-creation UI (standalone mode —
24066
+ * Reolink pattern). The connection lives on the AC DEVICE config, not in a broker
24067
+ * registry. A `name` field is included so the operator names the AC at creation
24068
+ * time (mirrors the Reolink / Ecowitt creation form).
24069
+ */
24070
+ function buildConnectionFormSchema() {
24071
+ return { sections: [{
24072
+ id: "identity",
24073
+ title: "Air conditioner",
24074
+ description: "Gree air conditioners are controlled directly over your LAN (no cloud). Enter the AC's IP address; CamStack binds to it over UDP.",
24075
+ columns: 1,
24076
+ fields: [{
24077
+ type: "text",
24078
+ key: "name",
24079
+ label: "Name",
24080
+ required: true,
24081
+ placeholder: "Living room AC"
24082
+ }, {
24083
+ type: "text",
24084
+ key: "host",
24085
+ label: "IP address",
24086
+ required: true,
24087
+ placeholder: "192.168.1.50"
24088
+ }]
24089
+ }, {
24090
+ id: "advanced",
24091
+ title: "Advanced (UDP)",
24092
+ columns: 2,
24093
+ fields: [
24094
+ {
24095
+ type: "text",
24096
+ key: "broadcastAddr",
24097
+ label: "Broadcast address (optional)",
24098
+ required: false,
24099
+ placeholder: "192.168.1.255"
24100
+ },
24101
+ {
24102
+ type: "number",
24103
+ key: "timeoutMs",
24104
+ label: "UDP timeout (ms)",
24105
+ min: 500,
24106
+ max: 3e4,
24107
+ default: 3e3
24108
+ },
24109
+ {
24110
+ type: "number",
24111
+ key: "retries",
24112
+ label: "Retries",
24113
+ min: 0,
24114
+ max: 10,
24115
+ default: 3
24116
+ },
24117
+ {
24118
+ type: "select",
24119
+ key: "encryption",
24120
+ label: "Encryption",
24121
+ default: "auto",
24122
+ options: GREE_ENCRYPTION_MODES.map((m) => ({
24123
+ value: m,
24124
+ label: m.toUpperCase()
24125
+ }))
24126
+ }
24127
+ ]
24128
+ }] };
24129
+ }
24130
+ //#endregion
24034
24131
  //#region ../../node_modules/@apocaliss92/nodegree/dist/index.js
24035
24132
  var GreeError = class extends Error {
24036
24133
  constructor(message) {
@@ -24712,191 +24809,79 @@ var Nodegree = class {
24712
24809
  }
24713
24810
  };
24714
24811
  //#endregion
24715
- //#region src/config.ts
24716
- /**
24717
- * The AES negotiation modes the wrapped `@apocaliss92/nodegree` client accepts.
24718
- * Mirrors the library's `encryption` union — kept local so the addon validates
24719
- * the operator-supplied value at the system boundary without importing a runtime
24720
- * value the library does not export. `auto` tries V2 (GCM) then V1 (ECB).
24721
- */
24722
- var GREE_ENCRYPTION_MODES = [
24723
- "auto",
24724
- "v1",
24725
- "v2"
24726
- ];
24727
- var GreeEncryptionSchema = _enum(GREE_ENCRYPTION_MODES);
24728
- /**
24729
- * Operator-supplied settings for ONE Gree "broker" (= one LAN discovery scope on
24730
- * one node). Gree is LOCAL-ONLY (UDP broadcast discovery + per-device AES
24731
- * control), so a broker carries no credentials — only the broadcast target and
24732
- * the UDP tuning knobs. Each node running the addon owns its own socket; the
24733
- * `broadcastAddr` lets the operator point discovery at the right subnet
24734
- * (directed broadcast) when the node has multiple interfaces.
24735
- */
24736
- var greeConfigSchema = object({
24737
- /** Directed broadcast address for discovery (e.g. `192.168.1.255`). Empty =
24738
- * the library's default global broadcast. */
24739
- broadcastAddr: string().default("").describe("Directed broadcast address for discovery"),
24740
- /** UDP request timeout in ms. */
24741
- timeoutMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(500).max(3e4).default(3e3)).describe("UDP request timeout (ms)"),
24742
- /** Retry count per UDP request. */
24743
- retries: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(0).max(10).default(3)).describe("Retries per UDP request"),
24744
- /** AES negotiation mode. */
24745
- encryption: GreeEncryptionSchema.default("auto").describe("AES cipher negotiation")
24746
- });
24747
- /**
24748
- * Build the `NodegreeOptions` the wrapped client constructor expects from the
24749
- * validated addon config. Pure: same config in → same options out. The
24750
- * `broadcastAddr` is NOT a constructor option — it is passed to `discover()` per
24751
- * scan — so it is intentionally omitted here.
24752
- */
24753
- function toNodegreeOptions(config) {
24754
- return {
24755
- timeoutMs: config.timeoutMs,
24756
- retries: config.retries,
24757
- encryption: config.encryption
24758
- };
24812
+ //#region src/gree-gateway.ts
24813
+ /** Lowercase a MAC for stable map keying (Gree echoes mixed-case MACs). */
24814
+ function macKey(mac) {
24815
+ return mac.toLowerCase();
24759
24816
  }
24760
- /** Top-level addon config — an ordered list of broker entries (default empty). */
24761
- var greeAddonConfigSchema = object({ brokers: array(object({
24762
- /** Stable opaque identifier — e.g. 'gree_001', 'gree_002'. */
24763
- id: string().min(1),
24764
- /** Human-readable label shown in the admin UI. */
24765
- name: string().min(1),
24766
- /** Validated discovery-scope settings. */
24767
- connection: greeConfigSchema,
24768
- /** FK to the spawning integration — auto-cleanup on integration delete. */
24769
- integrationId: string().optional()
24770
- })).default([]) });
24771
24817
  /**
24772
- * Coerce a loose settings blob (from the broker `add`/`setSettings` cap) through
24773
- * the connection schema, applying all defaults. Throws a `ZodError` on invalid
24774
- * input so the caller surfaces a clear error at the system boundary.
24818
+ * Per-connection registry that device classes use to reach a bound Gree AC handle
24819
+ * (and the bind-scan rows) for a given AC device (standalone mode — the
24820
+ * connection lives on the AC device, not a broker).
24821
+ *
24822
+ * The kernel constructs device classes with only a `DeviceContext` — it cannot
24823
+ * thread the handle in as a constructor arg. Like the Ecowitt / Dreame addon's
24824
+ * facade resolver, we keep it simple and in-process: the AC device's integration
24825
+ * manager owns the connection surface per `connectionKey` (the device's own
24826
+ * stableId) and publishes it here; the AC's accessory children resolve their live
24827
+ * `AcDevice` handle by `(connectionKey, mac)`.
24775
24828
  */
24776
- function settingsToGreeConfig(settings) {
24777
- return greeConfigSchema.parse(settings ?? {});
24829
+ var GreeConnectionResolver = class {
24830
+ #surfaces = /* @__PURE__ */ new Map();
24831
+ /** Publish or remove the connection surface for a connection key. `null` removes. */
24832
+ set(connectionKey, surface) {
24833
+ if (surface === null) {
24834
+ this.#surfaces.delete(connectionKey);
24835
+ return;
24836
+ }
24837
+ this.#surfaces.set(connectionKey, surface);
24838
+ }
24839
+ /** The bind-scan rows for a connection key, or empty when unknown. */
24840
+ discovered(connectionKey) {
24841
+ return this.#surfaces.get(connectionKey)?.discovered ?? [];
24842
+ }
24843
+ /** True when the connection has a published surface (i.e. it is bound). */
24844
+ has(connectionKey) {
24845
+ return this.#surfaces.has(connectionKey);
24846
+ }
24847
+ /** The active connection keys (one entry per published surface). */
24848
+ list() {
24849
+ return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
24850
+ }
24851
+ /**
24852
+ * Resolve the bound {@link AcDevice} handle for a `(connectionKey, mac)` pair,
24853
+ * or null when the connection is unknown or the AC has not been bound.
24854
+ */
24855
+ getDevice(connectionKey, mac) {
24856
+ return this.#surfaces.get(connectionKey)?.handles.get(macKey(mac)) ?? null;
24857
+ }
24858
+ /** Remove all registered surfaces (called on full shutdown). */
24859
+ clear() {
24860
+ this.#surfaces.clear();
24861
+ }
24862
+ };
24863
+ /** The single in-process per-connection resolver shared between the AC device's
24864
+ * manager and its accessory children. */
24865
+ var greeConnections = new GreeConnectionResolver();
24866
+ //#endregion
24867
+ //#region src/gree-integration-manager.ts
24868
+ function defaultFacade(config) {
24869
+ return new Nodegree(toNodegreeOptions(config));
24778
24870
  }
24871
+ var DEFAULT_POLL_INTERVAL_MS = 3e4;
24779
24872
  /**
24780
- * Hand-written settings form for the broker/integration creation UI. Mirrors the
24781
- * Dreame / Homematic broker-settings form shape a flat set of sections the
24782
- * admin UI renders into the "Add Gree scope" modal.
24783
- */
24784
- function buildConnectionFormSchema() {
24785
- return { sections: [{
24786
- id: "discovery",
24787
- title: "Gree discovery scope",
24788
- description: "Gree air conditioners are controlled directly over your LAN (no cloud). Optionally point discovery at a specific subnet broadcast address.",
24789
- columns: 1,
24790
- fields: [{
24791
- type: "text",
24792
- key: "broadcastAddr",
24793
- label: "Broadcast address (optional)",
24794
- required: false,
24795
- placeholder: "192.168.1.255"
24796
- }]
24797
- }, {
24798
- id: "advanced",
24799
- title: "Advanced (UDP)",
24800
- columns: 2,
24801
- fields: [
24802
- {
24803
- type: "number",
24804
- key: "timeoutMs",
24805
- label: "UDP timeout (ms)",
24806
- min: 500,
24807
- max: 3e4,
24808
- default: 3e3
24809
- },
24810
- {
24811
- type: "number",
24812
- key: "retries",
24813
- label: "Retries",
24814
- min: 0,
24815
- max: 10,
24816
- default: 3
24817
- },
24818
- {
24819
- type: "select",
24820
- key: "encryption",
24821
- label: "Encryption",
24822
- default: "auto",
24823
- options: GREE_ENCRYPTION_MODES.map((m) => ({
24824
- value: m,
24825
- label: m.toUpperCase()
24826
- }))
24827
- }
24828
- ]
24829
- }] };
24830
- }
24831
- //#endregion
24832
- //#region src/gree-gateway.ts
24833
- /** Lowercase a MAC for stable map keying (Gree echoes mixed-case MACs). */
24834
- function macKey$2(mac) {
24835
- return mac.toLowerCase();
24836
- }
24837
- /**
24838
- * Per-broker registry that device classes use to reach a bound Gree AC handle
24839
- * (and the discovery rows) for a given discovery scope.
24840
- *
24841
- * The kernel constructs device classes with only a `DeviceContext` — it cannot
24842
- * thread the handle in as a constructor arg. Like the Dreame addon's
24843
- * `dreameFacades`, we keep it simple and in-process: the integration manager
24844
- * owns the connection surface per registered broker and publishes it here;
24845
- * device classes resolve their live `AcDevice` handle by `(brokerId, mac)`.
24846
- */
24847
- var GreeConnectionResolver = class {
24848
- #surfaces = /* @__PURE__ */ new Map();
24849
- /** Publish or remove the connection surface for a broker id. `null` removes. */
24850
- set(brokerId, surface) {
24851
- if (surface === null) {
24852
- this.#surfaces.delete(brokerId);
24853
- return;
24854
- }
24855
- this.#surfaces.set(brokerId, surface);
24856
- }
24857
- /** The discovery rows for a broker id, or empty when unknown. */
24858
- discovered(brokerId) {
24859
- return this.#surfaces.get(brokerId)?.discovered ?? [];
24860
- }
24861
- /** True when the broker has a published surface (i.e. its scope is active). */
24862
- has(brokerId) {
24863
- return this.#surfaces.has(brokerId);
24864
- }
24865
- /** The active broker ids (one entry per published surface). */
24866
- list() {
24867
- return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
24868
- }
24869
- /**
24870
- * Resolve the bound {@link AcDevice} handle for a `(brokerId, mac)` pair, or
24871
- * null when the broker is unknown or the AC has not been bound.
24872
- */
24873
- getDevice(brokerId, mac) {
24874
- return this.#surfaces.get(brokerId)?.handles.get(macKey$2(mac)) ?? null;
24875
- }
24876
- /** Remove all registered surfaces (called on full shutdown). */
24877
- clear() {
24878
- this.#surfaces.clear();
24879
- }
24880
- };
24881
- /** The single in-process per-broker connection resolver shared between the
24882
- * manager and device instances. */
24883
- var greeConnections = new GreeConnectionResolver();
24884
- //#endregion
24885
- //#region src/gree-integration-manager.ts
24886
- function defaultFacade(config) {
24887
- return new Nodegree(toNodegreeOptions(config));
24888
- }
24889
- var DEFAULT_POLL_INTERVAL_MS = 3e4;
24890
- /**
24891
- * Wraps exactly one `@apocaliss92/nodegree` facade (= one LAN discovery scope)
24892
- * with observable status + lifecycle. {@link start} runs a discovery scan and
24893
- * binds (creates) an {@link AcDevice} handle per found AC, publishing the
24894
- * connection surface on the shared resolver and starting per-AC polling.
24895
- * {@link stop} closes the facade; {@link applyConnection} does both atomically
24896
- * when the operator changes the scope settings.
24897
- *
24898
- * Mirrors `DreameIntegrationManager`. Binding is best-effort per AC so one
24899
- * offline unit does not fail the whole scope.
24873
+ * Wraps exactly one `@apocaliss92/nodegree` facade bound to a SINGLE Gree AC
24874
+ * (standalone mode the connection lives on the AC device). {@link start} runs a
24875
+ * DIRECTED bind scan aimed at the configured `host` (falling back to the
24876
+ * `broadcastAddr` / global broadcast), matches the responder for that host,
24877
+ * binds (creates) its {@link AcDevice} handle, publishes the connection surface
24878
+ * on the shared resolver keyed by the device's `connectionKey`, and starts
24879
+ * polling. {@link stop} closes the facade; {@link applyConnection} does both
24880
+ * atomically when the operator changes the connection.
24881
+ *
24882
+ * {@link bindOnce} is the create-time helper: a static one-shot directed scan
24883
+ * that returns the AC's durable identity (MAC/ip/name) WITHOUT holding a handle,
24884
+ * so `onCreateDevice` can persist the identity before the device exists.
24900
24885
  */
24901
24886
  var GreeIntegrationManager = class {
24902
24887
  #id;
@@ -24908,6 +24893,8 @@ var GreeIntegrationManager = class {
24908
24893
  #surfaceSink;
24909
24894
  #pollIntervalMs;
24910
24895
  #makeFacade;
24896
+ /** When set, only the responder for this MAC is bound (standalone: one AC). */
24897
+ #expectMac;
24911
24898
  #facade = null;
24912
24899
  #handles = /* @__PURE__ */ new Map();
24913
24900
  #status = "disconnected";
@@ -24924,6 +24911,7 @@ var GreeIntegrationManager = class {
24924
24911
  this.#surfaceSink = options.surfaceSink;
24925
24912
  this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
24926
24913
  this.#makeFacade = options.makeFacade ?? defaultFacade;
24914
+ this.#expectMac = options.expectMac !== void 0 ? macKey(options.expectMac) : null;
24927
24915
  }
24928
24916
  /** The connection settings the manager is currently configured with. */
24929
24917
  getConnection() {
@@ -24937,6 +24925,7 @@ var GreeIntegrationManager = class {
24937
24925
  kind: "gree",
24938
24926
  status: this.#status,
24939
24927
  info: {
24928
+ host: this.#connection.host,
24940
24929
  broadcastAddr: this.#connection.broadcastAddr,
24941
24930
  deviceCount: this.#deviceCount
24942
24931
  },
@@ -24945,14 +24934,14 @@ var GreeIntegrationManager = class {
24945
24934
  };
24946
24935
  }
24947
24936
  /**
24948
- * Build the facade, run a discovery scan, and bind one handle per found AC
24949
- * (starting per-AC polling). Publishes the connection surface. Sets `connected`
24950
- * on success (even with zero ACs an empty subnet is not an error). Surfaces a
24951
- * clear bind error on {@link GreeAuthError}.
24937
+ * Build the facade, run a DIRECTED bind scan (aimed at `host`), bind the
24938
+ * responder for the expected AC, and publish the connection surface. Sets
24939
+ * `connected` on a successful bind. Surfaces a clear bind error on
24940
+ * {@link GreeAuthError}.
24952
24941
  */
24953
24942
  async start() {
24954
24943
  if (this.#facade !== null) {
24955
- this.#logger.warn("GreeIntegrationManager: start() called while running — stop first", { tags: { brokerId: this.#id } });
24944
+ this.#logger.warn("GreeIntegrationManager: start() called while running — stop first", { tags: { connectionKey: this.#id } });
24956
24945
  return;
24957
24946
  }
24958
24947
  this.#status = "connecting";
@@ -24961,18 +24950,19 @@ var GreeIntegrationManager = class {
24961
24950
  this.#facade = facade;
24962
24951
  try {
24963
24952
  const discovered = await facade.discover(this.#discoverOpts());
24953
+ const matched = this.#selectResponders(discovered);
24964
24954
  const handles = /* @__PURE__ */ new Map();
24965
- for (const dev of discovered) try {
24955
+ for (const dev of matched) try {
24966
24956
  const ac = await facade.createAc({
24967
24957
  ip: dev.ip,
24968
24958
  port: dev.port,
24969
24959
  mac: dev.mac
24970
24960
  });
24971
24961
  if (this.#pollIntervalMs > 0) ac.startPolling(this.#pollIntervalMs);
24972
- handles.set(macKey$2(dev.mac), ac);
24962
+ handles.set(macKey(dev.mac), ac);
24973
24963
  } catch (err) {
24974
24964
  this.#logger.warn("GreeIntegrationManager: bind (createAc) failed", {
24975
- tags: { brokerId: this.#id },
24965
+ tags: { connectionKey: this.#id },
24976
24966
  meta: {
24977
24967
  mac: dev.mac,
24978
24968
  ip: dev.ip,
@@ -24982,22 +24972,24 @@ var GreeIntegrationManager = class {
24982
24972
  }
24983
24973
  this.#handles = handles;
24984
24974
  const surface = {
24985
- discovered,
24975
+ discovered: matched,
24986
24976
  handles
24987
24977
  };
24988
24978
  this.#surfaceSink.set(this.#id, surface);
24989
24979
  this.#deviceCount = handles.size;
24990
- this.#status = "connected";
24991
- this.#error = null;
24980
+ this.#status = handles.size > 0 ? "connected" : "error";
24981
+ this.#error = handles.size > 0 ? null : "AC not reachable — bind returned no handle";
24992
24982
  this.#lastCheckedAt = Date.now();
24993
- this.#onConnected(this.#id);
24994
- this.#logger.info("GreeIntegrationManager: connected", {
24995
- tags: { brokerId: this.#id },
24996
- meta: {
24997
- discovered: discovered.length,
24998
- bound: handles.size
24999
- }
25000
- });
24983
+ if (handles.size > 0) {
24984
+ this.#onConnected(this.#id);
24985
+ this.#logger.info("GreeIntegrationManager: connected", {
24986
+ tags: { connectionKey: this.#id },
24987
+ meta: {
24988
+ discovered: discovered.length,
24989
+ bound: handles.size
24990
+ }
24991
+ });
24992
+ } else this.#onDisconnected(this.#id);
25001
24993
  } catch (err) {
25002
24994
  this.#status = "error";
25003
24995
  this.#error = err instanceof GreeAuthError ? "device bind failed — check the AC is reachable" : errMsg(err);
@@ -25005,7 +24997,7 @@ var GreeIntegrationManager = class {
25005
24997
  this.#surfaceSink.set(this.#id, null);
25006
24998
  this.#onDisconnected(this.#id);
25007
24999
  this.#logger.warn("GreeIntegrationManager: scan failed", {
25008
- tags: { brokerId: this.#id },
25000
+ tags: { connectionKey: this.#id },
25009
25001
  meta: { error: this.#error }
25010
25002
  });
25011
25003
  throw err;
@@ -25025,7 +25017,7 @@ var GreeIntegrationManager = class {
25025
25017
  await facade.close();
25026
25018
  } catch (err) {
25027
25019
  this.#logger.warn("GreeIntegrationManager: facade close failed", {
25028
- tags: { brokerId: this.#id },
25020
+ tags: { connectionKey: this.#id },
25029
25021
  meta: { error: errMsg(err) }
25030
25022
  });
25031
25023
  }
@@ -25037,534 +25029,87 @@ var GreeIntegrationManager = class {
25037
25029
  this.#connection = conn;
25038
25030
  await this.start();
25039
25031
  }
25032
+ /** Keep only the responder(s) this manager should bind: the one matching the
25033
+ * expected MAC when set, else the one matching the configured host, else all. */
25034
+ #selectResponders(discovered) {
25035
+ if (this.#expectMac !== null) return discovered.filter((d) => macKey(d.mac) === this.#expectMac);
25036
+ const host = this.#connection.host.trim();
25037
+ if (host.length > 0) {
25038
+ const byHost = discovered.filter((d) => d.ip === host);
25039
+ if (byHost.length > 0) return byHost;
25040
+ }
25041
+ return [...discovered];
25042
+ }
25040
25043
  #discoverOpts() {
25041
25044
  const out = { timeoutMs: this.#connection.timeoutMs };
25042
- if (this.#connection.broadcastAddr.length > 0) out.broadcastAddr = this.#connection.broadcastAddr;
25045
+ const broadcast = resolveBroadcastTarget(this.#connection);
25046
+ if (broadcast.length > 0) out.broadcastAddr = broadcast;
25043
25047
  return out;
25044
25048
  }
25045
25049
  };
25046
- /** True when two configs are the "same connection" (no facade rebuild needed). */
25047
- function sameConnection(a, b) {
25048
- if (a === null || b === null) return false;
25049
- return a.broadcastAddr === b.broadcastAddr && a.timeoutMs === b.timeoutMs && a.retries === b.retries && a.encryption === b.encryption;
25050
- }
25051
- //#endregion
25052
- //#region src/gree-broker-registry.ts
25053
- /**
25054
- * Manages N live Gree discovery scopes ("brokers"), each backed by a
25055
- * {@link GreeIntegrationManager}. Allocates stable ids (`gree_001`, …), supports
25056
- * CRUD, an integration FK index for cascade-delete, and lifecycle helpers.
25057
- * Mirrors `DreoBrokerRegistry`.
25058
- */
25059
- var GreeBrokerRegistry = class {
25060
- #logger;
25061
- #onBrokerConnected;
25062
- #onBrokerDisconnected;
25063
- #makeManager;
25064
- #managers = /* @__PURE__ */ new Map();
25065
- #integrationToBroker = /* @__PURE__ */ new Map();
25066
- #nextId = 1;
25067
- constructor(logger, deps = {}) {
25068
- this.#logger = logger;
25069
- this.#onBrokerConnected = deps.onBrokerConnected ?? (() => void 0);
25070
- this.#onBrokerDisconnected = deps.onBrokerDisconnected ?? (() => void 0);
25071
- this.#makeManager = deps.makeManager ?? ((opts) => new GreeIntegrationManager({
25072
- id: opts.id,
25073
- name: opts.name,
25074
- connection: opts.connection,
25075
- logger: opts.logger,
25076
- onConnected: opts.onConnected,
25077
- onDisconnected: opts.onDisconnected,
25078
- surfaceSink: greeConnections
25079
- }));
25080
- }
25081
- /** Restore persisted broker entries on boot (best-effort per manager). */
25082
- async restore(entries) {
25083
- for (const entry of entries) this.#seedCounter(entry.id);
25084
- for (const entry of entries) try {
25085
- await this.#startManager(entry);
25086
- } catch (err) {
25087
- this.#logger.warn("GreeBrokerRegistry: failed to restore manager", {
25088
- tags: { brokerId: entry.id },
25089
- meta: { error: errMsg(err) }
25090
- });
25091
- }
25092
- }
25093
- /** Stop all managers and clear state. */
25094
- async shutdown() {
25095
- const ids = Array.from(this.#managers.keys());
25096
- await Promise.all(ids.map(async (id) => {
25097
- try {
25098
- await this.#managers.get(id)?.stop();
25099
- } catch (err) {
25100
- this.#logger.warn("GreeBrokerRegistry: shutdown stop failed", {
25101
- tags: { brokerId: id },
25102
- meta: { error: errMsg(err) }
25103
- });
25104
- }
25105
- }));
25106
- this.#managers.clear();
25107
- this.#integrationToBroker.clear();
25108
- greeConnections.clear();
25109
- }
25110
- setOnBrokerConnected(cb) {
25111
- this.#onBrokerConnected = cb;
25112
- }
25113
- setOnBrokerDisconnected(cb) {
25114
- this.#onBrokerDisconnected = cb;
25115
- }
25116
- async createEntry(name, connection, opts = {}) {
25117
- const id = this.#allocateId();
25118
- const entry = {
25119
- id,
25120
- name,
25121
- connection,
25122
- ...opts.integrationId !== void 0 ? { integrationId: opts.integrationId } : {}
25123
- };
25124
- await this.#startManager(entry);
25125
- if (opts.integrationId !== void 0) this.#integrationToBroker.set(opts.integrationId, id);
25126
- return entry;
25127
- }
25128
- async removeEntry(id) {
25129
- const mgr = this.#managers.get(id);
25130
- if (!mgr) throw new Error(`GreeBrokerRegistry: unknown broker id "${id}"`);
25131
- for (const [integrationId, brokerId] of this.#integrationToBroker.entries()) if (brokerId === id) this.#integrationToBroker.delete(integrationId);
25132
- this.#managers.delete(id);
25133
- try {
25134
- await mgr.stop();
25135
- } catch (err) {
25136
- this.#logger.warn("GreeBrokerRegistry: removeEntry stop failed", {
25137
- tags: { brokerId: id },
25138
- meta: { error: errMsg(err) }
25139
- });
25140
- }
25141
- }
25142
- async updateEntry(id, connection) {
25143
- const mgr = this.#managers.get(id);
25144
- if (!mgr) throw new Error(`GreeBrokerRegistry: unknown broker id "${id}"`);
25145
- await mgr.applyConnection(connection);
25146
- return {
25147
- id,
25148
- name: mgr.getInfo().name,
25149
- connection
25150
- };
25151
- }
25152
- linkIntegration(integrationId, brokerId) {
25153
- this.#integrationToBroker.set(integrationId, brokerId);
25154
- }
25155
- getBrokerIdByIntegrationId(integrationId) {
25156
- const brokerId = this.#integrationToBroker.get(integrationId);
25157
- if (brokerId === void 0) throw new Error(`GreeBrokerRegistry: no broker linked for integration id "${integrationId}"`);
25158
- return brokerId;
25159
- }
25160
- list() {
25161
- return Array.from(this.#managers.values()).map((m) => m.getInfo());
25162
- }
25163
- get(id) {
25164
- return this.#managers.get(id)?.getInfo() ?? null;
25165
- }
25166
- getConnection(id) {
25167
- return this.#managers.get(id)?.getConnection() ?? null;
25168
- }
25169
- size() {
25170
- return this.#managers.size;
25171
- }
25172
- connectedCount() {
25173
- let count = 0;
25174
- for (const mgr of this.#managers.values()) if (mgr.getInfo().status === "connected") count++;
25175
- return count;
25176
- }
25177
- async #startManager(entry) {
25178
- if (this.#managers.has(entry.id)) {
25179
- if (entry.integrationId !== void 0) this.#integrationToBroker.set(entry.integrationId, entry.id);
25180
- return;
25181
- }
25182
- const mgr = this.#makeManager({
25183
- id: entry.id,
25184
- name: entry.name,
25185
- connection: entry.connection,
25186
- logger: this.#logger,
25187
- onConnected: (id) => this.#onBrokerConnected(id),
25188
- onDisconnected: (id) => this.#onBrokerDisconnected(id)
25189
- });
25190
- this.#managers.set(entry.id, mgr);
25191
- if (entry.integrationId !== void 0) this.#integrationToBroker.set(entry.integrationId, entry.id);
25192
- await mgr.start();
25193
- }
25194
- #allocateId() {
25195
- const id = `gree_${String(this.#nextId).padStart(3, "0")}`;
25196
- this.#nextId++;
25197
- return id;
25198
- }
25199
- #seedCounter(id) {
25200
- const match = /^gree_(\d+)$/.exec(id);
25201
- if (match === null || match[1] === void 0) return;
25202
- const n = parseInt(match[1], 10);
25203
- if (!isNaN(n)) this.#nextId = Math.max(this.#nextId, n + 1);
25204
- }
25205
- };
25206
- //#endregion
25207
- //#region src/gree-broker-provider.ts
25208
- /** Kind tag registered by this provider — matches the `listProviders` entry. */
25209
- var GREE_KIND = "gree";
25210
25050
  /**
25211
- * Construct the `broker` cap provider for the Gree addon. Pure builder: all
25212
- * side-effecting deps are injected so the returned object is unit-testable.
25213
- * Mirrors `buildDreoBrokerProvider`.
25214
- *
25215
- * Gree carries no secret — a "broker" is just a LAN discovery scope — so the
25216
- * settings getters expose the connection verbatim (no password redaction).
25051
+ * Resolve the directed-scan target for a connection: the explicit
25052
+ * `broadcastAddr` when set, else the AC `host` (unicast-directed scan), else the
25053
+ * empty string (library default global broadcast).
25217
25054
  */
25218
- function buildGreeBrokerProvider(deps) {
25219
- const { ownerAddonId, registry, getBrokers, persistBrokers, cascadeRemoveDevices, logger } = deps;
25220
- const owns = (id) => getBrokers().some((b) => b.id === id);
25221
- return {
25222
- list: async ({ kind }) => {
25223
- if (kind && kind !== GREE_KIND) return [];
25224
- return registry.list().map((info) => ({
25225
- ...info,
25226
- addonId: ownerAddonId
25227
- }));
25228
- },
25229
- get: async ({ id }) => {
25230
- const info = registry.get(id);
25231
- return info ? {
25232
- ...info,
25233
- addonId: ownerAddonId
25234
- } : null;
25235
- },
25236
- listProviders: async () => [{
25237
- addonId: ownerAddonId,
25238
- kinds: [{
25239
- kind: GREE_KIND,
25240
- label: "Gree"
25241
- }]
25242
- }],
25243
- add: async ({ kind, name, settings }) => {
25244
- if (kind !== GREE_KIND) throw new Error(`provider-gree: only kind '${GREE_KIND}' is handled here (got '${kind}')`);
25245
- const entry = await registry.createEntry(name, settingsToGreeConfig(settings));
25246
- await persistBrokers([...getBrokers(), entry]);
25247
- return { id: entry.id };
25248
- },
25249
- remove: async ({ id }) => {
25250
- if (!owns(id)) return;
25251
- await registry.removeEntry(id);
25252
- await cascadeRemoveDevices(id).catch((err) => {
25253
- logger.warn("gree: broker cascade-remove threw", {
25254
- tags: { brokerId: id },
25255
- meta: { error: errMsg(err) }
25256
- });
25257
- });
25258
- await persistBrokers(getBrokers().filter((b) => b.id !== id));
25259
- },
25260
- testConnection: async ({ id }) => {
25261
- if (!owns(id)) return {
25262
- ok: false,
25263
- error: "unknown broker"
25264
- };
25265
- const info = registry.get(id);
25266
- if (!info) return {
25267
- ok: false,
25268
- error: "unknown broker"
25269
- };
25270
- if (info.status === "connected") return {
25271
- ok: true,
25272
- latencyMs: info.lastCheckedAt ? Math.max(0, Date.now() - info.lastCheckedAt) : 0
25273
- };
25274
- return {
25275
- ok: false,
25276
- error: info.error ?? `status: ${info.status}`
25277
- };
25278
- },
25279
- getSettings: async ({ id }) => {
25280
- const entry = getBrokers().find((b) => b.id === id);
25281
- if (!entry) return null;
25282
- return { ...entry.connection };
25283
- },
25284
- setSettings: async ({ id, settings }) => {
25285
- if (!owns(id)) return;
25286
- if (!getBrokers().find((b) => b.id === id)) return;
25287
- const parsed = settingsToGreeConfig(settings);
25288
- const updated = await registry.updateEntry(id, parsed);
25289
- await persistBrokers(getBrokers().map((b) => b.id === id ? updated : b));
25290
- },
25291
- getBrokerConfig: async ({ id }) => {
25292
- const entry = getBrokers().find((b) => b.id === id);
25293
- if (!entry) return null;
25294
- return { ...entry.connection };
25295
- },
25296
- getSettingsSchema: async ({ kind }) => {
25297
- if (kind !== GREE_KIND) return null;
25298
- return buildConnectionFormSchema();
25299
- },
25300
- testSettings: async ({ kind, settings }) => {
25301
- if (kind !== GREE_KIND) return {
25302
- ok: false,
25303
- error: `unsupported kind: ${kind}`
25304
- };
25305
- try {
25306
- settingsToGreeConfig(settings);
25307
- return { ok: true };
25308
- } catch (err) {
25309
- return {
25310
- ok: false,
25311
- error: errMsg(err)
25312
- };
25313
- }
25314
- },
25315
- publish: async () => null,
25316
- subscribe: async () => ({ subscriptionId: "" }),
25317
- unsubscribe: async () => void 0,
25318
- getState: async () => null,
25319
- getStatus: async () => ({
25320
- brokerCount: registry.size(),
25321
- connectedCount: registry.connectedCount()
25322
- })
25323
- };
25324
- }
25325
- //#endregion
25326
- //#region src/gree-discovery.ts
25327
- /** Lowercase a MAC for stable keying (Gree echoes mixed-case MACs). */
25328
- function macKey$1(mac) {
25329
- return mac.toLowerCase();
25055
+ function resolveBroadcastTarget(connection) {
25056
+ const broadcast = connection.broadcastAddr.trim();
25057
+ if (broadcast.length > 0) return broadcast;
25058
+ return connection.host.trim();
25330
25059
  }
25331
25060
  /**
25332
- * Map Gree discovery rows to adoption candidates one Thermostat-type candidate
25333
- * per AC. Every discovered AC is a valid candidate (there is no "unsupported"
25334
- * sub-kind they are all air conditioners). Pure: no network I/O, no side
25335
- * effects. Mirrors `buildDreoCandidates`.
25336
- */
25337
- function buildGreeCandidates(input) {
25338
- const out = [];
25339
- for (const device of input.devices) {
25340
- const adoptedId = input.adopted.get(macKey$1(device.mac)) ?? null;
25341
- out.push({
25342
- childNativeId: macKey$1(device.mac),
25343
- name: device.name.length > 0 ? device.name : device.mac,
25344
- type: DeviceType.Thermostat,
25345
- status: "online",
25346
- metadata: {
25347
- serialNumber: device.mac,
25348
- ...device.model !== void 0 ? { model: device.model } : {}
25349
- },
25350
- alreadyAdopted: adoptedId !== null,
25351
- adoptedDeviceId: adoptedId,
25352
- capabilities: ["climate-control", "fan-control"]
25353
- });
25354
- }
25355
- return out;
25356
- }
25357
- //#endregion
25358
- //#region src/gree-adoption-provider.ts
25359
- /** The single granularity this provider advertises: whole-device adoption. */
25360
- var DEVICES_FILTER = {
25361
- id: "devices",
25362
- label: "Devices",
25363
- isDefault: true
25364
- };
25365
- function macKey(mac) {
25366
- return mac.toLowerCase();
25367
- }
25368
- /** Build a `mac → CamStack deviceId` map for a single broker. */
25369
- async function adoptedMapForBroker(brokerId, listAdoptedGree) {
25370
- const all = await listAdoptedGree();
25371
- const map = /* @__PURE__ */ new Map();
25372
- for (const device of all) if (device.config["system"] === "gree" && device.config["brokerId"] === brokerId) {
25373
- const mac = device.config["greeMac"];
25374
- if (typeof mac === "string") map.set(macKey(mac), device.id);
25375
- }
25376
- return map;
25377
- }
25378
- /**
25379
- * Construct the `device-adoption` cap provider for the Gree addon. Pure builder:
25380
- * all side-effecting deps are injected. Mirrors `buildDreoAdoptionProvider` with
25381
- * a single `devices` granularity (one Container per discovered AC).
25061
+ * Create-time one-shot directed bind: build a throwaway facade, run a directed
25062
+ * scan (aimed at the connection's `host`/`broadcastAddr`), bind the matching
25063
+ * responder to prove the AC is reachable and learn its durable identity, then
25064
+ * close the facade. Returns the AC's identity (MAC/ip/name/model). Throws when no
25065
+ * AC responds or the bind fails — surfaced to the operator in the Add modal.
25066
+ *
25067
+ * The returned identity is what {@link import('./config.js').greeAcDeviceSchema}
25068
+ * persists on the new device; the live per-device manager re-binds on activate.
25382
25069
  */
25383
- function buildGreeAdoptionProvider(deps) {
25384
- const { registry, getBrokerIdForIntegration, listAdoptedGree, adoptDevice, removeDevice, findDeviceConfig, logger } = deps;
25385
- async function allCandidatesForBroker(brokerId) {
25386
- return buildGreeCandidates({
25387
- devices: registry.discovered(brokerId),
25388
- adopted: await adoptedMapForBroker(brokerId, listAdoptedGree)
25070
+ async function bindOnce(input) {
25071
+ const { connection, logger } = input;
25072
+ const facade = (input.makeFacade ?? defaultFacade)(connection);
25073
+ try {
25074
+ const broadcast = resolveBroadcastTarget(connection);
25075
+ const opts = { timeoutMs: connection.timeoutMs };
25076
+ if (broadcast.length > 0) opts.broadcastAddr = broadcast;
25077
+ const discovered = await facade.discover(opts);
25078
+ const host = connection.host.trim();
25079
+ const target = host.length > 0 ? discovered.find((d) => d.ip === host) ?? discovered[0] : discovered[0];
25080
+ if (target === void 0) throw new Error(host.length > 0 ? `no Gree AC responded at ${host}` : "no Gree AC responded to the discovery scan");
25081
+ const ac = await facade.createAc({
25082
+ ip: target.ip,
25083
+ port: target.port,
25084
+ mac: target.mac
25389
25085
  });
25390
- }
25391
- function applyCandidateTextFilter(cands, filterText) {
25392
- let filtered = [...cands];
25393
- if (filterText === void 0) return filtered;
25394
- const { search, adoptedOnly, unadoptedOnly } = filterText;
25395
- if (search !== void 0 && search.length > 0) {
25396
- const lower = search.toLowerCase();
25397
- filtered = filtered.filter((c) => c.name.toLowerCase().includes(lower) || (c.metadata.model?.toLowerCase().includes(lower) ?? false) || c.childNativeId.toLowerCase().includes(lower));
25398
- }
25399
- if (adoptedOnly === true) filtered = filtered.filter((c) => c.alreadyAdopted);
25400
- if (unadoptedOnly === true) filtered = filtered.filter((c) => !c.alreadyAdopted);
25401
- return filtered;
25402
- }
25403
- return {
25404
- listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
25405
- listCandidates: async ({ integrationId, page, pageSize, filterText }) => {
25406
- const filtered = applyCandidateTextFilter(await allCandidatesForBroker(await getBrokerIdForIntegration(integrationId)), filterText);
25407
- const start = (page - 1) * pageSize;
25408
- return {
25409
- candidates: filtered.slice(start, start + pageSize),
25410
- totalCount: filtered.length,
25411
- page,
25412
- pageSize
25413
- };
25414
- },
25415
- getCandidate: async ({ integrationId, childNativeId }) => {
25416
- return (await allCandidatesForBroker(await getBrokerIdForIntegration(integrationId))).find((c) => c.childNativeId === childNativeId) ?? null;
25417
- },
25418
- getStatus: async () => {
25419
- try {
25420
- const brokers = registry.list();
25421
- let candidateCount = 0;
25422
- for (const broker of brokers) candidateCount += (await allCandidatesForBroker(broker.id)).length;
25423
- const adoptedCount = (await listAdoptedGree()).filter((d) => d.config["system"] === "gree").length;
25424
- return {
25425
- lastDiscoveryAt: Date.now(),
25426
- candidateCount,
25427
- adoptedCount,
25428
- lastError: null
25429
- };
25430
- } catch (err) {
25431
- logger.warn("gree adoption: getStatus failed", { meta: { error: errMsg(err) } });
25432
- return {
25433
- lastDiscoveryAt: null,
25434
- candidateCount: 0,
25435
- adoptedCount: 0,
25436
- lastError: errMsg(err)
25437
- };
25438
- }
25439
- },
25440
- refresh: async ({ integrationId }) => {
25441
- const brokerId = await getBrokerIdForIntegration(integrationId);
25442
- const candidateCount = (await allCandidatesForBroker(brokerId)).length;
25443
- const adoptedCount = (await listAdoptedGree()).filter((d) => d.config["system"] === "gree" && d.config["brokerId"] === brokerId).length;
25444
- return {
25445
- lastDiscoveryAt: Date.now(),
25446
- candidateCount,
25447
- adoptedCount,
25448
- lastError: null
25449
- };
25450
- },
25451
- adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
25452
- const brokerId = await getBrokerIdForIntegration(integrationId);
25453
- if (!registry.has(brokerId)) throw new Error(`gree adopt: broker ${brokerId} not active`);
25454
- const devices = registry.discovered(brokerId);
25455
- const adopted = [];
25456
- let failures = 0;
25457
- for (const childNativeId of childNativeIds) try {
25458
- const dev = devices.find((d) => macKey(d.mac) === macKey(childNativeId));
25459
- if (dev === void 0) {
25460
- logger.warn("gree adopt: device not found on broker — skipping", { meta: {
25461
- childNativeId,
25462
- brokerId
25463
- } });
25464
- failures++;
25465
- continue;
25466
- }
25467
- const name = perCandidate?.[childNativeId]?.name ?? dev.name ?? dev.mac;
25468
- const candidate = buildGreeCandidates({
25469
- devices: [dev],
25470
- adopted: /* @__PURE__ */ new Map()
25471
- })[0];
25472
- if (candidate === void 0) {
25473
- failures++;
25474
- continue;
25475
- }
25476
- const { deviceId, accessoryDeviceIds } = await adoptDevice({
25477
- greeMac: dev.mac,
25478
- greeIp: dev.ip,
25479
- brokerId,
25480
- integrationId,
25481
- type: candidate.type,
25482
- name
25483
- });
25484
- adopted.push({
25485
- childNativeId: macKey(childNativeId),
25486
- parentDeviceId: deviceId,
25487
- accessoryDeviceIds
25488
- });
25489
- } catch (err) {
25490
- logger.warn("gree adopt: failed to adopt device", { meta: {
25491
- childNativeId,
25492
- brokerId,
25493
- error: errMsg(err)
25494
- } });
25495
- failures++;
25496
- }
25497
- if (adopted.length === 0 && failures > 0) throw new Error(`gree adopt: all ${failures} adopt(s) failed`);
25498
- return { adopted };
25499
- },
25500
- release: async ({ camDeviceId }) => {
25501
- await removeDevice(camDeviceId);
25502
- },
25503
- resync: async ({ camDeviceId }) => {
25504
- const cfg = await findDeviceConfig(camDeviceId);
25505
- if (cfg === null) throw new Error(`gree resync: device ${camDeviceId} not found`);
25506
- if (cfg["system"] !== "gree") throw new Error(`gree resync: device ${camDeviceId} is not a Gree device`);
25507
- const brokerId = String(cfg["brokerId"]);
25508
- const greeMac = String(cfg["greeMac"]);
25509
- if (!registry.discovered(brokerId).some((d) => macKey(d.mac) === macKey(greeMac))) throw new Error(`gree resync: device ${greeMac} no longer present on broker ${brokerId}`);
25510
- return {
25511
- changed: false,
25512
- rebuiltChildren: 0
25513
- };
25514
- }
25515
- };
25516
- }
25517
- //#endregion
25518
- //#region src/gree-broker-device-cascade.ts
25519
- /**
25520
- * Remove every adopted Gree PARENT device (children cascade via the kernel)
25521
- * whose persisted config carries `{ system: 'gree', brokerId }`. Used when a
25522
- * broker is removed (broker.remove) and when its spawning integration is
25523
- * deleted. Best-effort per device; returns the count removed. Mirrors the
25524
- * Dreo cascade helper.
25525
- */
25526
- async function cascadeRemoveDevicesForBroker(input) {
25527
- const { reg, devices, addonId, brokerId, logger } = input;
25528
- let removed = 0;
25529
- for (const d of reg.getAllForAddon(addonId)) {
25530
- if (d.parentDeviceId !== null) continue;
25531
- const cfg = await devices.loadConfig(d.id).catch(() => ({}));
25532
- if (cfg["system"] !== "gree" || cfg["brokerId"] !== brokerId) continue;
25533
25086
  try {
25534
- await devices.remove(d.id);
25535
- removed += 1;
25536
- } catch (err) {
25537
- logger.warn("gree: broker cascade-remove failed", {
25538
- tags: { deviceId: d.id },
25539
- meta: {
25540
- brokerId,
25541
- error: errMsg(err)
25542
- }
25543
- });
25544
- }
25087
+ ac.stopPolling();
25088
+ } catch {}
25089
+ return {
25090
+ mac: target.mac,
25091
+ ip: target.ip,
25092
+ port: target.port,
25093
+ name: target.name.length > 0 ? target.name : target.mac,
25094
+ ...target.model !== void 0 ? { model: target.model } : {}
25095
+ };
25096
+ } catch (err) {
25097
+ const message = err instanceof GreeAuthError ? "device bind failed — check the AC is reachable and the encryption mode" : errMsg(err);
25098
+ logger.warn("gree bindOnce failed", { meta: {
25099
+ host: connection.host,
25100
+ error: message
25101
+ } });
25102
+ throw new Error(message);
25103
+ } finally {
25104
+ try {
25105
+ await facade.close();
25106
+ } catch {}
25545
25107
  }
25546
- return removed;
25547
25108
  }
25548
- //#endregion
25549
- //#region src/gree-broker-offline.ts
25550
- /**
25551
- * Set every Gree device belonging to `brokerId` to the requested online state.
25552
- * `devices` is the addon-scoped device list; every Gree top-level Container
25553
- * carries `{ system: 'gree', brokerId }` in its config blob.
25554
- *
25555
- * Churn-free: skips any device already in the target state. Returns the count of
25556
- * devices actually transitioned. Mirrors the Dreo offline helper.
25557
- */
25558
- function setBrokerDevicesOnline(devices, brokerId, online) {
25559
- let count = 0;
25560
- for (const dev of devices) {
25561
- if (dev.config.get("system") !== "gree") continue;
25562
- if (dev.config.get("brokerId") !== brokerId) continue;
25563
- if (dev.online === online) continue;
25564
- dev.markOnline(online);
25565
- count += 1;
25566
- }
25567
- return count;
25109
+ /** True when two configs are the "same connection" (no facade rebuild needed). */
25110
+ function sameConnection(a, b) {
25111
+ if (a === null || b === null) return false;
25112
+ return a.host === b.host && a.broadcastAddr === b.broadcastAddr && a.timeoutMs === b.timeoutMs && a.retries === b.retries && a.encryption === b.encryption;
25568
25113
  }
25569
25114
  //#endregion
25570
25115
  //#region src/gree-domain-mapping.ts
@@ -25812,12 +25357,13 @@ var FAN_COLD_START = {
25812
25357
  };
25813
25358
  /**
25814
25359
  * Persisted config every Gree AC accessory child carries. `greeMac` resolves the
25815
- * bound handle from the connection resolver; `brokerId` selects the discovery
25816
- * scope; `system`/`integrationId` are provenance.
25360
+ * bound handle from the connection resolver; `connectionKey` selects the parent
25361
+ * AC device's live connection (its own stableId); `system`/`integrationId` are
25362
+ * provenance.
25817
25363
  */
25818
25364
  var greeAcSchema = object({
25819
25365
  greeMac: string().min(1).describe("Gree AC MAC address"),
25820
- brokerId: string().min(1).describe("Registry broker id"),
25366
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
25821
25367
  system: literal("gree").optional(),
25822
25368
  integrationId: string().optional()
25823
25369
  });
@@ -25868,13 +25414,13 @@ var GreeAcDevice = class extends BaseDevice$1 {
25868
25414
  return flags;
25869
25415
  }
25870
25416
  greeMac;
25871
- brokerId;
25417
+ connectionKey;
25872
25418
  stateChangedUnsub = null;
25873
25419
  constructor(ctx) {
25874
25420
  const persisted = greeAcSchema.parse(ctx.persistedConfig ?? {});
25875
25421
  super(ctx, greeAcSchema, { type: ctx.deviceMeta.type });
25876
25422
  this.greeMac = persisted.greeMac;
25877
- this.brokerId = persisted.brokerId;
25423
+ this.connectionKey = persisted.connectionKey;
25878
25424
  this.online = true;
25879
25425
  this.updateSourceInfo({
25880
25426
  id: this.greeMac,
@@ -25882,7 +25428,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25882
25428
  });
25883
25429
  }
25884
25430
  resolveAc() {
25885
- return greeConnections.getDevice(this.brokerId, this.greeMac);
25431
+ return greeConnections.getDevice(this.connectionKey, this.greeMac);
25886
25432
  }
25887
25433
  requireAc() {
25888
25434
  const ac = this.resolveAc();
@@ -26042,9 +25588,10 @@ var SWITCH_COLD_START = {
26042
25588
  };
26043
25589
  /**
26044
25590
  * Persisted config every Gree toggle accessory child carries. Mirrors
26045
- * {@link greeAcSchema}: `greeMac` resolves the bound handle, `brokerId` selects
26046
- * the discovery scope, `system`/`integrationId` are provenance. `toggle` selects
26047
- * which of the four boolean device flags this child drives.
25591
+ * {@link greeAcSchema}: `greeMac` resolves the bound handle, `connectionKey`
25592
+ * selects the parent AC device's live connection, `system`/`integrationId` are
25593
+ * provenance. `toggle` selects which of the four boolean device flags this child
25594
+ * drives.
26048
25595
  */
26049
25596
  var greeToggleSchema = object({
26050
25597
  toggle: _enum([
@@ -26054,7 +25601,7 @@ var greeToggleSchema = object({
26054
25601
  "freshAir"
26055
25602
  ]).describe("Which Gree boolean flag"),
26056
25603
  greeMac: string().min(1).describe("Gree AC MAC address"),
26057
- brokerId: string().min(1).describe("Registry broker id"),
25604
+ connectionKey: string().min(1).describe("Per-device connection resolver key"),
26058
25605
  system: literal("gree").optional(),
26059
25606
  integrationId: string().optional()
26060
25607
  });
@@ -26072,14 +25619,14 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26072
25619
  features = [];
26073
25620
  toggle;
26074
25621
  greeMac;
26075
- brokerId;
25622
+ connectionKey;
26076
25623
  stateChangedUnsub = null;
26077
25624
  constructor(ctx) {
26078
25625
  const persisted = greeToggleSchema.parse(ctx.persistedConfig ?? {});
26079
25626
  super(ctx, greeToggleSchema, { type: ctx.deviceMeta.type });
26080
25627
  this.toggle = persisted.toggle;
26081
25628
  this.greeMac = persisted.greeMac;
26082
- this.brokerId = persisted.brokerId;
25629
+ this.connectionKey = persisted.connectionKey;
26083
25630
  this.online = true;
26084
25631
  this.updateSourceInfo({
26085
25632
  id: `${this.greeMac}:${this.toggle}`,
@@ -26087,7 +25634,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26087
25634
  });
26088
25635
  }
26089
25636
  resolveAc() {
26090
- return greeConnections.getDevice(this.brokerId, this.greeMac);
25637
+ return greeConnections.getDevice(this.connectionKey, this.greeMac);
26091
25638
  }
26092
25639
  requireAc() {
26093
25640
  const ac = this.resolveAc();
@@ -26187,49 +25734,117 @@ var GreeToggleDevice = class extends BaseDevice$1 {
26187
25734
  //#endregion
26188
25735
  //#region src/devices/gree-container-device.ts
26189
25736
  /**
26190
- * Persisted config for a Gree Container (the parent of one AC). `greeMac`
26191
- * resolves the bound handle from the connection resolver; `greeIp` is the last
26192
- * known LAN address (re-bound on each scope start); `brokerId` selects the
26193
- * discovery scope; `integrationId` is stamped onto the child; `system`/`name`
26194
- * are provenance.
26195
- */
26196
- var greeContainerSchema = object({
26197
- greeMac: string().min(1).describe("Gree AC MAC address"),
26198
- greeIp: string().optional().describe("Last known LAN IP"),
26199
- brokerId: string().min(1).describe("Registry broker id"),
26200
- integrationId: string().optional(),
26201
- system: literal("gree").optional(),
26202
- name: string().optional()
26203
- });
26204
- /**
26205
- * Parent Container device for a single Gree AC. Owns no control caps itself — it
26206
- * declares `getAccessoryChildren()` so the kernel auto-spawns the single
26207
- * {@link GreeAcDevice} accessory child (Thermostat) and reconciles it across
26208
- * reboots. Mirrors `DreoContainerDevice`.
25737
+ * Standalone-mode parent Container device for a single Gree AC (Reolink /
25738
+ * Ecowitt pattern). The operator-supplied CONNECTION lives on THIS device's
25739
+ * config; the device OWNS exactly one live `@apocaliss92/nodegree` client (via a
25740
+ * {@link GreeIntegrationManager}) keyed on its own `connectionKey` and published
25741
+ * on the in-process {@link greeConnections} resolver so its AC + toggle accessory
25742
+ * children read the live bound handle. There is NO broker and NO device-adoption
25743
+ * cap.
26209
25744
  *
26210
- * A Gree AC = one Thermostat child carrying both `climate-control` and
26211
- * `fan-control` caps, plus presence-gated `DeviceType.Switch` children for the
26212
- * panel-light / X-Fan / health / fresh-air boolean flags (mirroring the HA Gree
26213
- * integration's separate switch entities). Capability presence is resolved from
26214
- * the bound handle's `AcCapabilities` (or model defaults when no live handle).
25745
+ * It owns no control caps itself it declares `getAccessoryChildren()` so the
25746
+ * kernel auto-spawns the single {@link GreeAcDevice} accessory child (Thermostat,
25747
+ * carrying `climate-control` + `fan-control` with the swing entities) plus
25748
+ * presence-gated {@link DeviceType.Switch} children for the panel-light / X-Fan /
25749
+ * health / fresh-air boolean flags (mirroring the HA Gree integration).
25750
+ * Capability presence is resolved from the bound handle's `AcCapabilities` (or
25751
+ * model defaults when no live handle).
26215
25752
  */
26216
25753
  var GreeContainerDevice = class extends BaseDevice$1 {
26217
25754
  features = [DeviceFeature.Resyncable];
26218
25755
  greeMac;
26219
- brokerId;
25756
+ connectionKey;
26220
25757
  integrationId;
25758
+ connection;
25759
+ manager = null;
26221
25760
  constructor(ctx) {
26222
- const cfg = greeContainerSchema.parse(ctx.persistedConfig ?? {});
26223
- super(ctx, greeContainerSchema, { type: DeviceType.Container });
25761
+ const cfg = greeAcDeviceSchema.parse(ctx.persistedConfig ?? {});
25762
+ super(ctx, greeAcDeviceSchema, { type: DeviceType.Container });
26224
25763
  this.greeMac = cfg.greeMac;
26225
- this.brokerId = cfg.brokerId;
25764
+ this.connectionKey = cfg.connectionKey;
26226
25765
  this.integrationId = cfg.integrationId;
26227
- this.online = greeConnections.getDevice(this.brokerId, this.greeMac) !== null;
25766
+ this.connection = cfg.connection;
25767
+ this.online = greeConnections.getDevice(this.connectionKey, this.greeMac) !== null;
26228
25768
  this.updateSourceInfo({
26229
25769
  id: this.greeMac,
26230
25770
  system: "gree"
26231
25771
  });
26232
25772
  }
25773
+ /** Adopt a pre-built manager (created by `onCreateDevice` so the live client
25774
+ * binds once at create time) — mirrors `EcowittGatewayDevice.adoptManager`. */
25775
+ adoptManager(manager) {
25776
+ this.manager = manager;
25777
+ }
25778
+ async onActivate() {
25779
+ await super.onActivate();
25780
+ if (this.manager === null) this.ensureManager().catch((err) => {
25781
+ this.ctx.logger.warn("Gree AC initial bind failed", { meta: {
25782
+ greeMac: this.greeMac,
25783
+ error: errMsg(err)
25784
+ } });
25785
+ });
25786
+ }
25787
+ async removeDevice() {
25788
+ const mgr = this.manager;
25789
+ this.manager = null;
25790
+ if (mgr) try {
25791
+ await mgr.stop();
25792
+ } catch (err) {
25793
+ this.ctx.logger.warn("Gree AC: client stop failed", { meta: {
25794
+ greeMac: this.greeMac,
25795
+ error: errMsg(err)
25796
+ } });
25797
+ }
25798
+ }
25799
+ async applySettingsPatch(patch) {
25800
+ if (!("connection" in patch)) return;
25801
+ const parsed = greeAcDeviceSchema.shape.connection.parse(patch["connection"]);
25802
+ await this.config.setAll({ connection: parsed });
25803
+ this.connection = parsed;
25804
+ const mgr = this.manager;
25805
+ if (mgr) try {
25806
+ await mgr.applyConnection(parsed);
25807
+ } catch (err) {
25808
+ this.ctx.logger.warn("Gree AC: applyConnection failed", { meta: {
25809
+ greeMac: this.greeMac,
25810
+ error: errMsg(err)
25811
+ } });
25812
+ }
25813
+ else await this.ensureManager().catch((err) => {
25814
+ this.ctx.logger.warn("Gree AC re-bind after settings change failed", { meta: {
25815
+ greeMac: this.greeMac,
25816
+ error: errMsg(err)
25817
+ } });
25818
+ });
25819
+ }
25820
+ /** Build + start the per-device nodegree client (if not already running). */
25821
+ async ensureManager() {
25822
+ if (this.manager !== null) return;
25823
+ const mgr = new GreeIntegrationManager({
25824
+ id: this.connectionKey,
25825
+ name: this.name,
25826
+ connection: this.connection,
25827
+ logger: this.ctx.logger,
25828
+ onConnected: () => this.setAcOnline(true),
25829
+ onDisconnected: () => this.setAcOnline(false),
25830
+ surfaceSink: greeConnections,
25831
+ expectMac: this.greeMac
25832
+ });
25833
+ this.manager = mgr;
25834
+ await mgr.start();
25835
+ }
25836
+ setAcOnline(online) {
25837
+ if (this.online === online) return;
25838
+ this.markOnline(online);
25839
+ this.setChildrenOnline(online).catch(() => {});
25840
+ }
25841
+ async setChildrenOnline(online) {
25842
+ const children = await this.ctx.devices.getChildren(this.id).catch(() => []);
25843
+ for (const child of children) {
25844
+ if (child.online === online) continue;
25845
+ child.markOnline(online);
25846
+ }
25847
+ }
26233
25848
  getAccessoryChildren() {
26234
25849
  return [{
26235
25850
  stableIdSuffix: "ac",
@@ -26241,7 +25856,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26241
25856
  },
26242
25857
  config: {
26243
25858
  greeMac: this.greeMac,
26244
- brokerId: this.brokerId,
25859
+ connectionKey: this.connectionKey,
26245
25860
  system: "gree",
26246
25861
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
26247
25862
  },
@@ -26255,7 +25870,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26255
25870
  * defaults when no live handle is bound yet).
26256
25871
  */
26257
25872
  toggleChildren() {
26258
- const caps = greeConnections.getDevice(this.brokerId, this.greeMac)?.capabilities ?? DEFAULT_AC_CAPABILITIES;
25873
+ const caps = greeConnections.getDevice(this.connectionKey, this.greeMac)?.capabilities ?? DEFAULT_AC_CAPABILITIES;
26259
25874
  return [
26260
25875
  {
26261
25876
  toggle: "light",
@@ -26291,7 +25906,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26291
25906
  const childConfig = {
26292
25907
  toggle: d.toggle,
26293
25908
  greeMac: this.greeMac,
26294
- brokerId: this.brokerId,
25909
+ connectionKey: this.connectionKey,
26295
25910
  system: "gree",
26296
25911
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
26297
25912
  };
@@ -26306,284 +25921,100 @@ var GreeContainerDevice = class extends BaseDevice$1 {
26306
25921
  };
26307
25922
  //#endregion
26308
25923
  //#region src/addon.ts
26309
- /** Default multi-broker config — a fresh install starts with no scopes. */
26310
- var DEFAULTS = { brokers: [] };
26311
25924
  /**
26312
- * Gree device-provider addon (multi-scope).
25925
+ * Gree air-conditioner device-provider addon — `mode: standalone` (the Reolink /
25926
+ * Ecowitt pattern). No broker, no device-adoption cap. An AC is added as a DEVICE
25927
+ * via the manual-creation form: the CONNECTION (host IP + optional broadcastAddr
25928
+ * + UDP tuning) lives on the AC DEVICE config. `onCreateDevice` runs a DIRECTED
25929
+ * bind against the given host to learn the AC's durable identity (MAC) and prove
25930
+ * reachability, then creates a {@link GreeContainerDevice} that OWNS its own live
25931
+ * `@apocaliss92/nodegree` client and fans out its Thermostat + toggle accessory
25932
+ * children (climate-control with swing entities + the panel-light / X-Fan /
25933
+ * health / fresh-air switches — all preserved from the broker model).
26313
25934
  *
26314
- * Wraps the `@apocaliss92/nodegree` local-UDP client. One registered LAN
26315
- * discovery scope = one "broker"; each adopted AC becomes a
26316
- * {@link DeviceType.Container} parent that fans out a single {@link DeviceType.Thermostat}
26317
- * accessory child carrying `climate-control` + `fan-control`. Modelled directly
26318
- * on the Dreo / Dreame device-provider template: `broker` + `device-adoption` cap
26319
- * providers for connection + adoption, the `device-provider` cap from the base
26320
- * class, and an in-process connection resolver (`greeConnections`) the device
26321
- * classes read.
25935
+ * `instanceMode: multiple` many ACs may be added. Placement: any-node — Gree is
25936
+ * LOCAL UDP; the addon runs on whichever node shares the AC's subnet.
26322
25937
  *
26323
- * Placement: any-node Gree is LOCAL UDP. Discovery (broadcast) and per-AC AES
26324
- * control packets must reach the AC on the LAN, so the addon is eligible to run
26325
- * on whichever node shares the AC's subnet (vs hub-only for the cloud providers).
26326
- * The operator points each scope's `broadcastAddr` at the right subnet.
25938
+ * LAN UDP-broadcast auto-discovery of multiple ACs is deliberately NOT
25939
+ * implemented here that is the later Discovery-cap phase. Phase 1 is
25940
+ * add-one-AC-by-IP with a directed bind.
26327
25941
  */
26328
25942
  var GreeProviderAddon = class extends BaseDeviceProvider {
26329
25943
  addonId = "provider-gree";
26330
25944
  providerName = "Gree";
26331
25945
  deviceClasses = { [DeviceType.Container]: GreeContainerDevice };
26332
- registry = null;
26333
25946
  constructor() {
26334
- super({ ...DEFAULTS });
26335
- }
26336
- async onInitialize() {
26337
- const regs = await super.onInitialize();
26338
- this.registry = new GreeBrokerRegistry(this.ctx.logger);
26339
- this.registry.setOnBrokerConnected((brokerId) => {
26340
- this.ctx.logger.info("Gree: broker connected", { meta: { brokerId } });
26341
- this.setBrokerDevicesOnline(brokerId, true);
26342
- });
26343
- this.registry.setOnBrokerDisconnected((brokerId) => {
26344
- this.setBrokerDevicesOnline(brokerId, false);
26345
- });
26346
- await this.registry.restore(this.config.brokers);
26347
- this.ctx.logger.info("Gree: provider initialised", { meta: { brokerCount: this.config.brokers.length } });
26348
- await this.reconcileIntegrationsToBrokers();
26349
- this.subscribeIntegrationLifecycle();
26350
- return [
26351
- ...regs,
26352
- {
26353
- capability: brokerCapability,
26354
- provider: this.buildBrokerProvider()
26355
- },
26356
- {
26357
- capability: deviceAdoptionCapability,
26358
- provider: this.buildAdoptionProvider()
26359
- }
26360
- ];
25947
+ super({});
26361
25948
  }
26362
- /** Reconcile the live registry against the persisted `brokers` array after a
26363
- * settings write (UI save or `broker.*` cap). Mirrors the Dreo addon. */
26364
- async onConfigChanged() {
26365
- const reg = this.registry;
26366
- if (!reg) return;
26367
- const persisted = this.config.brokers;
26368
- const liveIds = new Set(reg.list().map((b) => b.id));
26369
- const persistedIds = new Set(persisted.map((e) => e.id));
26370
- for (const liveId of liveIds) if (!persistedIds.has(liveId)) try {
26371
- await reg.removeEntry(liveId);
26372
- } catch (err) {
26373
- this.ctx.logger.warn("Gree onConfigChanged: removeEntry failed", { meta: {
26374
- brokerId: liveId,
26375
- error: errMsg(err)
26376
- } });
26377
- }
26378
- for (const entry of persisted) {
26379
- if (entry.id && liveIds.has(entry.id)) {
26380
- if (sameConnection(entry.connection, reg.getConnection(entry.id))) continue;
26381
- try {
26382
- await reg.updateEntry(entry.id, entry.connection);
26383
- } catch (err) {
26384
- this.ctx.logger.warn("Gree onConfigChanged: updateEntry failed", { meta: {
26385
- brokerId: entry.id,
26386
- error: errMsg(err)
26387
- } });
26388
- }
26389
- continue;
26390
- }
26391
- try {
26392
- const created = await reg.createEntry(entry.name, entry.connection);
26393
- if (created.id !== entry.id) {
26394
- const next = persisted.map((b) => b === entry ? {
26395
- ...b,
26396
- id: created.id
26397
- } : b);
26398
- await this.updateGlobalSettings({ brokers: next });
26399
- }
26400
- } catch (err) {
26401
- this.ctx.logger.warn("Gree onConfigChanged: failed to start new broker", { meta: {
26402
- brokerName: entry.name,
26403
- error: errMsg(err)
26404
- } });
26405
- }
26406
- }
25949
+ async supportsDiscovery() {
25950
+ return false;
26407
25951
  }
26408
- async onShutdown() {
26409
- try {
26410
- await this.registry?.shutdown();
26411
- } catch (err) {
26412
- this.ctx.logger.warn("Gree: provider shutdown error", { meta: { error: errMsg(err) } });
26413
- }
26414
- this.registry = null;
26415
- await super.onShutdown();
25952
+ async supportsManualCreation() {
25953
+ return true;
26416
25954
  }
26417
- /** Stable id, broker-scoped so two scopes exposing the same MAC don't collide. */
26418
- generateStableId(_type, config) {
26419
- return `gree:${String(config?.["brokerId"] ?? "unknown")}:${String(config?.["greeMac"] ?? Date.now())}`;
26420
- }
26421
- buildBrokerProvider() {
26422
- return buildGreeBrokerProvider({
26423
- ownerAddonId: this.ctx.id,
26424
- registry: this.requireRegistry(),
26425
- getBrokers: () => this.config.brokers,
26426
- persistBrokers: (brokers) => this.updateGlobalSettings({ brokers: [...brokers] }),
26427
- cascadeRemoveDevices: (brokerId) => this.cascadeRemoveDevicesForBroker(brokerId),
25955
+ async onGetCreationSchema(type) {
25956
+ if (type !== DeviceType.Container) return null;
25957
+ return buildConnectionFormSchema();
25958
+ }
25959
+ async onCreateDevice(type, config) {
25960
+ if (type !== DeviceType.Container) throw new Error(`Gree provider does not support device type: ${type}`);
25961
+ const name = typeof config["name"] === "string" ? config["name"].trim() : "";
25962
+ if (!name) throw new Error("Air conditioner name is required");
25963
+ const connection = settingsToGreeConfig(config);
25964
+ if (connection.host.trim().length === 0) throw new Error("Air conditioner IP address is required");
25965
+ const bound = await bindOnce({
25966
+ connection,
26428
25967
  logger: this.ctx.logger
26429
25968
  });
26430
- }
26431
- async cascadeRemoveDevicesForBroker(brokerId) {
26432
- const reg = this.ctx.kernel.deviceRegistry;
26433
- const devices = this.ctx.kernel.devices;
26434
- if (!reg || !devices) return;
26435
- await cascadeRemoveDevicesForBroker({
26436
- reg,
26437
- devices,
26438
- addonId: this.addonId,
26439
- brokerId,
26440
- logger: this.ctx.logger
25969
+ const connectionKey = `gree:${macKey(bound.mac)}`;
25970
+ const deviceConfig = {
25971
+ greeMac: macKey(bound.mac),
25972
+ greeIp: bound.ip,
25973
+ connectionKey,
25974
+ connection,
25975
+ system: "gree",
25976
+ name
25977
+ };
25978
+ const manager = new GreeIntegrationManager({
25979
+ id: connectionKey,
25980
+ name,
25981
+ connection,
25982
+ logger: this.ctx.logger,
25983
+ onConnected: () => void 0,
25984
+ onDisconnected: () => void 0,
25985
+ surfaceSink: greeConnections,
25986
+ expectMac: bound.mac
26441
25987
  });
26442
- }
26443
- /**
26444
- * Link each surviving integration (carrying `{ brokerId }` in its settings) to
26445
- * its broker so the generic `device-adoption` cap resolves the scope, and
26446
- * cascade-clean brokers (+ their adopted devices) whose spawning integration
26447
- * was deleted. Idempotent; runs on boot + on every integration lifecycle event.
26448
- * Guarded so a failure never fails init.
26449
- */
26450
- async reconcileIntegrationsToBrokers() {
26451
25988
  try {
26452
- const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id);
26453
- const surviving = new Set(mine.map((i) => i.id));
26454
- for (const integration of mine) {
26455
- const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
26456
- const brokerId = typeof settings["brokerId"] === "string" ? settings["brokerId"] : void 0;
26457
- if (brokerId === void 0) continue;
26458
- if (!this.config.brokers.some((b) => b.id === brokerId)) {
26459
- this.ctx.logger.warn("Gree integration→broker: linked broker not found", { meta: {
26460
- integrationId: integration.id,
26461
- brokerId
26462
- } });
26463
- continue;
26464
- }
26465
- this.requireRegistry().linkIntegration(integration.id, brokerId);
26466
- }
26467
- const toRemove = this.config.brokers.filter((b) => b.integrationId !== void 0 && !surviving.has(b.integrationId)).map((b) => b.id);
26468
- if (toRemove.length === 0) return;
26469
- for (const id of toRemove) try {
26470
- await this.requireRegistry().removeEntry(id);
26471
- await this.cascadeRemoveDevicesForBroker(id);
26472
- } catch (err) {
26473
- this.ctx.logger.warn("Gree integration→broker: broker cleanup failed", { meta: {
26474
- brokerId: id,
26475
- error: errMsg(err)
26476
- } });
26477
- }
26478
- const nextBrokers = this.config.brokers.filter((b) => !toRemove.includes(b.id));
26479
- await this.updateGlobalSettings({ brokers: nextBrokers });
25989
+ await manager.start();
26480
25990
  } catch (err) {
26481
- this.ctx.logger.warn("Gree integration→broker reconcile failed", { meta: { error: errMsg(err) } });
25991
+ this.ctx.logger.warn("Gree create: initial client start failed — device kept", { meta: {
25992
+ connectionKey,
25993
+ error: errMsg(err)
25994
+ } });
26482
25995
  }
26483
- }
26484
- subscribeIntegrationLifecycle() {
26485
- const handler = (event) => {
26486
- const addonId = event.data["addonId"];
26487
- if (typeof addonId === "string" && addonId !== this.ctx.id) return;
26488
- this.reconcileIntegrationsToBrokers();
26489
- };
26490
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
26491
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
26492
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
26493
- }
26494
- buildAdoptionProvider() {
26495
- return buildGreeAdoptionProvider({
26496
- registry: greeConnections,
26497
- logger: this.ctx.logger,
26498
- getBrokerIdForIntegration: async (id) => {
26499
- const brokerId = (await this.ctx.api.integrations.getSettings.query({ id }))["brokerId"];
26500
- if (typeof brokerId !== "string") throw new Error(`integration ${id} has no linked brokerId`);
26501
- return brokerId;
26502
- },
26503
- listAdoptedGree: async () => {
26504
- const reg = this.ctx.kernel.deviceRegistry;
26505
- const devices = this.ctx.kernel.devices;
26506
- if (!reg || !devices) return [];
26507
- const out = [];
26508
- for (const d of reg.getAllForAddon(this.addonId)) {
26509
- if (d.parentDeviceId !== null) continue;
26510
- const config = await devices.loadConfig(d.id).catch(() => ({}));
26511
- out.push({
26512
- id: d.id,
26513
- config
26514
- });
26515
- }
26516
- return out;
26517
- },
26518
- adoptDevice: async ({ greeMac, greeIp, brokerId, integrationId, name }) => {
26519
- const devices = this.ctx.kernel.devices;
26520
- if (!devices) throw new Error("gree adopt: kernel.devices unavailable");
26521
- const config = {
26522
- greeMac,
26523
- greeIp,
26524
- brokerId,
26525
- system: "gree",
26526
- integrationId,
26527
- name
26528
- };
26529
- const stableId = this.generateStableId(DeviceType.Container, config);
26530
- const device = await devices.create(stableId, GreeContainerDevice, config, null, {
26531
- type: DeviceType.Container,
26532
- name,
26533
- integrationId
26534
- });
26535
- const children = await devices.getChildren(device.id);
26536
- return {
26537
- deviceId: device.id,
26538
- accessoryDeviceIds: children.map((c) => c.id)
26539
- };
26540
- },
26541
- removeDevice: async (id) => {
26542
- await this.ctx.kernel.devices?.remove(id);
25996
+ return {
25997
+ meta: {
25998
+ type: DeviceType.Container,
25999
+ name
26543
26000
  },
26544
- findDeviceConfig: async (id) => {
26545
- const devices = this.ctx.kernel.devices;
26546
- if (!devices) return null;
26547
- return devices.loadConfig(id).catch(() => null);
26001
+ config: deviceConfig,
26002
+ onAfterCreate: async (device) => {
26003
+ if (device instanceof GreeContainerDevice) device.adoptManager(manager);
26548
26004
  }
26549
- });
26550
- }
26551
- globalSettingsSchema() {
26552
- return this.schema({ sections: [{
26553
- id: "gree-broker",
26554
- title: "Gree discovery scope",
26555
- description: "Gree scopes are managed in the External systems → Brokers tab.",
26556
- columns: 1,
26557
- fields: [{
26558
- type: "info",
26559
- key: "brokerHelp",
26560
- label: "Scopes are managed separately",
26561
- content: "This integration links to a Gree LAN discovery scope. Add, edit, or remove scopes from External systems → Brokers. Gree is local-only (UDP) — no cloud account is needed; a scope just stores the broadcast address + UDP settings."
26562
- }]
26563
- }] });
26564
- }
26565
- async supportsManualCreation() {
26566
- return false;
26567
- }
26568
- async onGetCreationSchema(_type) {
26569
- return null;
26570
- }
26571
- async onCreateDevice(_type, _config) {
26572
- throw new Error("Gree devices are adopted via LAN discovery, not created manually");
26573
- }
26574
- setBrokerDevicesOnline(brokerId, online) {
26575
- const reg = this.ctx.kernel.deviceRegistry;
26576
- if (!reg) return;
26577
- const n = setBrokerDevicesOnline(reg.getAllForAddon(this.addonId), brokerId, online);
26578
- if (n > 0) this.ctx.logger.info("Gree: broker devices " + (online ? "online" : "offline"), { meta: {
26579
- brokerId,
26580
- count: n
26581
- } });
26005
+ };
26582
26006
  }
26583
- requireRegistry() {
26584
- if (!this.registry) throw new Error("Gree provider not initialised");
26585
- return this.registry;
26007
+ /**
26008
+ * AC stableId derived from the AC's durable MAC identity (resolved by the
26009
+ * create-time bind), NOT a broker id. Re-adding the same physical AC reuses its
26010
+ * persisted row. Falls back to a timestamp only when no MAC is resolvable
26011
+ * (should not happen — `onCreateDevice` binds first).
26012
+ */
26013
+ generateStableId(_type, config) {
26014
+ const mac = config?.["greeMac"];
26015
+ if (typeof mac === "string" && mac.length > 0) return `gree:${macKey(mac)}`;
26016
+ return `gree:${Date.now()}`;
26586
26017
  }
26587
26018
  };
26588
26019
  //#endregion
26589
- export { GreeProviderAddon, greeAddonConfigSchema as _, boolToVerticalSwing as a, horizontalSwingToBool as c, oscillatingToVerticalSwing as d, percentageToFanSpeed as f, buildConnectionFormSchema as g, buildGreeCandidates as h, boolToHorizontalSwing as i, isAutoFan as l, verticalSwingToBool as m, GREE_FAN_PERCENTAGE_STEP as n, capModeToLibMode as o, swingToOscillating as p, SUPPORTED_CAP_MODES as r, fanSpeedToPercentage as s, ADVERTISED_CAP_MODES as t, libModeToCapMode as u, greeConfigSchema as v, toNodegreeOptions as y };
26020
+ export { DeviceType as C, GreeProviderAddon, toNodegreeOptions as S, sameConnection as _, boolToVerticalSwing as a, greeConfigSchema as b, horizontalSwingToBool as c, oscillatingToVerticalSwing as d, percentageToFanSpeed as f, resolveBroadcastTarget as g, bindOnce as h, boolToHorizontalSwing as i, isAutoFan as l, verticalSwingToBool as m, GREE_FAN_PERCENTAGE_STEP as n, capModeToLibMode as o, swingToOscillating as p, SUPPORTED_CAP_MODES as r, fanSpeedToPercentage as s, ADVERTISED_CAP_MODES as t, libModeToCapMode as u, buildConnectionFormSchema as v, settingsToGreeConfig as x, greeAcDeviceSchema as y };