@camstack/addon-provider-gree 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -4645,7 +4645,7 @@ function preprocess(fn, schema) {
4645
4645
  });
4646
4646
  }
4647
4647
  //#endregion
4648
- //#region ../types/dist/sleep-B3AOslwX.mjs
4648
+ //#region ../types/dist/sleep-C2M2zF7x.mjs
4649
4649
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4650
4650
  EventCategory["SystemBoot"] = "system.boot";
4651
4651
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -6213,6 +6213,12 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
6213
6213
  DeviceType["Switch"] = "switch";
6214
6214
  DeviceType["Sensor"] = "sensor";
6215
6215
  DeviceType["Thermostat"] = "thermostat";
6216
+ /** Air-conditioner / heat-pump climate device (HVAC) — shares the
6217
+ * `climate-control` cap surface with `Thermostat` but renders a
6218
+ * dedicated AC-appropriate control UI (mode chips, fan speed,
6219
+ * independent vertical/horizontal swing). Sources: native Gree, and
6220
+ * reusable by other AC integrations. */
6221
+ DeviceType["Climate"] = "climate";
6216
6222
  DeviceType["Button"] = "button";
6217
6223
  /** Generic stateless event emitter — carries a device's EXACT declared
6218
6224
  * event vocabulary verbatim (no normalization). Installed with the
@@ -9008,7 +9014,7 @@ var climateControlCapability = {
9008
9014
  scope: "device",
9009
9015
  deviceNative: true,
9010
9016
  mode: "singleton",
9011
- deviceTypes: [DeviceType.Thermostat],
9017
+ deviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
9012
9018
  methods: {
9013
9019
  setMode: method(object({
9014
9020
  deviceId: number().int().nonnegative(),
@@ -13607,10 +13613,30 @@ var deviceProviderCapability = {
13607
13613
  type: string()
13608
13614
  }))),
13609
13615
  supportsDiscovery: method(object({}), boolean()),
13610
- discoverDevices: method(object({}), array(DiscoveryCandidateSchema), {
13616
+ /**
13617
+ * Run a network scan. `params` carries optional provider-specific scan
13618
+ * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
13619
+ * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
13620
+ * (provider uses its local-network default).
13621
+ */
13622
+ discoverDevices: method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
13611
13623
  kind: "mutation",
13612
13624
  auth: "admin"
13613
13625
  }),
13626
+ /**
13627
+ * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
13628
+ * provider accepts (e.g. Gree's broadcast address for a different subnet).
13629
+ * `null` when the provider takes no extra scan params — the generic
13630
+ * aggregated scan never renders this; the per-integration scan does.
13631
+ */
13632
+ getDiscoveryParamsSchema: method(object({}), CreationSchemaOutputSchema),
13633
+ /**
13634
+ * The DeviceType this provider creates via manual add (Camera for
13635
+ * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
13636
+ * provider does not support manual creation. Lets the Add-Device dialog
13637
+ * pick the right type instead of assuming Camera.
13638
+ */
13639
+ getManualCreationType: method(object({}), object({ deviceType: _enum(DeviceType).nullable() })),
13614
13640
  adoptDiscoveredDevice: method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
13615
13641
  kind: "mutation",
13616
13642
  auth: "admin"
@@ -13734,9 +13760,23 @@ var BaseDeviceProvider = class extends BaseAddon {
13734
13760
  async supportsDiscovery() {
13735
13761
  return false;
13736
13762
  }
13737
- async discoverDevices() {
13763
+ async discoverDevices(_input) {
13738
13764
  return [];
13739
13765
  }
13766
+ /** Extra per-scan input form (e.g. a broadcast address for another subnet).
13767
+ * Null = no extra params. Override in providers that support scoped scans. */
13768
+ async getDiscoveryParamsSchema() {
13769
+ return null;
13770
+ }
13771
+ /**
13772
+ * The DeviceType this provider creates via manual add — derived from the
13773
+ * `deviceClasses` map (first registered type). `null` when manual creation is
13774
+ * unsupported. Lets the Add-Device dialog pick the right type per provider.
13775
+ */
13776
+ async getManualCreationType() {
13777
+ if (!await this.supportsManualCreation()) return { deviceType: null };
13778
+ return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
13779
+ }
13740
13780
  async adoptDiscoveredDevice(_input) {
13741
13781
  throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
13742
13782
  }
@@ -15581,7 +15621,10 @@ method(object({
15581
15621
  }), FieldProbeResultSchema, {
15582
15622
  kind: "mutation",
15583
15623
  auth: "admin"
15584
- }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15624
+ }), method(object({
15625
+ addonId: string(),
15626
+ integrationId: string()
15627
+ }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15585
15628
  addonId: string(),
15586
15629
  integrationId: string()
15587
15630
  }), AdoptionStatusSchema, {
@@ -15596,7 +15639,24 @@ method(object({
15596
15639
  }), method(ResyncInputSchema, ResyncResultSchema, {
15597
15640
  kind: "mutation",
15598
15641
  auth: "admin"
15642
+ }), method(object({}), object({ providers: array(object({
15643
+ addonId: string(),
15644
+ label: string()
15645
+ })).readonly() }), { auth: "admin" }), method(object({}), object({ groups: array(object({
15646
+ addonId: string(),
15647
+ label: string(),
15648
+ candidates: array(DiscoveryCandidateSchema).readonly(),
15649
+ error: string().nullable()
15650
+ })).readonly() }), {
15651
+ kind: "mutation",
15652
+ auth: "admin"
15599
15653
  }), method(object({
15654
+ addonId: string(),
15655
+ params: record(string(), unknown()).optional()
15656
+ }), object({ candidates: array(DiscoveryCandidateSchema).readonly() }), {
15657
+ kind: "mutation",
15658
+ auth: "admin"
15659
+ }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
15600
15660
  deviceId: number(),
15601
15661
  key: string(),
15602
15662
  value: unknown()
@@ -20719,6 +20779,12 @@ Object.freeze({
20719
20779
  addonId: null,
20720
20780
  access: "create"
20721
20781
  },
20782
+ "deviceManager.adoptionListCandidateFilters": {
20783
+ capName: "device-manager",
20784
+ capScope: "system",
20785
+ addonId: null,
20786
+ access: "view"
20787
+ },
20722
20788
  "deviceManager.adoptionListCandidates": {
20723
20789
  capName: "device-manager",
20724
20790
  capScope: "system",
@@ -20767,12 +20833,30 @@ Object.freeze({
20767
20833
  addonId: null,
20768
20834
  access: "create"
20769
20835
  },
20836
+ "deviceManager.discoverAllProviders": {
20837
+ capName: "device-manager",
20838
+ capScope: "system",
20839
+ addonId: null,
20840
+ access: "create"
20841
+ },
20770
20842
  "deviceManager.discoverDevices": {
20771
20843
  capName: "device-manager",
20772
20844
  capScope: "system",
20773
20845
  addonId: null,
20774
20846
  access: "create"
20775
20847
  },
20848
+ "deviceManager.discoverProvider": {
20849
+ capName: "device-manager",
20850
+ capScope: "system",
20851
+ addonId: null,
20852
+ access: "create"
20853
+ },
20854
+ "deviceManager.discoveryProviders": {
20855
+ capName: "device-manager",
20856
+ capScope: "system",
20857
+ addonId: null,
20858
+ access: "view"
20859
+ },
20776
20860
  "deviceManager.enable": {
20777
20861
  capName: "device-manager",
20778
20862
  capScope: "system",
@@ -20923,6 +21007,18 @@ Object.freeze({
20923
21007
  addonId: null,
20924
21008
  access: "create"
20925
21009
  },
21010
+ "deviceManager.providerCreationType": {
21011
+ capName: "device-manager",
21012
+ capScope: "system",
21013
+ addonId: null,
21014
+ access: "view"
21015
+ },
21016
+ "deviceManager.providerDiscoveryParamsSchema": {
21017
+ capName: "device-manager",
21018
+ capScope: "system",
21019
+ addonId: null,
21020
+ access: "view"
21021
+ },
20926
21022
  "deviceManager.registerDevice": {
20927
21023
  capName: "device-manager",
20928
21024
  capScope: "system",
@@ -21139,6 +21235,18 @@ Object.freeze({
21139
21235
  addonId: null,
21140
21236
  access: "view"
21141
21237
  },
21238
+ "deviceProvider.getDiscoveryParamsSchema": {
21239
+ capName: "device-provider",
21240
+ capScope: "system",
21241
+ addonId: null,
21242
+ access: "view"
21243
+ },
21244
+ "deviceProvider.getManualCreationType": {
21245
+ capName: "device-provider",
21246
+ capScope: "system",
21247
+ addonId: null,
21248
+ access: "view"
21249
+ },
21142
21250
  "deviceProvider.getStatus": {
21143
21251
  capName: "device-provider",
21144
21252
  capScope: "system",
@@ -24128,6 +24236,34 @@ function buildConnectionFormSchema() {
24128
24236
  ]
24129
24237
  }] };
24130
24238
  }
24239
+ /**
24240
+ * Extra params form for a PER-INTEGRATION network scan. Gree's UDP broadcast
24241
+ * only reaches the addon node's local subnet; to find ACs on a DIFFERENT subnet
24242
+ * (e.g. 192.168.20.x) the operator supplies that subnet's directed-broadcast
24243
+ * address (192.168.20.255). Empty = default local-subnet broadcast.
24244
+ */
24245
+ function buildDiscoveryParamsFormSchema() {
24246
+ return { sections: [{
24247
+ id: "scan",
24248
+ title: "Scan options",
24249
+ description: "Leave empty to scan the local subnet. To find air conditioners on a different subnet, enter that subnet’s broadcast address (e.g. 192.168.20.255).",
24250
+ columns: 1,
24251
+ fields: [{
24252
+ type: "text",
24253
+ key: "broadcastAddress",
24254
+ label: "Broadcast address",
24255
+ required: false,
24256
+ placeholder: "192.168.20.255"
24257
+ }, {
24258
+ type: "number",
24259
+ key: "timeoutMs",
24260
+ label: "UDP timeout (ms)",
24261
+ min: 500,
24262
+ max: 3e4,
24263
+ default: 3e3
24264
+ }]
24265
+ }] };
24266
+ }
24131
24267
  //#endregion
24132
24268
  //#region ../../node_modules/@apocaliss92/nodegree/dist/index.js
24133
24269
  var GreeError = class extends Error {
@@ -24829,6 +24965,16 @@ function macKey(mac) {
24829
24965
  */
24830
24966
  var GreeConnectionResolver = class {
24831
24967
  #surfaces = /* @__PURE__ */ new Map();
24968
+ /**
24969
+ * Per-connection "surface (re)published" subscribers. An AC accessory child
24970
+ * activates from `restoreDevices` BEFORE its parent's manager finishes the
24971
+ * async bind, so `getDevice` is null at `onActivate` time and its
24972
+ * `stateChanged` listener never attaches → the climate slice stays frozen at
24973
+ * cold-start (`lastFetchedAt: 0`, no temperature). Children subscribe here and
24974
+ * (re)attach + recompute once the handle is published. A plain `Set` has no
24975
+ * listener cap. Mirrors the Ecowitt facade-resolver fan-out.
24976
+ */
24977
+ #subs = /* @__PURE__ */ new Map();
24832
24978
  /** Publish or remove the connection surface for a connection key. `null` removes. */
24833
24979
  set(connectionKey, surface) {
24834
24980
  if (surface === null) {
@@ -24836,6 +24982,24 @@ var GreeConnectionResolver = class {
24836
24982
  return;
24837
24983
  }
24838
24984
  this.#surfaces.set(connectionKey, surface);
24985
+ const subs = this.#subs.get(connectionKey);
24986
+ if (subs) for (const cb of subs) try {
24987
+ cb();
24988
+ } catch {}
24989
+ }
24990
+ /** Subscribe to surface (re)publish for a connection key. Returns unsubscribe. */
24991
+ onSurface(connectionKey, cb) {
24992
+ let set = this.#subs.get(connectionKey);
24993
+ if (!set) {
24994
+ set = /* @__PURE__ */ new Set();
24995
+ this.#subs.set(connectionKey, set);
24996
+ }
24997
+ set.add(cb);
24998
+ return () => {
24999
+ const current = this.#subs.get(connectionKey);
25000
+ current?.delete(cb);
25001
+ if (current && current.size === 0) this.#subs.delete(connectionKey);
25002
+ };
24839
25003
  }
24840
25004
  /** The bind-scan rows for a connection key, or empty when unknown. */
24841
25005
  discovered(connectionKey) {
@@ -24859,6 +25023,7 @@ var GreeConnectionResolver = class {
24859
25023
  /** Remove all registered surfaces (called on full shutdown). */
24860
25024
  clear() {
24861
25025
  this.#surfaces.clear();
25026
+ this.#subs.clear();
24862
25027
  }
24863
25028
  };
24864
25029
  /** The single in-process per-connection resolver shared between the AC device's
@@ -24869,7 +25034,7 @@ var greeConnections = new GreeConnectionResolver();
24869
25034
  function defaultFacade(config) {
24870
25035
  return new Nodegree(toNodegreeOptions(config));
24871
25036
  }
24872
- var DEFAULT_POLL_INTERVAL_MS = 3e4;
25037
+ var DEFAULT_POLL_INTERVAL_MS = 1e4;
24873
25038
  /**
24874
25039
  * Wraps exactly one `@apocaliss92/nodegree` facade bound to a SINGLE Gree AC
24875
25040
  * (standalone mode — the connection lives on the AC device). {@link start} runs a
@@ -25068,6 +25233,27 @@ function resolveBroadcastTarget(connection) {
25068
25233
  * The returned identity is what {@link import('./config.js').greeAcDeviceSchema}
25069
25234
  * persists on the new device; the live per-device manager re-binds on activate.
25070
25235
  */
25236
+ /**
25237
+ * One-shot LAN broadcast scan for Gree ACs — the network-discovery pass. Builds a
25238
+ * throwaway facade from default (or provided) UDP settings, runs the broadcast,
25239
+ * and returns every responder. No bind, no persistence. The provider's
25240
+ * `discoverDevices()` maps the responders to adoption candidates.
25241
+ */
25242
+ async function discoverGreeDevices(input) {
25243
+ const connection = settingsToGreeConfig({
25244
+ ...input.broadcastAddress ? { broadcastAddr: input.broadcastAddress } : {},
25245
+ ...input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}
25246
+ });
25247
+ const facade = (input.makeFacade ?? defaultFacade)(connection);
25248
+ try {
25249
+ const broadcast = resolveBroadcastTarget(connection);
25250
+ const opts = { timeoutMs: connection.timeoutMs };
25251
+ if (broadcast.length > 0) opts.broadcastAddr = broadcast;
25252
+ return await facade.discover(opts);
25253
+ } finally {
25254
+ await facade.close().catch(() => void 0);
25255
+ }
25256
+ }
25071
25257
  async function bindOnce(input) {
25072
25258
  const { connection, logger } = input;
25073
25259
  const facade = (input.makeFacade ?? defaultFacade)(connection);
@@ -25366,7 +25552,19 @@ var greeAcSchema = object({
25366
25552
  greeMac: string().min(1).describe("Gree AC MAC address"),
25367
25553
  connectionKey: string().min(1).describe("Per-device connection resolver key"),
25368
25554
  system: literal("gree").optional(),
25369
- integrationId: string().optional()
25555
+ integrationId: string().optional(),
25556
+ /** Operator filter — HVAC modes exposed in the control UI (subset of the
25557
+ * model's supported modes). Empty / omitted ⇒ expose all. `off` is always
25558
+ * exposed (power). */
25559
+ enabledModes: array(string()).optional(),
25560
+ /** Operator filter — fan speeds exposed in the control UI (subset of
25561
+ * `GREE_FAN_MODES`). Empty / omitted ⇒ expose all. */
25562
+ enabledFanModes: array(string()).optional(),
25563
+ /** Operator filter — expose the vertical-swing toggle. Default true. */
25564
+ exposeVerticalSwing: boolean().optional(),
25565
+ /** Operator filter — expose the horizontal-swing toggle (model-permitting).
25566
+ * Default true. */
25567
+ exposeHorizontalSwing: boolean().optional()
25370
25568
  });
25371
25569
  /**
25372
25570
  * One Gree air conditioner as a CamStack `Thermostat`-type accessory child.
@@ -25406,17 +25604,16 @@ var GreeAcDevice = class extends BaseDevice$1 {
25406
25604
  */
25407
25605
  get features() {
25408
25606
  const caps = this.resolveAc()?.capabilities ?? getAcCapabilities();
25409
- const flags = [
25410
- DeviceFeature.ClimateFanMode,
25411
- DeviceFeature.ClimatePreset,
25412
- DeviceFeature.ClimateSwingVertical
25413
- ];
25414
- if (caps.horizontalSwing) flags.push(DeviceFeature.ClimateSwingHorizontal);
25607
+ const cfg = this.config.values;
25608
+ const flags = [DeviceFeature.ClimateFanMode, DeviceFeature.ClimatePreset];
25609
+ if (cfg.exposeVerticalSwing ?? true) flags.push(DeviceFeature.ClimateSwingVertical);
25610
+ if (caps.horizontalSwing && (cfg.exposeHorizontalSwing ?? true)) flags.push(DeviceFeature.ClimateSwingHorizontal);
25415
25611
  return flags;
25416
25612
  }
25417
25613
  greeMac;
25418
25614
  connectionKey;
25419
25615
  stateChangedUnsub = null;
25616
+ surfaceUnsub = null;
25420
25617
  constructor(ctx) {
25421
25618
  const persisted = greeAcSchema.parse(ctx.persistedConfig ?? {});
25422
25619
  super(ctx, greeAcSchema, { type: ctx.deviceMeta.type });
@@ -25441,8 +25638,21 @@ var GreeAcDevice = class extends BaseDevice$1 {
25441
25638
  this.registerCaps();
25442
25639
  this.attachStateListener();
25443
25640
  this.recomputeSlices();
25641
+ this.surfaceUnsub = greeConnections.onSurface(this.connectionKey, () => {
25642
+ this.attachStateListener();
25643
+ this.recomputeSlices();
25644
+ });
25444
25645
  }
25445
25646
  async removeDevice() {
25647
+ this.detachStateListener();
25648
+ if (this.surfaceUnsub) {
25649
+ try {
25650
+ this.surfaceUnsub();
25651
+ } catch {}
25652
+ this.surfaceUnsub = null;
25653
+ }
25654
+ }
25655
+ detachStateListener() {
25446
25656
  if (this.stateChangedUnsub) {
25447
25657
  try {
25448
25658
  this.stateChangedUnsub();
@@ -25450,12 +25660,16 @@ var GreeAcDevice = class extends BaseDevice$1 {
25450
25660
  this.stateChangedUnsub = null;
25451
25661
  }
25452
25662
  }
25663
+ /** Attach the live `stateChanged` listener to the bound AC handle. Idempotent:
25664
+ * drops any prior listener first, so it is safe to call on every surface
25665
+ * (re)publish. No-op until the handle is actually bound. */
25453
25666
  attachStateListener() {
25454
25667
  const ac = this.resolveAc();
25455
25668
  if (!ac) {
25456
25669
  this.ctx.logger.debug("GreeAcDevice: handle not present; no live listener yet", { meta: { greeMac: this.greeMac } });
25457
25670
  return;
25458
25671
  }
25672
+ this.detachStateListener();
25459
25673
  const onState = () => {
25460
25674
  try {
25461
25675
  this.recomputeSlices();
@@ -25473,7 +25687,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25473
25687
  }
25474
25688
  registerCaps() {
25475
25689
  this.ctx.registerNativeCap(climateControlCapability, {
25476
- getStatus: async () => this.runtimeState.getCapState(CLIMATE_CAP) ?? CLIMATE_COLD_START,
25690
+ getStatus: async () => this.runtimeState.getCapState(CLIMATE_CAP) ?? this.applyClimateFilters(CLIMATE_COLD_START),
25477
25691
  setMode: async ({ mode }) => {
25478
25692
  const ac = this.requireAc();
25479
25693
  const libMode = capModeToLibMode(mode);
@@ -25520,7 +25734,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25520
25734
  await ac.setSwingHorizontal(boolToHorizontalSwing(on));
25521
25735
  }
25522
25736
  });
25523
- this.runtimeState.setCapState(CLIMATE_CAP, CLIMATE_COLD_START);
25737
+ this.runtimeState.setCapState(CLIMATE_CAP, this.applyClimateFilters(CLIMATE_COLD_START));
25524
25738
  this.ctx.registerNativeCap(fanControlCapability, {
25525
25739
  getStatus: async () => this.runtimeState.getCapState(FAN_CAP) ?? FAN_COLD_START,
25526
25740
  setPercentage: async ({ percentage }) => {
@@ -25567,7 +25781,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25567
25781
  swingHorizontal: ac.capabilities.horizontalSwing ? horizontalSwingToBool(ac.swingHorizontal) : null,
25568
25782
  lastFetchedAt: now
25569
25783
  };
25570
- this.runtimeState.setCapState(CLIMATE_CAP, climateSlice);
25784
+ this.runtimeState.setCapState(CLIMATE_CAP, this.applyClimateFilters(climateSlice));
25571
25785
  const fanSlice = {
25572
25786
  percentage: fanSpeedToPercentage(ac.fanSpeed),
25573
25787
  percentageStep: GREE_FAN_PERCENTAGE_STEP,
@@ -25579,7 +25793,97 @@ var GreeAcDevice = class extends BaseDevice$1 {
25579
25793
  };
25580
25794
  this.runtimeState.setCapState(FAN_CAP, fanSlice);
25581
25795
  }
25796
+ /**
25797
+ * Apply the operator's per-AC exposure filters to a climate slice: narrow
25798
+ * `availableModes` / `availableFanModes` to the enabled subsets (empty ⇒ all;
25799
+ * `off` is always kept) and null out a swing axis the operator has hidden.
25800
+ * Pure — returns a new slice, never mutates the input.
25801
+ */
25802
+ applyClimateFilters(slice) {
25803
+ const cfg = this.config.values;
25804
+ const enabledModes = cfg.enabledModes;
25805
+ const enabledFanModes = cfg.enabledFanModes;
25806
+ const exposeV = cfg.exposeVerticalSwing ?? true;
25807
+ const exposeH = cfg.exposeHorizontalSwing ?? true;
25808
+ const availableModes = enabledModes && enabledModes.length > 0 ? slice.availableModes.filter((m) => m === "off" || enabledModes.includes(m)) : slice.availableModes;
25809
+ const availableFanModes = enabledFanModes && enabledFanModes.length > 0 ? slice.availableFanModes.filter((f) => enabledFanModes.includes(f)) : slice.availableFanModes;
25810
+ return {
25811
+ ...slice,
25812
+ availableModes,
25813
+ availableFanModes,
25814
+ swingVertical: exposeV ? slice.swingVertical : null,
25815
+ swingHorizontal: exposeH ? slice.swingHorizontal : null
25816
+ };
25817
+ }
25818
+ /**
25819
+ * Per-AC exposure form (device-details settings). Lets the operator pick
25820
+ * which modes / fan speeds / swing axes appear in the AC control UI. An empty
25821
+ * multiselect means "expose all". The horizontal-swing toggle is only offered
25822
+ * when the model advertises horizontal swing.
25823
+ */
25824
+ getSettingsUISchema() {
25825
+ const supportsHSwing = (this.resolveAc()?.capabilities ?? getAcCapabilities()).horizontalSwing;
25826
+ const cfg = this.config.values;
25827
+ const fields = [
25828
+ {
25829
+ type: "multiselect",
25830
+ key: "enabledModes",
25831
+ label: "Modes",
25832
+ description: "Modes shown in the control UI. Empty = all. “Off” is always available.",
25833
+ options: SUPPORTED_CAP_MODES.map((m) => ({
25834
+ value: m,
25835
+ label: titleCase(m)
25836
+ }))
25837
+ },
25838
+ {
25839
+ type: "multiselect",
25840
+ key: "enabledFanModes",
25841
+ label: "Fan speeds",
25842
+ description: "Fan speeds shown in the control UI. Empty = all.",
25843
+ options: GREE_FAN_MODES.map((f) => ({
25844
+ value: f,
25845
+ label: titleCase(f)
25846
+ }))
25847
+ },
25848
+ {
25849
+ type: "boolean",
25850
+ style: "switch",
25851
+ key: "exposeVerticalSwing",
25852
+ label: "Vertical swing",
25853
+ default: true
25854
+ }
25855
+ ];
25856
+ if (supportsHSwing) fields.push({
25857
+ type: "boolean",
25858
+ style: "switch",
25859
+ key: "exposeHorizontalSwing",
25860
+ label: "Horizontal swing",
25861
+ default: true
25862
+ });
25863
+ return hydrateSchema({ sections: [{
25864
+ id: "exposure",
25865
+ title: "Exposed controls",
25866
+ description: "Choose which modes, fan speeds and swing axes appear in the air-conditioner control UI.",
25867
+ fields
25868
+ }] }, {
25869
+ enabledModes: cfg.enabledModes ?? [],
25870
+ enabledFanModes: cfg.enabledFanModes ?? [],
25871
+ exposeVerticalSwing: cfg.exposeVerticalSwing ?? true,
25872
+ exposeHorizontalSwing: cfg.exposeHorizontalSwing ?? true
25873
+ });
25874
+ }
25875
+ async applySettingsPatch(patch) {
25876
+ const typed = greeAcSchema.partial().parse(patch);
25877
+ await this.config.setAll(typed);
25878
+ this.recomputeSlices();
25879
+ await this.refreshFeatures();
25880
+ }
25582
25881
  };
25882
+ /** 'fan_only' → 'Fan only', 'medium_low' → 'Medium low'. Pure. */
25883
+ function titleCase(value) {
25884
+ const spaced = value.replace(/_/g, " ");
25885
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
25886
+ }
25583
25887
  //#endregion
25584
25888
  //#region src/devices/gree-toggle-device.ts
25585
25889
  var SWITCH_CAP = "switch";
@@ -25622,6 +25926,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
25622
25926
  greeMac;
25623
25927
  connectionKey;
25624
25928
  stateChangedUnsub = null;
25929
+ surfaceUnsub = null;
25625
25930
  constructor(ctx) {
25626
25931
  const persisted = greeToggleSchema.parse(ctx.persistedConfig ?? {});
25627
25932
  super(ctx, greeToggleSchema, { type: ctx.deviceMeta.type });
@@ -25673,8 +25978,21 @@ var GreeToggleDevice = class extends BaseDevice$1 {
25673
25978
  this.registerCap();
25674
25979
  this.attachStateListener();
25675
25980
  this.recomputeSlice();
25981
+ this.surfaceUnsub = greeConnections.onSurface(this.connectionKey, () => {
25982
+ this.attachStateListener();
25983
+ this.recomputeSlice();
25984
+ });
25676
25985
  }
25677
25986
  async removeDevice() {
25987
+ this.detachStateListener();
25988
+ if (this.surfaceUnsub) {
25989
+ try {
25990
+ this.surfaceUnsub();
25991
+ } catch {}
25992
+ this.surfaceUnsub = null;
25993
+ }
25994
+ }
25995
+ detachStateListener() {
25678
25996
  if (this.stateChangedUnsub) {
25679
25997
  try {
25680
25998
  this.stateChangedUnsub();
@@ -25691,6 +26009,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
25691
26009
  } });
25692
26010
  return;
25693
26011
  }
26012
+ this.detachStateListener();
25694
26013
  const onState = () => {
25695
26014
  try {
25696
26015
  this.recomputeSlice();
@@ -25850,7 +26169,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
25850
26169
  return [{
25851
26170
  stableIdSuffix: "ac",
25852
26171
  meta: {
25853
- type: DeviceType.Thermostat,
26172
+ type: DeviceType.Climate,
25854
26173
  name: this.name,
25855
26174
  linkDeviceId: this.id,
25856
26175
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
@@ -25936,9 +26255,10 @@ var GreeContainerDevice = class extends BaseDevice$1 {
25936
26255
  * `instanceMode: multiple` — many ACs may be added. Placement: any-node — Gree is
25937
26256
  * LOCAL UDP; the addon runs on whichever node shares the AC's subnet.
25938
26257
  *
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.
26258
+ * LAN UDP-broadcast auto-discovery is implemented via the `device-provider`
26259
+ * discovery surface (`supportsDiscovery`/`discoverDevices`/`adoptDiscoveredDevice`)
26260
+ * — the same one ONVIF uses and the aggregated "Scan network" modal fans out to.
26261
+ * Manual add-one-AC-by-IP (directed bind) remains available in parallel.
25942
26262
  */
25943
26263
  var GreeProviderAddon = class extends BaseDeviceProvider {
25944
26264
  addonId = "provider-gree";
@@ -25948,7 +26268,41 @@ var GreeProviderAddon = class extends BaseDeviceProvider {
25948
26268
  super({});
25949
26269
  }
25950
26270
  async supportsDiscovery() {
25951
- return false;
26271
+ return true;
26272
+ }
26273
+ async getDiscoveryParamsSchema() {
26274
+ return buildDiscoveryParamsFormSchema();
26275
+ }
26276
+ async discoverDevices(input) {
26277
+ const broadcastAddress = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"].trim() : void 0;
26278
+ const timeoutMs = typeof input?.params?.["timeoutMs"] === "number" ? input.params["timeoutMs"] : void 0;
26279
+ const responders = await discoverGreeDevices({
26280
+ ...broadcastAddress ? { broadcastAddress } : {},
26281
+ ...timeoutMs ? { timeoutMs } : {}
26282
+ });
26283
+ this.ctx.logger.info("Gree discovery complete", { meta: {
26284
+ count: responders.length,
26285
+ broadcastAddress: broadcastAddress ?? "local"
26286
+ } });
26287
+ return responders.map((d) => {
26288
+ const displayName = d.name.length > 0 ? d.name : d.mac;
26289
+ return {
26290
+ stableId: `gree:${macKey(d.mac)}`,
26291
+ type: DeviceType.Container,
26292
+ suggestedName: displayName,
26293
+ prefilledConfig: {
26294
+ name: displayName,
26295
+ host: d.ip,
26296
+ ...d.model !== void 0 ? { model: d.model } : {}
26297
+ }
26298
+ };
26299
+ });
26300
+ }
26301
+ async adoptDiscoveredDevice(input) {
26302
+ return this.createDevice({
26303
+ type: DeviceType.Container,
26304
+ config: input.candidate.prefilledConfig
26305
+ });
25952
26306
  }
25953
26307
  async supportsManualCreation() {
25954
26308
  return true;
package/dist/addon.mjs CHANGED
@@ -4644,7 +4644,7 @@ function preprocess(fn, schema) {
4644
4644
  });
4645
4645
  }
4646
4646
  //#endregion
4647
- //#region ../types/dist/sleep-B3AOslwX.mjs
4647
+ //#region ../types/dist/sleep-C2M2zF7x.mjs
4648
4648
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4649
4649
  EventCategory["SystemBoot"] = "system.boot";
4650
4650
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -6212,6 +6212,12 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
6212
6212
  DeviceType["Switch"] = "switch";
6213
6213
  DeviceType["Sensor"] = "sensor";
6214
6214
  DeviceType["Thermostat"] = "thermostat";
6215
+ /** Air-conditioner / heat-pump climate device (HVAC) — shares the
6216
+ * `climate-control` cap surface with `Thermostat` but renders a
6217
+ * dedicated AC-appropriate control UI (mode chips, fan speed,
6218
+ * independent vertical/horizontal swing). Sources: native Gree, and
6219
+ * reusable by other AC integrations. */
6220
+ DeviceType["Climate"] = "climate";
6215
6221
  DeviceType["Button"] = "button";
6216
6222
  /** Generic stateless event emitter — carries a device's EXACT declared
6217
6223
  * event vocabulary verbatim (no normalization). Installed with the
@@ -9007,7 +9013,7 @@ var climateControlCapability = {
9007
9013
  scope: "device",
9008
9014
  deviceNative: true,
9009
9015
  mode: "singleton",
9010
- deviceTypes: [DeviceType.Thermostat],
9016
+ deviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
9011
9017
  methods: {
9012
9018
  setMode: method(object({
9013
9019
  deviceId: number().int().nonnegative(),
@@ -13606,10 +13612,30 @@ var deviceProviderCapability = {
13606
13612
  type: string()
13607
13613
  }))),
13608
13614
  supportsDiscovery: method(object({}), boolean()),
13609
- discoverDevices: method(object({}), array(DiscoveryCandidateSchema), {
13615
+ /**
13616
+ * Run a network scan. `params` carries optional provider-specific scan
13617
+ * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
13618
+ * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
13619
+ * (provider uses its local-network default).
13620
+ */
13621
+ discoverDevices: method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
13610
13622
  kind: "mutation",
13611
13623
  auth: "admin"
13612
13624
  }),
13625
+ /**
13626
+ * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
13627
+ * provider accepts (e.g. Gree's broadcast address for a different subnet).
13628
+ * `null` when the provider takes no extra scan params — the generic
13629
+ * aggregated scan never renders this; the per-integration scan does.
13630
+ */
13631
+ getDiscoveryParamsSchema: method(object({}), CreationSchemaOutputSchema),
13632
+ /**
13633
+ * The DeviceType this provider creates via manual add (Camera for
13634
+ * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
13635
+ * provider does not support manual creation. Lets the Add-Device dialog
13636
+ * pick the right type instead of assuming Camera.
13637
+ */
13638
+ getManualCreationType: method(object({}), object({ deviceType: _enum(DeviceType).nullable() })),
13613
13639
  adoptDiscoveredDevice: method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
13614
13640
  kind: "mutation",
13615
13641
  auth: "admin"
@@ -13733,9 +13759,23 @@ var BaseDeviceProvider = class extends BaseAddon {
13733
13759
  async supportsDiscovery() {
13734
13760
  return false;
13735
13761
  }
13736
- async discoverDevices() {
13762
+ async discoverDevices(_input) {
13737
13763
  return [];
13738
13764
  }
13765
+ /** Extra per-scan input form (e.g. a broadcast address for another subnet).
13766
+ * Null = no extra params. Override in providers that support scoped scans. */
13767
+ async getDiscoveryParamsSchema() {
13768
+ return null;
13769
+ }
13770
+ /**
13771
+ * The DeviceType this provider creates via manual add — derived from the
13772
+ * `deviceClasses` map (first registered type). `null` when manual creation is
13773
+ * unsupported. Lets the Add-Device dialog pick the right type per provider.
13774
+ */
13775
+ async getManualCreationType() {
13776
+ if (!await this.supportsManualCreation()) return { deviceType: null };
13777
+ return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
13778
+ }
13739
13779
  async adoptDiscoveredDevice(_input) {
13740
13780
  throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
13741
13781
  }
@@ -15580,7 +15620,10 @@ method(object({
15580
15620
  }), FieldProbeResultSchema, {
15581
15621
  kind: "mutation",
15582
15622
  auth: "admin"
15583
- }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15623
+ }), method(object({
15624
+ addonId: string(),
15625
+ integrationId: string()
15626
+ }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema.extend({ addonId: string() }), ListCandidatesOutputSchema, { auth: "admin" }), method(object({
15584
15627
  addonId: string(),
15585
15628
  integrationId: string()
15586
15629
  }), AdoptionStatusSchema, {
@@ -15595,7 +15638,24 @@ method(object({
15595
15638
  }), method(ResyncInputSchema, ResyncResultSchema, {
15596
15639
  kind: "mutation",
15597
15640
  auth: "admin"
15641
+ }), method(object({}), object({ providers: array(object({
15642
+ addonId: string(),
15643
+ label: string()
15644
+ })).readonly() }), { auth: "admin" }), method(object({}), object({ groups: array(object({
15645
+ addonId: string(),
15646
+ label: string(),
15647
+ candidates: array(DiscoveryCandidateSchema).readonly(),
15648
+ error: string().nullable()
15649
+ })).readonly() }), {
15650
+ kind: "mutation",
15651
+ auth: "admin"
15598
15652
  }), method(object({
15653
+ addonId: string(),
15654
+ params: record(string(), unknown()).optional()
15655
+ }), object({ candidates: array(DiscoveryCandidateSchema).readonly() }), {
15656
+ kind: "mutation",
15657
+ auth: "admin"
15658
+ }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
15599
15659
  deviceId: number(),
15600
15660
  key: string(),
15601
15661
  value: unknown()
@@ -20718,6 +20778,12 @@ Object.freeze({
20718
20778
  addonId: null,
20719
20779
  access: "create"
20720
20780
  },
20781
+ "deviceManager.adoptionListCandidateFilters": {
20782
+ capName: "device-manager",
20783
+ capScope: "system",
20784
+ addonId: null,
20785
+ access: "view"
20786
+ },
20721
20787
  "deviceManager.adoptionListCandidates": {
20722
20788
  capName: "device-manager",
20723
20789
  capScope: "system",
@@ -20766,12 +20832,30 @@ Object.freeze({
20766
20832
  addonId: null,
20767
20833
  access: "create"
20768
20834
  },
20835
+ "deviceManager.discoverAllProviders": {
20836
+ capName: "device-manager",
20837
+ capScope: "system",
20838
+ addonId: null,
20839
+ access: "create"
20840
+ },
20769
20841
  "deviceManager.discoverDevices": {
20770
20842
  capName: "device-manager",
20771
20843
  capScope: "system",
20772
20844
  addonId: null,
20773
20845
  access: "create"
20774
20846
  },
20847
+ "deviceManager.discoverProvider": {
20848
+ capName: "device-manager",
20849
+ capScope: "system",
20850
+ addonId: null,
20851
+ access: "create"
20852
+ },
20853
+ "deviceManager.discoveryProviders": {
20854
+ capName: "device-manager",
20855
+ capScope: "system",
20856
+ addonId: null,
20857
+ access: "view"
20858
+ },
20775
20859
  "deviceManager.enable": {
20776
20860
  capName: "device-manager",
20777
20861
  capScope: "system",
@@ -20922,6 +21006,18 @@ Object.freeze({
20922
21006
  addonId: null,
20923
21007
  access: "create"
20924
21008
  },
21009
+ "deviceManager.providerCreationType": {
21010
+ capName: "device-manager",
21011
+ capScope: "system",
21012
+ addonId: null,
21013
+ access: "view"
21014
+ },
21015
+ "deviceManager.providerDiscoveryParamsSchema": {
21016
+ capName: "device-manager",
21017
+ capScope: "system",
21018
+ addonId: null,
21019
+ access: "view"
21020
+ },
20925
21021
  "deviceManager.registerDevice": {
20926
21022
  capName: "device-manager",
20927
21023
  capScope: "system",
@@ -21138,6 +21234,18 @@ Object.freeze({
21138
21234
  addonId: null,
21139
21235
  access: "view"
21140
21236
  },
21237
+ "deviceProvider.getDiscoveryParamsSchema": {
21238
+ capName: "device-provider",
21239
+ capScope: "system",
21240
+ addonId: null,
21241
+ access: "view"
21242
+ },
21243
+ "deviceProvider.getManualCreationType": {
21244
+ capName: "device-provider",
21245
+ capScope: "system",
21246
+ addonId: null,
21247
+ access: "view"
21248
+ },
21141
21249
  "deviceProvider.getStatus": {
21142
21250
  capName: "device-provider",
21143
21251
  capScope: "system",
@@ -24127,6 +24235,34 @@ function buildConnectionFormSchema() {
24127
24235
  ]
24128
24236
  }] };
24129
24237
  }
24238
+ /**
24239
+ * Extra params form for a PER-INTEGRATION network scan. Gree's UDP broadcast
24240
+ * only reaches the addon node's local subnet; to find ACs on a DIFFERENT subnet
24241
+ * (e.g. 192.168.20.x) the operator supplies that subnet's directed-broadcast
24242
+ * address (192.168.20.255). Empty = default local-subnet broadcast.
24243
+ */
24244
+ function buildDiscoveryParamsFormSchema() {
24245
+ return { sections: [{
24246
+ id: "scan",
24247
+ title: "Scan options",
24248
+ description: "Leave empty to scan the local subnet. To find air conditioners on a different subnet, enter that subnet’s broadcast address (e.g. 192.168.20.255).",
24249
+ columns: 1,
24250
+ fields: [{
24251
+ type: "text",
24252
+ key: "broadcastAddress",
24253
+ label: "Broadcast address",
24254
+ required: false,
24255
+ placeholder: "192.168.20.255"
24256
+ }, {
24257
+ type: "number",
24258
+ key: "timeoutMs",
24259
+ label: "UDP timeout (ms)",
24260
+ min: 500,
24261
+ max: 3e4,
24262
+ default: 3e3
24263
+ }]
24264
+ }] };
24265
+ }
24130
24266
  //#endregion
24131
24267
  //#region ../../node_modules/@apocaliss92/nodegree/dist/index.js
24132
24268
  var GreeError = class extends Error {
@@ -24828,6 +24964,16 @@ function macKey(mac) {
24828
24964
  */
24829
24965
  var GreeConnectionResolver = class {
24830
24966
  #surfaces = /* @__PURE__ */ new Map();
24967
+ /**
24968
+ * Per-connection "surface (re)published" subscribers. An AC accessory child
24969
+ * activates from `restoreDevices` BEFORE its parent's manager finishes the
24970
+ * async bind, so `getDevice` is null at `onActivate` time and its
24971
+ * `stateChanged` listener never attaches → the climate slice stays frozen at
24972
+ * cold-start (`lastFetchedAt: 0`, no temperature). Children subscribe here and
24973
+ * (re)attach + recompute once the handle is published. A plain `Set` has no
24974
+ * listener cap. Mirrors the Ecowitt facade-resolver fan-out.
24975
+ */
24976
+ #subs = /* @__PURE__ */ new Map();
24831
24977
  /** Publish or remove the connection surface for a connection key. `null` removes. */
24832
24978
  set(connectionKey, surface) {
24833
24979
  if (surface === null) {
@@ -24835,6 +24981,24 @@ var GreeConnectionResolver = class {
24835
24981
  return;
24836
24982
  }
24837
24983
  this.#surfaces.set(connectionKey, surface);
24984
+ const subs = this.#subs.get(connectionKey);
24985
+ if (subs) for (const cb of subs) try {
24986
+ cb();
24987
+ } catch {}
24988
+ }
24989
+ /** Subscribe to surface (re)publish for a connection key. Returns unsubscribe. */
24990
+ onSurface(connectionKey, cb) {
24991
+ let set = this.#subs.get(connectionKey);
24992
+ if (!set) {
24993
+ set = /* @__PURE__ */ new Set();
24994
+ this.#subs.set(connectionKey, set);
24995
+ }
24996
+ set.add(cb);
24997
+ return () => {
24998
+ const current = this.#subs.get(connectionKey);
24999
+ current?.delete(cb);
25000
+ if (current && current.size === 0) this.#subs.delete(connectionKey);
25001
+ };
24838
25002
  }
24839
25003
  /** The bind-scan rows for a connection key, or empty when unknown. */
24840
25004
  discovered(connectionKey) {
@@ -24858,6 +25022,7 @@ var GreeConnectionResolver = class {
24858
25022
  /** Remove all registered surfaces (called on full shutdown). */
24859
25023
  clear() {
24860
25024
  this.#surfaces.clear();
25025
+ this.#subs.clear();
24861
25026
  }
24862
25027
  };
24863
25028
  /** The single in-process per-connection resolver shared between the AC device's
@@ -24868,7 +25033,7 @@ var greeConnections = new GreeConnectionResolver();
24868
25033
  function defaultFacade(config) {
24869
25034
  return new Nodegree(toNodegreeOptions(config));
24870
25035
  }
24871
- var DEFAULT_POLL_INTERVAL_MS = 3e4;
25036
+ var DEFAULT_POLL_INTERVAL_MS = 1e4;
24872
25037
  /**
24873
25038
  * Wraps exactly one `@apocaliss92/nodegree` facade bound to a SINGLE Gree AC
24874
25039
  * (standalone mode — the connection lives on the AC device). {@link start} runs a
@@ -25067,6 +25232,27 @@ function resolveBroadcastTarget(connection) {
25067
25232
  * The returned identity is what {@link import('./config.js').greeAcDeviceSchema}
25068
25233
  * persists on the new device; the live per-device manager re-binds on activate.
25069
25234
  */
25235
+ /**
25236
+ * One-shot LAN broadcast scan for Gree ACs — the network-discovery pass. Builds a
25237
+ * throwaway facade from default (or provided) UDP settings, runs the broadcast,
25238
+ * and returns every responder. No bind, no persistence. The provider's
25239
+ * `discoverDevices()` maps the responders to adoption candidates.
25240
+ */
25241
+ async function discoverGreeDevices(input) {
25242
+ const connection = settingsToGreeConfig({
25243
+ ...input.broadcastAddress ? { broadcastAddr: input.broadcastAddress } : {},
25244
+ ...input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}
25245
+ });
25246
+ const facade = (input.makeFacade ?? defaultFacade)(connection);
25247
+ try {
25248
+ const broadcast = resolveBroadcastTarget(connection);
25249
+ const opts = { timeoutMs: connection.timeoutMs };
25250
+ if (broadcast.length > 0) opts.broadcastAddr = broadcast;
25251
+ return await facade.discover(opts);
25252
+ } finally {
25253
+ await facade.close().catch(() => void 0);
25254
+ }
25255
+ }
25070
25256
  async function bindOnce(input) {
25071
25257
  const { connection, logger } = input;
25072
25258
  const facade = (input.makeFacade ?? defaultFacade)(connection);
@@ -25365,7 +25551,19 @@ var greeAcSchema = object({
25365
25551
  greeMac: string().min(1).describe("Gree AC MAC address"),
25366
25552
  connectionKey: string().min(1).describe("Per-device connection resolver key"),
25367
25553
  system: literal("gree").optional(),
25368
- integrationId: string().optional()
25554
+ integrationId: string().optional(),
25555
+ /** Operator filter — HVAC modes exposed in the control UI (subset of the
25556
+ * model's supported modes). Empty / omitted ⇒ expose all. `off` is always
25557
+ * exposed (power). */
25558
+ enabledModes: array(string()).optional(),
25559
+ /** Operator filter — fan speeds exposed in the control UI (subset of
25560
+ * `GREE_FAN_MODES`). Empty / omitted ⇒ expose all. */
25561
+ enabledFanModes: array(string()).optional(),
25562
+ /** Operator filter — expose the vertical-swing toggle. Default true. */
25563
+ exposeVerticalSwing: boolean().optional(),
25564
+ /** Operator filter — expose the horizontal-swing toggle (model-permitting).
25565
+ * Default true. */
25566
+ exposeHorizontalSwing: boolean().optional()
25369
25567
  });
25370
25568
  /**
25371
25569
  * One Gree air conditioner as a CamStack `Thermostat`-type accessory child.
@@ -25405,17 +25603,16 @@ var GreeAcDevice = class extends BaseDevice$1 {
25405
25603
  */
25406
25604
  get features() {
25407
25605
  const caps = this.resolveAc()?.capabilities ?? getAcCapabilities();
25408
- const flags = [
25409
- DeviceFeature.ClimateFanMode,
25410
- DeviceFeature.ClimatePreset,
25411
- DeviceFeature.ClimateSwingVertical
25412
- ];
25413
- if (caps.horizontalSwing) flags.push(DeviceFeature.ClimateSwingHorizontal);
25606
+ const cfg = this.config.values;
25607
+ const flags = [DeviceFeature.ClimateFanMode, DeviceFeature.ClimatePreset];
25608
+ if (cfg.exposeVerticalSwing ?? true) flags.push(DeviceFeature.ClimateSwingVertical);
25609
+ if (caps.horizontalSwing && (cfg.exposeHorizontalSwing ?? true)) flags.push(DeviceFeature.ClimateSwingHorizontal);
25414
25610
  return flags;
25415
25611
  }
25416
25612
  greeMac;
25417
25613
  connectionKey;
25418
25614
  stateChangedUnsub = null;
25615
+ surfaceUnsub = null;
25419
25616
  constructor(ctx) {
25420
25617
  const persisted = greeAcSchema.parse(ctx.persistedConfig ?? {});
25421
25618
  super(ctx, greeAcSchema, { type: ctx.deviceMeta.type });
@@ -25440,8 +25637,21 @@ var GreeAcDevice = class extends BaseDevice$1 {
25440
25637
  this.registerCaps();
25441
25638
  this.attachStateListener();
25442
25639
  this.recomputeSlices();
25640
+ this.surfaceUnsub = greeConnections.onSurface(this.connectionKey, () => {
25641
+ this.attachStateListener();
25642
+ this.recomputeSlices();
25643
+ });
25443
25644
  }
25444
25645
  async removeDevice() {
25646
+ this.detachStateListener();
25647
+ if (this.surfaceUnsub) {
25648
+ try {
25649
+ this.surfaceUnsub();
25650
+ } catch {}
25651
+ this.surfaceUnsub = null;
25652
+ }
25653
+ }
25654
+ detachStateListener() {
25445
25655
  if (this.stateChangedUnsub) {
25446
25656
  try {
25447
25657
  this.stateChangedUnsub();
@@ -25449,12 +25659,16 @@ var GreeAcDevice = class extends BaseDevice$1 {
25449
25659
  this.stateChangedUnsub = null;
25450
25660
  }
25451
25661
  }
25662
+ /** Attach the live `stateChanged` listener to the bound AC handle. Idempotent:
25663
+ * drops any prior listener first, so it is safe to call on every surface
25664
+ * (re)publish. No-op until the handle is actually bound. */
25452
25665
  attachStateListener() {
25453
25666
  const ac = this.resolveAc();
25454
25667
  if (!ac) {
25455
25668
  this.ctx.logger.debug("GreeAcDevice: handle not present; no live listener yet", { meta: { greeMac: this.greeMac } });
25456
25669
  return;
25457
25670
  }
25671
+ this.detachStateListener();
25458
25672
  const onState = () => {
25459
25673
  try {
25460
25674
  this.recomputeSlices();
@@ -25472,7 +25686,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25472
25686
  }
25473
25687
  registerCaps() {
25474
25688
  this.ctx.registerNativeCap(climateControlCapability, {
25475
- getStatus: async () => this.runtimeState.getCapState(CLIMATE_CAP) ?? CLIMATE_COLD_START,
25689
+ getStatus: async () => this.runtimeState.getCapState(CLIMATE_CAP) ?? this.applyClimateFilters(CLIMATE_COLD_START),
25476
25690
  setMode: async ({ mode }) => {
25477
25691
  const ac = this.requireAc();
25478
25692
  const libMode = capModeToLibMode(mode);
@@ -25519,7 +25733,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25519
25733
  await ac.setSwingHorizontal(boolToHorizontalSwing(on));
25520
25734
  }
25521
25735
  });
25522
- this.runtimeState.setCapState(CLIMATE_CAP, CLIMATE_COLD_START);
25736
+ this.runtimeState.setCapState(CLIMATE_CAP, this.applyClimateFilters(CLIMATE_COLD_START));
25523
25737
  this.ctx.registerNativeCap(fanControlCapability, {
25524
25738
  getStatus: async () => this.runtimeState.getCapState(FAN_CAP) ?? FAN_COLD_START,
25525
25739
  setPercentage: async ({ percentage }) => {
@@ -25566,7 +25780,7 @@ var GreeAcDevice = class extends BaseDevice$1 {
25566
25780
  swingHorizontal: ac.capabilities.horizontalSwing ? horizontalSwingToBool(ac.swingHorizontal) : null,
25567
25781
  lastFetchedAt: now
25568
25782
  };
25569
- this.runtimeState.setCapState(CLIMATE_CAP, climateSlice);
25783
+ this.runtimeState.setCapState(CLIMATE_CAP, this.applyClimateFilters(climateSlice));
25570
25784
  const fanSlice = {
25571
25785
  percentage: fanSpeedToPercentage(ac.fanSpeed),
25572
25786
  percentageStep: GREE_FAN_PERCENTAGE_STEP,
@@ -25578,7 +25792,97 @@ var GreeAcDevice = class extends BaseDevice$1 {
25578
25792
  };
25579
25793
  this.runtimeState.setCapState(FAN_CAP, fanSlice);
25580
25794
  }
25795
+ /**
25796
+ * Apply the operator's per-AC exposure filters to a climate slice: narrow
25797
+ * `availableModes` / `availableFanModes` to the enabled subsets (empty ⇒ all;
25798
+ * `off` is always kept) and null out a swing axis the operator has hidden.
25799
+ * Pure — returns a new slice, never mutates the input.
25800
+ */
25801
+ applyClimateFilters(slice) {
25802
+ const cfg = this.config.values;
25803
+ const enabledModes = cfg.enabledModes;
25804
+ const enabledFanModes = cfg.enabledFanModes;
25805
+ const exposeV = cfg.exposeVerticalSwing ?? true;
25806
+ const exposeH = cfg.exposeHorizontalSwing ?? true;
25807
+ const availableModes = enabledModes && enabledModes.length > 0 ? slice.availableModes.filter((m) => m === "off" || enabledModes.includes(m)) : slice.availableModes;
25808
+ const availableFanModes = enabledFanModes && enabledFanModes.length > 0 ? slice.availableFanModes.filter((f) => enabledFanModes.includes(f)) : slice.availableFanModes;
25809
+ return {
25810
+ ...slice,
25811
+ availableModes,
25812
+ availableFanModes,
25813
+ swingVertical: exposeV ? slice.swingVertical : null,
25814
+ swingHorizontal: exposeH ? slice.swingHorizontal : null
25815
+ };
25816
+ }
25817
+ /**
25818
+ * Per-AC exposure form (device-details settings). Lets the operator pick
25819
+ * which modes / fan speeds / swing axes appear in the AC control UI. An empty
25820
+ * multiselect means "expose all". The horizontal-swing toggle is only offered
25821
+ * when the model advertises horizontal swing.
25822
+ */
25823
+ getSettingsUISchema() {
25824
+ const supportsHSwing = (this.resolveAc()?.capabilities ?? getAcCapabilities()).horizontalSwing;
25825
+ const cfg = this.config.values;
25826
+ const fields = [
25827
+ {
25828
+ type: "multiselect",
25829
+ key: "enabledModes",
25830
+ label: "Modes",
25831
+ description: "Modes shown in the control UI. Empty = all. “Off” is always available.",
25832
+ options: SUPPORTED_CAP_MODES.map((m) => ({
25833
+ value: m,
25834
+ label: titleCase(m)
25835
+ }))
25836
+ },
25837
+ {
25838
+ type: "multiselect",
25839
+ key: "enabledFanModes",
25840
+ label: "Fan speeds",
25841
+ description: "Fan speeds shown in the control UI. Empty = all.",
25842
+ options: GREE_FAN_MODES.map((f) => ({
25843
+ value: f,
25844
+ label: titleCase(f)
25845
+ }))
25846
+ },
25847
+ {
25848
+ type: "boolean",
25849
+ style: "switch",
25850
+ key: "exposeVerticalSwing",
25851
+ label: "Vertical swing",
25852
+ default: true
25853
+ }
25854
+ ];
25855
+ if (supportsHSwing) fields.push({
25856
+ type: "boolean",
25857
+ style: "switch",
25858
+ key: "exposeHorizontalSwing",
25859
+ label: "Horizontal swing",
25860
+ default: true
25861
+ });
25862
+ return hydrateSchema({ sections: [{
25863
+ id: "exposure",
25864
+ title: "Exposed controls",
25865
+ description: "Choose which modes, fan speeds and swing axes appear in the air-conditioner control UI.",
25866
+ fields
25867
+ }] }, {
25868
+ enabledModes: cfg.enabledModes ?? [],
25869
+ enabledFanModes: cfg.enabledFanModes ?? [],
25870
+ exposeVerticalSwing: cfg.exposeVerticalSwing ?? true,
25871
+ exposeHorizontalSwing: cfg.exposeHorizontalSwing ?? true
25872
+ });
25873
+ }
25874
+ async applySettingsPatch(patch) {
25875
+ const typed = greeAcSchema.partial().parse(patch);
25876
+ await this.config.setAll(typed);
25877
+ this.recomputeSlices();
25878
+ await this.refreshFeatures();
25879
+ }
25581
25880
  };
25881
+ /** 'fan_only' → 'Fan only', 'medium_low' → 'Medium low'. Pure. */
25882
+ function titleCase(value) {
25883
+ const spaced = value.replace(/_/g, " ");
25884
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
25885
+ }
25582
25886
  //#endregion
25583
25887
  //#region src/devices/gree-toggle-device.ts
25584
25888
  var SWITCH_CAP = "switch";
@@ -25621,6 +25925,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
25621
25925
  greeMac;
25622
25926
  connectionKey;
25623
25927
  stateChangedUnsub = null;
25928
+ surfaceUnsub = null;
25624
25929
  constructor(ctx) {
25625
25930
  const persisted = greeToggleSchema.parse(ctx.persistedConfig ?? {});
25626
25931
  super(ctx, greeToggleSchema, { type: ctx.deviceMeta.type });
@@ -25672,8 +25977,21 @@ var GreeToggleDevice = class extends BaseDevice$1 {
25672
25977
  this.registerCap();
25673
25978
  this.attachStateListener();
25674
25979
  this.recomputeSlice();
25980
+ this.surfaceUnsub = greeConnections.onSurface(this.connectionKey, () => {
25981
+ this.attachStateListener();
25982
+ this.recomputeSlice();
25983
+ });
25675
25984
  }
25676
25985
  async removeDevice() {
25986
+ this.detachStateListener();
25987
+ if (this.surfaceUnsub) {
25988
+ try {
25989
+ this.surfaceUnsub();
25990
+ } catch {}
25991
+ this.surfaceUnsub = null;
25992
+ }
25993
+ }
25994
+ detachStateListener() {
25677
25995
  if (this.stateChangedUnsub) {
25678
25996
  try {
25679
25997
  this.stateChangedUnsub();
@@ -25690,6 +26008,7 @@ var GreeToggleDevice = class extends BaseDevice$1 {
25690
26008
  } });
25691
26009
  return;
25692
26010
  }
26011
+ this.detachStateListener();
25693
26012
  const onState = () => {
25694
26013
  try {
25695
26014
  this.recomputeSlice();
@@ -25849,7 +26168,7 @@ var GreeContainerDevice = class extends BaseDevice$1 {
25849
26168
  return [{
25850
26169
  stableIdSuffix: "ac",
25851
26170
  meta: {
25852
- type: DeviceType.Thermostat,
26171
+ type: DeviceType.Climate,
25853
26172
  name: this.name,
25854
26173
  linkDeviceId: this.id,
25855
26174
  ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
@@ -25935,9 +26254,10 @@ var GreeContainerDevice = class extends BaseDevice$1 {
25935
26254
  * `instanceMode: multiple` — many ACs may be added. Placement: any-node — Gree is
25936
26255
  * LOCAL UDP; the addon runs on whichever node shares the AC's subnet.
25937
26256
  *
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.
26257
+ * LAN UDP-broadcast auto-discovery is implemented via the `device-provider`
26258
+ * discovery surface (`supportsDiscovery`/`discoverDevices`/`adoptDiscoveredDevice`)
26259
+ * — the same one ONVIF uses and the aggregated "Scan network" modal fans out to.
26260
+ * Manual add-one-AC-by-IP (directed bind) remains available in parallel.
25941
26261
  */
25942
26262
  var GreeProviderAddon = class extends BaseDeviceProvider {
25943
26263
  addonId = "provider-gree";
@@ -25947,7 +26267,41 @@ var GreeProviderAddon = class extends BaseDeviceProvider {
25947
26267
  super({});
25948
26268
  }
25949
26269
  async supportsDiscovery() {
25950
- return false;
26270
+ return true;
26271
+ }
26272
+ async getDiscoveryParamsSchema() {
26273
+ return buildDiscoveryParamsFormSchema();
26274
+ }
26275
+ async discoverDevices(input) {
26276
+ const broadcastAddress = typeof input?.params?.["broadcastAddress"] === "string" ? input.params["broadcastAddress"].trim() : void 0;
26277
+ const timeoutMs = typeof input?.params?.["timeoutMs"] === "number" ? input.params["timeoutMs"] : void 0;
26278
+ const responders = await discoverGreeDevices({
26279
+ ...broadcastAddress ? { broadcastAddress } : {},
26280
+ ...timeoutMs ? { timeoutMs } : {}
26281
+ });
26282
+ this.ctx.logger.info("Gree discovery complete", { meta: {
26283
+ count: responders.length,
26284
+ broadcastAddress: broadcastAddress ?? "local"
26285
+ } });
26286
+ return responders.map((d) => {
26287
+ const displayName = d.name.length > 0 ? d.name : d.mac;
26288
+ return {
26289
+ stableId: `gree:${macKey(d.mac)}`,
26290
+ type: DeviceType.Container,
26291
+ suggestedName: displayName,
26292
+ prefilledConfig: {
26293
+ name: displayName,
26294
+ host: d.ip,
26295
+ ...d.model !== void 0 ? { model: d.model } : {}
26296
+ }
26297
+ };
26298
+ });
26299
+ }
26300
+ async adoptDiscoveredDevice(input) {
26301
+ return this.createDevice({
26302
+ type: DeviceType.Container,
26303
+ config: input.candidate.prefilledConfig
26304
+ });
25951
26305
  }
25952
26306
  async supportsManualCreation() {
25953
26307
  return true;
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ function buildGreeCandidates(input) {
18
18
  out.push({
19
19
  childNativeId: macKey(device.mac),
20
20
  name: device.name.length > 0 ? device.name : device.mac,
21
- type: require_addon.DeviceType.Thermostat,
21
+ type: require_addon.DeviceType.Climate,
22
22
  status: "online",
23
23
  metadata: {
24
24
  serialNumber: device.mac,
package/dist/index.mjs CHANGED
@@ -17,7 +17,7 @@ function buildGreeCandidates(input) {
17
17
  out.push({
18
18
  childNativeId: macKey(device.mac),
19
19
  name: device.name.length > 0 ? device.name : device.mac,
20
- type: DeviceType.Thermostat,
20
+ type: DeviceType.Climate,
21
21
  status: "online",
22
22
  metadata: {
23
23
  serialNumber: device.mac,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-gree",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Gree air-conditioner device-provider addon for CamStack — wraps the @apocaliss92/nodegree local-UDP client (LAN discovery + AES control), exposing climate-control and fan-control",
5
5
  "keywords": [
6
6
  "camstack",