matterbridge-homey 0.1.0 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.0] - 2026-10-28
8
+
9
+ ### Added
10
+
11
+ - [platform]: Devices added to Homey are exposed right away, without a Matterbridge restart. The new `exposeNewDevices` setting (default on) turns this off.
12
+
13
+ ### Fixed
14
+
15
+ - [platform]: Homey device changes are applied one at a time, so a device that changes while the plugin starts or adds it isn't registered twice.
16
+
17
+ ## [0.2.0] - 2026-10-18
18
+
19
+ ### Added
20
+
21
+ - [devices]: Robotic vacuum cleaners with the standard Homey capabilities: start, stop, pause, resume and go home, the state, the clean modes and the battery.
22
+
23
+ ### Changed
24
+
25
+ - [docs]: The README explains the default color temperature range of lights and installing the plugin from npm.
26
+
27
+ ### Fixed
28
+
29
+ - [devices]: The Off mode of a thermostat or air conditioner turns `onoff` off instead of setting the `off` mode, which some drivers ignore.
30
+ - [platform]: A device that fails to build is skipped with an error instead of stopping the plugin from starting.
31
+ - [build]: Test builds of `npm run pack:tarball` are marked private, so Matterbridge reinstalls them from the uploaded tarball when the Docker container is recreated, instead of failing to find a `dev` version on npm.
32
+
7
33
  ## [0.1.0] - 2026-10-13
8
34
 
9
35
  ### Added
package/README.md CHANGED
@@ -36,6 +36,7 @@ The key is shown only once, so copy it right away.
36
36
  | `outletList` | These devices are exposed as plugs, whatever their Homey class. |
37
37
  | `capabilityBlackList` | These Homey capabilities are never exposed, e.g. `meter_power`. |
38
38
  | `deviceCapabilityBlackList` | Capabilities not exposed for one device: the device name (or ID) and its list of capabilities, e.g. `{ "Multisensor": ["alarm_occupancy"] }`. |
39
+ | `exposeNewDevices` | Expose devices added to Homey right away, without a restart (default on). The lists above still apply. When off, new devices are exposed after a restart. |
39
40
  | `debug` | Log the homey-api debug output. |
40
41
  | `unregisterOnShutdown` | Remove all devices from the controllers on shutdown. For development only. |
41
42
 
@@ -52,6 +53,9 @@ The Matter device type follows the Homey device class (or the class you chose fo
52
53
  | `windowcoverings`, `blinds`, `curtain`, `sunshade`, `shutterblinds` | `windowcoverings_set`, `windowcoverings_state` | Window covering (lift) |
53
54
  | `lock` | `locked` | Door lock |
54
55
  | `fan`, `airpurifier` | `onoff`, `fan_speed` | Fan |
56
+ | `vacuumcleaner` | `vacuumcleaner_state` or `onoff`, `operational_state`, `vacuumcleaner_job_mode` | Robotic vacuum cleaner |
57
+
58
+ The Off mode of a thermostat or air conditioner turns `onoff` off, and the other modes turn it back on. Only devices without `onoff` get the `off` value of `thermostat_mode`, because some drivers don't switch the device off with it.
55
59
 
56
60
  Sensors are added to any device that has them:
57
61
 
@@ -67,6 +71,26 @@ Sensors are added to any device that has them:
67
71
 
68
72
  Sub-capabilities of sensors, e.g. `measure_temperature.inside`, are exposed too. The outdoor temperature of a thermostat or air conditioner (e.g. `measure_temperature.outdoorTemperature`) is part of the thermostat.
69
73
 
74
+ ### Color temperature of lights
75
+
76
+ Homey doesn't give the color temperature of a light in Kelvin or mireds. Its `light_temperature` goes from 0 (the coldest white of the light) to 1 (the warmest white), and Homey doesn't tell which Kelvin range that is for the light.
77
+
78
+ The plugin therefore maps it onto a default range of 6500 K (153 mireds) to 2000 K (500 mireds) for every light. The coldest and warmest settings of the controller are the coldest and warmest white of the light, but the Kelvin values in between are approximate: a light that only goes from 2700 K to 6500 K looks colder than the controller shows.
79
+
80
+ ### Robotic vacuum cleaners
81
+
82
+ Vacuums are supported with the standard Homey capabilities and values only:
83
+
84
+ - Start and stop (the Matter run modes Idle and Cleaning) set `vacuumcleaner_state` to `cleaning` or `stopped`, or turn `onoff` on or off.
85
+ - Go home sets `vacuumcleaner_state` to `docked`. Homey has no standard pause: pause stops the vacuum, resume starts it again.
86
+ - The state comes from `vacuumcleaner_state` (cleaning, docked, charging, stopped) and the standard values of `operational_state` (paused, error).
87
+ - The standard values of `vacuumcleaner_job_mode` (normal, high, turbo, mop, auto) are the Matter clean modes. Matter only changes them while the vacuum is idle.
88
+ - No rooms or maps: Homey has no standard capability for them.
89
+
90
+ Vacuums without `vacuumcleaner_state` or `onoff` aren't exposed. This includes Matter vacuums paired with Homey: Homey's Matter driver uses the Matter state and mode numbers instead of the standard values. Add such a vacuum to your Matter controller directly instead.
91
+
92
+ Apple Home doesn't support a vacuum in a bridge, and the whole bridge becomes unstable. If you use Apple Home, add your vacuums to the black list.
93
+
70
94
  ### How devices are composed
71
95
 
72
96
  Each Homey device is one Matter bridged device. Its main function and as many sensors as possible share one endpoint, so controllers show one device instead of one per function (Alexa shows every endpoint as a separate device). A function that can't share the endpoint gets a child endpoint: a second sensor of the same kind (e.g. `alarm_motion` and `alarm_presence`) or a sub-capability (e.g. `onoff.output2`).
@@ -90,13 +114,18 @@ The white and black lists accept Homey device IDs as well as names. Entries by n
90
114
 
91
115
  ### Limitations
92
116
 
93
- - New Homey devices need a Matterbridge restart; the frontend asks for it. Deleted devices are removed right away.
117
+ - New Homey devices are exposed right away and deleted devices are removed right away. With `exposeNewDevices` off, new devices need a Matterbridge restart; the frontend asks for it. To keep new devices out entirely, use the white list.
118
+ - A new device is exposed with the capabilities Homey reports for it. Capabilities that its app adds later appear after a restart.
94
119
  - Devices with the same name get their zone appended, e.g. `Ceiling light Kitchen`.
95
- - Not supported yet: buttons and remotes, media players and TVs (only their `onoff`), vacuum cleaners, window covering tilt, plant moisture, `level_aqi`.
120
+ - Not supported yet: buttons and remotes, media players and TVs (only their `onoff`), window covering tilt, plant moisture, `level_aqi`.
96
121
 
97
122
  ## Install
98
123
 
99
- The plugin isn't published on npm yet. Build a tarball and upload it to Matterbridge:
124
+ On the Home page of the Matterbridge frontend, enter `matterbridge-homey` in the **Install plugins** panel and click **Install**. Then set the address and API key in the plugin configuration and restart.
125
+
126
+ ### Test builds
127
+
128
+ To test changes before they are published, build a tarball and upload it to Matterbridge:
100
129
 
101
130
  ```bash
102
131
  npm install
@@ -104,9 +133,9 @@ npm link matterbridge
104
133
  npm run pack:tarball
105
134
  ```
106
135
 
107
- This creates `matterbridge-homey-<version>-dev-<date>-<time>.tgz` in the project folder (`npm run pack:tarball -- --release` keeps the plain version). On the Home page of the Matterbridge frontend, click **Upload** in the **Install plugins** panel and choose the tarball (the panel is hidden when Matterbridge runs read-only). Matterbridge installs it with `npm install -g <tarball> --omit=dev`, so the host needs access to the npm registry for `homey-api`. Then set the address and API key in the plugin configuration and restart.
136
+ This creates `matterbridge-homey-<version>-dev-<date>-<time>.tgz` in the project folder (`npm run pack:tarball -- --release` keeps the plain version). On the Home page of the Matterbridge frontend, click **Upload** in the **Install plugins** panel and choose the tarball (the panel is hidden when Matterbridge runs read-only). Matterbridge installs it with `npm install -g <tarball> --omit=dev`, so the host needs access to the npm registry for `homey-api`.
108
137
 
109
- Uploading a newer tarball replaces the installed version.
138
+ Uploading a newer tarball replaces the installed version. Test builds are marked private in their `package.json`: when the plugin is missing at start, e.g. after Docker recreated the Matterbridge container for an image update, Matterbridge reinstalls a private plugin from the last uploaded tarball instead of from npm. Release builds are reinstalled from npm.
110
139
 
111
140
  ## Development
112
141
 
package/dist/features.js CHANGED
@@ -1,5 +1,6 @@
1
- import { airQualitySensor, colorTemperatureLight, contactSensor, dimmableLight, dimmablePlugInUnit, doorLock, electricalSensor, extendedColorLight, fan, humiditySensor, lightSensor, occupancySensor, onOffLight, onOffPlugInUnit, pressureSensor, rainSensor, smokeCoAlarm, temperatureSensor, thermostat, waterLeakDetector, windowCovering, } from 'matterbridge';
2
- import { AirQuality, BooleanState, CarbonDioxideConcentrationMeasurement, CarbonMonoxideConcentrationMeasurement, ColorControl, DoorLock, ElectricalEnergyMeasurement, ElectricalPowerMeasurement, FanControl, IlluminanceMeasurement, LevelControl, OccupancySensing, OnOff, Pm10ConcentrationMeasurement, Pm25ConcentrationMeasurement, PressureMeasurement, RelativeHumidityMeasurement, SmokeCoAlarm, TemperatureMeasurement, Thermostat, TotalVolatileOrganicCompoundsConcentrationMeasurement, WindowCovering, } from 'matterbridge/matter/clusters';
1
+ import { airQualitySensor, colorTemperatureLight, contactSensor, dimmableLight, dimmablePlugInUnit, doorLock, electricalSensor, extendedColorLight, fan, humiditySensor, lightSensor, occupancySensor, onOffLight, onOffPlugInUnit, pressureSensor, rainSensor, roboticVacuumCleaner, smokeCoAlarm, temperatureSensor, thermostat, waterLeakDetector, windowCovering, } from 'matterbridge';
2
+ import { MatterbridgeRvcCleanModeServer, MatterbridgeRvcOperationalStateServer, MatterbridgeRvcRunModeServer } from 'matterbridge/devices';
3
+ import { AirQuality, BooleanState, CarbonDioxideConcentrationMeasurement, CarbonMonoxideConcentrationMeasurement, ColorControl, DoorLock, ElectricalEnergyMeasurement, ElectricalPowerMeasurement, FanControl, IlluminanceMeasurement, LevelControl, OccupancySensing, OnOff, Pm10ConcentrationMeasurement, Pm25ConcentrationMeasurement, PressureMeasurement, RelativeHumidityMeasurement, RvcCleanMode, RvcOperationalState, RvcRunMode, SmokeCoAlarm, TemperatureMeasurement, Thermostat, TotalVolatileOrganicCompoundsConcentrationMeasurement, WindowCovering, } from 'matterbridge/matter/clusters';
3
4
  import { kelvinToRGB, miredToKelvin, rgbColorToHslColor, xyToHsl } from 'matterbridge/utils';
4
5
  import { colorTemperatureMaxMireds, colorTemperatureMinMireds, dimToLevel, levelToDim, lightTemperatureToMireds, liftToWindowCoveringsSet, luxToMatter, matterColorToUnit, miredsToLightTemperature, setpointToTargetTemperature, toHundredths, toThousandths, unitToMatterColor, windowCoveringsSetToLift, } from './converters.js';
5
6
  const lightClasses = new Set(['light']);
@@ -8,6 +9,7 @@ const coolingClasses = new Set(['airconditioning']);
8
9
  const coveringClasses = new Set(['windowcoverings', 'blinds', 'curtain', 'sunshade', 'shutterblinds']);
9
10
  const fanClasses = new Set(['fan', 'airpurifier']);
10
11
  const lockClasses = new Set(['lock']);
12
+ const vacuumClasses = new Set(['vacuumcleaner']);
11
13
  const outputClasses = new Set(['socket', 'other']);
12
14
  export function hasCapability(device, capabilityId) {
13
15
  return device.capabilities.includes(capabilityId);
@@ -44,6 +46,8 @@ export function planFeatures(device) {
44
46
  features.push(doorLockFeature());
45
47
  else if (fanClasses.has(deviceClass) && hasOnOff)
46
48
  features.push(fanFeature(device));
49
+ else if (vacuumClasses.has(deviceClass) && (hasOnOff || hasCapability(device, 'vacuumcleaner_state')))
50
+ features.push(vacuumFeature(device));
47
51
  else if (hasOnOff && deviceClass !== 'sensor')
48
52
  features.push(plugFeature(device, 'onoff'));
49
53
  if (!climate)
@@ -61,7 +65,7 @@ export function planFeatures(device) {
61
65
  addSensor(features, device, 'measure_luminance', illuminanceFeature);
62
66
  if (['measure_co2', 'measure_co', 'measure_pm25', 'measure_pm10', 'measure_tvoc'].some((id) => hasCapability(device, id)))
63
67
  features.push(airQualityFeature(device));
64
- let hasMainFunction = features.length > 0 && features[0].clusters.some((cluster) => ['OnOff', 'Thermostat', 'WindowCovering', 'DoorLock', 'FanControl'].includes(cluster));
68
+ let hasMainFunction = features.length > 0 && features[0].clusters.some((cluster) => ['OnOff', 'Thermostat', 'WindowCovering', 'DoorLock', 'FanControl', 'RvcRunMode'].includes(cluster));
65
69
  for (const capabilityId of device.capabilities) {
66
70
  const [base, sub] = capabilityId.split('.');
67
71
  if (sub === undefined)
@@ -319,7 +323,7 @@ function climateFeature(device, isCooling) {
319
323
  if (!isFromController(actionContext))
320
324
  return;
321
325
  const modeId = Object.keys(systemModes).find((id) => systemModes[id] === value);
322
- if (value === Thermostat.SystemMode.Off && !modeIds.has('off') && hasOnOff)
326
+ if (value === Thermostat.SystemMode.Off && hasOnOff)
323
327
  context.set('onoff', false);
324
328
  else if (modeId !== undefined && modeIds.has(modeId))
325
329
  context.set('thermostat_mode', modeId);
@@ -469,6 +473,149 @@ function doorLockFeature() {
469
473
  },
470
474
  };
471
475
  }
476
+ const vacuumIdleMode = 1;
477
+ const vacuumCleaningMode = 2;
478
+ const vacuumCleanModes = {
479
+ normal: { label: 'Normal', mode: 1, modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }] },
480
+ high: { label: 'High', mode: 2, modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.DeepClean }] },
481
+ turbo: { label: 'Turbo', mode: 3, modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Max }] },
482
+ mop: { label: 'Mop', mode: 4, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }] },
483
+ auto: { label: 'Auto', mode: 5, modeTags: [{ value: RvcCleanMode.ModeTag.Auto }, { value: RvcCleanMode.ModeTag.Vacuum }] },
484
+ };
485
+ const vacuumStates = {
486
+ cleaning: RvcOperationalState.OperationalState.Running,
487
+ spot_cleaning: RvcOperationalState.OperationalState.Running,
488
+ docked: RvcOperationalState.OperationalState.Docked,
489
+ charging: RvcOperationalState.OperationalState.Charging,
490
+ stopped: RvcOperationalState.OperationalState.Stopped,
491
+ };
492
+ const vacuumOperationalStates = {
493
+ stopped: RvcOperationalState.OperationalState.Stopped,
494
+ running: RvcOperationalState.OperationalState.Running,
495
+ paused: RvcOperationalState.OperationalState.Paused,
496
+ error: RvcOperationalState.OperationalState.Error,
497
+ };
498
+ export function getVacuumCleanModes(device) {
499
+ if (!hasCapability(device, 'vacuumcleaner_job_mode'))
500
+ return [];
501
+ const values = getCapability(device, 'vacuumcleaner_job_mode')?.values?.map((value) => value.id) ?? Object.keys(vacuumCleanModes);
502
+ return values.filter((value) => Object.hasOwn(vacuumCleanModes, value));
503
+ }
504
+ function mapStandardValue(mapping, value) {
505
+ return typeof value === 'string' && Object.hasOwn(mapping, value) ? mapping[value] : undefined;
506
+ }
507
+ function vacuumFeature(device) {
508
+ const hasState = hasCapability(device, 'vacuumcleaner_state');
509
+ const cleanModeIds = getVacuumCleanModes(device);
510
+ const { OperationalState } = RvcOperationalState;
511
+ return {
512
+ name: 'vacuum',
513
+ capabilities: ['vacuumcleaner_state', 'onoff', 'operational_state', 'vacuumcleaner_job_mode'],
514
+ deviceTypes: [roboticVacuumCleaner],
515
+ clusters: ['RvcRunMode', 'RvcCleanMode', 'RvcOperationalState'],
516
+ build: (context) => {
517
+ const { endpoint } = context;
518
+ const operationalState = () => {
519
+ const operational = mapStandardValue(vacuumOperationalStates, context.value('operational_state'));
520
+ if (operational === OperationalState.Paused || operational === OperationalState.Error)
521
+ return operational;
522
+ return (mapStandardValue(vacuumStates, context.value('vacuumcleaner_state')) ??
523
+ operational ??
524
+ (context.value('onoff') === true ? OperationalState.Running : OperationalState.Stopped));
525
+ };
526
+ const operationalError = (state) => state === OperationalState.Error
527
+ ? { errorStateId: RvcOperationalState.ErrorState.UnableToCompleteOperation }
528
+ : { errorStateId: RvcOperationalState.ErrorState.NoError, errorStateDetails: 'Fully operational' };
529
+ const runMode = (state) => state === OperationalState.Running || state === OperationalState.Paused ? vacuumCleaningMode : vacuumIdleMode;
530
+ const cleanMode = () => {
531
+ const jobMode = context.value('vacuumcleaner_job_mode');
532
+ return vacuumCleanModes[typeof jobMode === 'string' && cleanModeIds.includes(jobMode) ? jobMode : cleanModeIds[0]].mode;
533
+ };
534
+ const state = operationalState();
535
+ endpoint.behaviors.require(MatterbridgeRvcRunModeServer, {
536
+ supportedModes: [
537
+ { label: 'Idle', mode: vacuumIdleMode, modeTags: [{ value: RvcRunMode.ModeTag.Idle }] },
538
+ { label: 'Cleaning', mode: vacuumCleaningMode, modeTags: [{ value: RvcRunMode.ModeTag.Cleaning }] },
539
+ ],
540
+ currentMode: runMode(state),
541
+ });
542
+ if (cleanModeIds.length > 0) {
543
+ endpoint.behaviors.require(MatterbridgeRvcCleanModeServer, { supportedModes: cleanModeIds.map((id) => vacuumCleanModes[id]), currentMode: cleanMode() });
544
+ }
545
+ endpoint.behaviors.require(MatterbridgeRvcOperationalStateServer, {
546
+ phaseList: null,
547
+ currentPhase: null,
548
+ countdownTime: null,
549
+ operationalStateList: [
550
+ OperationalState.Stopped,
551
+ OperationalState.Running,
552
+ OperationalState.Paused,
553
+ OperationalState.Error,
554
+ OperationalState.SeekingCharger,
555
+ OperationalState.Charging,
556
+ OperationalState.Docked,
557
+ ].map((operationalStateId) => ({ operationalStateId })),
558
+ operationalState: state,
559
+ operationalError: operationalError(state),
560
+ });
561
+ const start = () => {
562
+ if (hasState)
563
+ context.set('vacuumcleaner_state', 'cleaning', true);
564
+ else
565
+ context.set('onoff', true, true);
566
+ };
567
+ const stop = (vacuumState, reflect) => {
568
+ if (hasState)
569
+ context.set('vacuumcleaner_state', vacuumState, reflect);
570
+ else
571
+ context.set('onoff', false, reflect);
572
+ };
573
+ endpoint.addCommandHandler('RvcRunMode.changeToMode', ({ request, attributes }) => {
574
+ if (request.newMode === attributes.currentMode)
575
+ return;
576
+ if (request.newMode === vacuumCleaningMode)
577
+ start();
578
+ else if (request.newMode === vacuumIdleMode)
579
+ stop('stopped', true);
580
+ });
581
+ endpoint.addCommandHandler('RvcOperationalState.pause', ({ attributes }) => {
582
+ if (attributes.operationalState === OperationalState.Running || attributes.operationalState === OperationalState.SeekingCharger)
583
+ stop('stopped', false);
584
+ });
585
+ endpoint.addCommandHandler('RvcOperationalState.resume', ({ attributes }) => {
586
+ if (attributes.operationalState === OperationalState.Paused)
587
+ start();
588
+ });
589
+ endpoint.addCommandHandler('RvcOperationalState.goHome', ({ attributes }) => {
590
+ if (attributes.operationalState === OperationalState.Stopped ||
591
+ attributes.operationalState === OperationalState.Running ||
592
+ attributes.operationalState === OperationalState.Paused)
593
+ stop('docked', true);
594
+ });
595
+ if (cleanModeIds.length > 0) {
596
+ endpoint.addCommandHandler('RvcCleanMode.changeToMode', ({ request, attributes }) => {
597
+ const modeId = cleanModeIds.find((id) => vacuumCleanModes[id].mode === request.newMode);
598
+ if (modeId === undefined || request.newMode === attributes.currentMode || endpoint.getAttribute(RvcRunMode, 'currentMode') !== vacuumIdleMode)
599
+ return;
600
+ context.set('vacuumcleaner_job_mode', modeId, true);
601
+ });
602
+ }
603
+ const update = async () => {
604
+ const newState = operationalState();
605
+ await endpoint.updateAttribute(RvcOperationalState, 'operationalState', newState, endpoint.log);
606
+ await endpoint.updateAttribute(RvcOperationalState, 'operationalError', operationalError(newState), endpoint.log);
607
+ await endpoint.updateAttribute(RvcRunMode, 'currentMode', runMode(newState), endpoint.log);
608
+ };
609
+ for (const capabilityId of ['vacuumcleaner_state', 'operational_state', 'onoff'])
610
+ context.bind(capabilityId, update);
611
+ if (cleanModeIds.length > 0) {
612
+ context.bind('vacuumcleaner_job_mode', async () => {
613
+ await endpoint.updateAttribute(RvcCleanMode, 'currentMode', cleanMode(), endpoint.log);
614
+ });
615
+ }
616
+ },
617
+ };
618
+ }
472
619
  function temperatureFeature(capabilityId) {
473
620
  return {
474
621
  name: capabilityId,
package/dist/module.js CHANGED
@@ -1,5 +1,5 @@
1
- import { MatterbridgeDynamicPlatform } from 'matterbridge';
2
- import { CYAN, db, idn, nf, rs } from 'matterbridge/logger';
1
+ import { MatterbridgeDynamicPlatform, roboticVacuumCleaner } from 'matterbridge';
2
+ import { CYAN, db, idn, nf, rs, wr } from 'matterbridge/logger';
3
3
  import { fireAndForget, getErrorMessage, inspectError, isValidArray, isValidObject, isValidString } from 'matterbridge/utils';
4
4
  import { getExposedCapabilities } from './features.js';
5
5
  import { HomeyApiConnection } from './homey.js';
@@ -54,6 +54,9 @@ export class HomeyPlatform extends MatterbridgeDynamicPlatform {
54
54
  homey;
55
55
  bridgedDevices = new Map();
56
56
  zones = [];
57
+ newDeviceIds = new Set();
58
+ areDevicesConfigured = false;
59
+ deviceChanges = Promise.resolve();
57
60
  constructor(matterbridge, log, config, homey) {
58
61
  super(matterbridge, log, config);
59
62
  this.config = config;
@@ -73,20 +76,26 @@ export class HomeyPlatform extends MatterbridgeDynamicPlatform {
73
76
  this.config.outletList = isValidArray(config.outletList) ? config.outletList : [];
74
77
  this.config.capabilityBlackList = isValidArray(config.capabilityBlackList) ? config.capabilityBlackList : [];
75
78
  this.config.deviceCapabilityBlackList = isValidObject(config.deviceCapabilityBlackList) ? config.deviceCapabilityBlackList : {};
79
+ this.config.exposeNewDevices ??= true;
76
80
  this.config.debug ??= false;
77
81
  this.config.unregisterOnShutdown ??= false;
78
82
  this.homey =
79
83
  homey ?? new HomeyApiConnection(config.address, config.token, 15000, config.debug ? (message) => this.log.debug(`[homey-api] ${message}`) : undefined);
80
84
  this.homey.on('device.create', (device) => {
81
- this.log.notice(`New Homey device ${CYAN}${device.name}${nf}: restart Matterbridge to expose it`);
82
- if (this.isConfigured)
83
- this.wssSendRestartRequired();
85
+ if (!this.config.exposeNewDevices) {
86
+ this.log.notice(`New Homey device ${CYAN}${device.name}${nf}: restart Matterbridge to expose it`);
87
+ if (this.isConfigured)
88
+ this.wssSendRestartRequired();
89
+ return;
90
+ }
91
+ this.log.notice(`New Homey device ${CYAN}${device.name}${nf}`);
92
+ fireAndForget(this.serialize(async () => this.addNewDevice(device)), this.log, `addition of ${device.name}`);
84
93
  });
85
94
  this.homey.on('device.update', (device) => {
86
- fireAndForget(this.updateDevice(device), this.log, `update of ${device.name}`);
95
+ fireAndForget(this.serialize(async () => this.updateDevice(device)), this.log, `update of ${device.name}`);
87
96
  });
88
97
  this.homey.on('device.delete', (device) => {
89
- fireAndForget(this.removeDevice(device.id), this.log, `removal of ${device.id}`);
98
+ fireAndForget(this.serialize(async () => this.removeDevice(device.id)), this.log, `removal of ${device.id}`);
90
99
  });
91
100
  this.homey.on('disconnected', () => {
92
101
  this.log.warn('Disconnected from Homey');
@@ -113,22 +122,27 @@ export class HomeyPlatform extends MatterbridgeDynamicPlatform {
113
122
  throw new Error(`Unable to connect to Homey at ${this.config.address}: ${getErrorMessage(error)}`, { cause: error });
114
123
  }
115
124
  this.log.notice(`Connected to Homey ${CYAN}${this.homey.homeyId ?? ''}${nf}`);
116
- const [devices, zones] = await Promise.all([this.homey.getDevices(), this.homey.getZones()]);
117
- this.zones = zones;
118
- this.log.info(`Found ${CYAN}${devices.length}${nf} devices in ${CYAN}${zones.length}${nf} zones`);
119
- const names = getUniqueDeviceNames(devices, zones);
120
- for (const device of devices.toSorted((a, b) => a.name.localeCompare(b.name))) {
121
- await this.addDevice(device, names.get(device.id) ?? device.name, zones);
122
- }
125
+ await this.serialize(async () => {
126
+ const [devices, zones] = await Promise.all([this.homey.getDevices(), this.homey.getZones()]);
127
+ this.zones = zones;
128
+ this.log.info(`Found ${CYAN}${devices.length}${nf} devices in ${CYAN}${zones.length}${nf} zones`);
129
+ const names = getUniqueDeviceNames(devices, zones);
130
+ for (const device of devices.toSorted((a, b) => a.name.localeCompare(b.name))) {
131
+ await this.addDevice(device, names.get(device.id) ?? device.name, zones);
132
+ }
133
+ });
123
134
  this.log.info(`Started platform ${idn}${this.config.name}${rs}${nf} with ${CYAN}${this.bridgedDevices.size}${nf} devices`);
124
135
  }
125
136
  async onConfigure() {
126
137
  await super.onConfigure();
127
138
  this.log.info(`Configuring platform ${idn}${this.config.name}${rs}${nf}...`);
128
- for (const bridgedDevice of this.bridgedDevices.values()) {
129
- await bridgedDevice.applyAll();
130
- bridgedDevice.subscribe();
131
- }
139
+ await this.serialize(async () => {
140
+ for (const bridgedDevice of this.bridgedDevices.values()) {
141
+ await bridgedDevice.applyAll();
142
+ bridgedDevice.subscribe();
143
+ }
144
+ this.areDevicesConfigured = true;
145
+ });
132
146
  this.log.info(`Configured platform ${idn}${this.config.name}${rs}${nf}`);
133
147
  }
134
148
  async onChangeLoggerLevel(logLevel) {
@@ -179,31 +193,57 @@ export class HomeyPlatform extends MatterbridgeDynamicPlatform {
179
193
  if (!this.validateDevice(keys))
180
194
  return;
181
195
  const excludedCapabilities = new Set([...this.config.capabilityBlackList, ...keys.flatMap((key) => this.config.deviceCapabilityBlackList[key] ?? [])]);
182
- const bridgedDevice = HomeyBridgedDevice.create({
183
- device: customizeDevice(remapped, undefined, excludedCapabilities),
184
- name,
185
- connection: this.homey,
186
- vendorId: this.matterbridge.aggregatorVendorId,
187
- logLevel: this.log.logLevel,
188
- configUrl,
189
- });
190
- if (!bridgedDevice) {
191
- this.log.info(`Device ${CYAN}${name}${nf} has no capabilities left after the capability black lists. Skipping...`);
192
- return;
193
- }
194
196
  try {
197
+ const bridgedDevice = HomeyBridgedDevice.create({
198
+ device: customizeDevice(remapped, undefined, excludedCapabilities),
199
+ name,
200
+ connection: this.homey,
201
+ vendorId: this.matterbridge.aggregatorVendorId,
202
+ logLevel: this.log.logLevel,
203
+ configUrl,
204
+ });
205
+ if (!bridgedDevice) {
206
+ this.log.info(`Device ${CYAN}${name}${nf} has no capabilities left after the capability black lists. Skipping...`);
207
+ return;
208
+ }
195
209
  this.log.info(`Registering device ${idn}${name}${rs}${nf} class ${CYAN}${remapped.virtualClass ?? remapped.class}${nf}...`);
196
210
  await this.registerDevice(bridgedDevice.endpoint);
197
211
  this.bridgedDevices.set(device.id, bridgedDevice);
212
+ if (bridgedDevice.endpoint.deviceTypes.has(roboticVacuumCleaner.code)) {
213
+ this.log.warn(`Device ${CYAN}${name}${wr} is a robotic vacuum cleaner: Apple Home doesn't support it in a bridge. If you use Apple Home, add it to the black list.`);
214
+ }
198
215
  }
199
216
  catch (error) {
200
217
  inspectError(this.log, `Failed to register device ${name}`, error);
201
218
  }
202
219
  }
220
+ async addNewDevice(device) {
221
+ if (this.bridgedDevices.has(device.id))
222
+ return;
223
+ const [devices, zones] = await Promise.all([this.homey.getDevices(), this.homey.getZones()]);
224
+ this.zones = zones;
225
+ const names = getUniqueDeviceNames([...devices.filter((other) => other.id !== device.id), device], zones);
226
+ for (const [deviceId, bridged] of this.bridgedDevices)
227
+ await bridged.setName(names.get(deviceId) ?? bridged.device.name);
228
+ await this.addDevice(device, names.get(device.id) ?? device.name, zones);
229
+ const bridgedDevice = this.bridgedDevices.get(device.id);
230
+ if (!bridgedDevice) {
231
+ this.newDeviceIds.add(device.id);
232
+ return;
233
+ }
234
+ this.newDeviceIds.delete(device.id);
235
+ if (!this.areDevicesConfigured)
236
+ return;
237
+ await bridgedDevice.applyAll();
238
+ bridgedDevice.subscribe();
239
+ }
203
240
  async updateDevice(device) {
204
241
  const bridgedDevice = this.bridgedDevices.get(device.id);
205
- if (!bridgedDevice)
242
+ if (!bridgedDevice) {
243
+ if (this.newDeviceIds.has(device.id))
244
+ await this.addNewDevice(device);
206
245
  return;
246
+ }
207
247
  const isRenamed = device.name !== bridgedDevice.homeyName;
208
248
  await bridgedDevice.update(device);
209
249
  if (!isRenamed)
@@ -213,6 +253,7 @@ export class HomeyPlatform extends MatterbridgeDynamicPlatform {
213
253
  await bridged.setName(names.get(deviceId) ?? bridged.device.name);
214
254
  }
215
255
  async removeDevice(deviceId) {
256
+ this.newDeviceIds.delete(deviceId);
216
257
  const bridgedDevice = this.bridgedDevices.get(deviceId);
217
258
  if (!bridgedDevice)
218
259
  return;
@@ -221,4 +262,10 @@ export class HomeyPlatform extends MatterbridgeDynamicPlatform {
221
262
  this.bridgedDevices.delete(deviceId);
222
263
  await this.unregisterDevice(bridgedDevice.endpoint);
223
264
  }
265
+ async serialize(change) {
266
+ const result = this.deviceChanges.then(change);
267
+ this.deviceChanges = result.catch(() => {
268
+ });
269
+ await result;
270
+ }
224
271
  }
@@ -10,6 +10,7 @@
10
10
  "outletList": [],
11
11
  "capabilityBlackList": [],
12
12
  "deviceCapabilityBlackList": {},
13
+ "exposeNewDevices": true,
13
14
  "debug": false,
14
15
  "unregisterOnShutdown": false
15
16
  }
@@ -104,6 +104,12 @@
104
104
  "selectDeviceEntityFrom": "name"
105
105
  }
106
106
  },
107
+ "exposeNewDevices": {
108
+ "title": "Expose New Devices",
109
+ "description": "Expose the devices added to Homey right away, without restarting Matterbridge. The white list, black list and zone white list still apply. When off, new devices are exposed after the next restart.",
110
+ "type": "boolean",
111
+ "default": true
112
+ },
107
113
  "debug": {
108
114
  "title": "Enable Debug",
109
115
  "description": "Enable debug mode for the plugin. Only for debugging purposes, not recommended for production use.",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "matterbridge-homey",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "matterbridge-homey",
9
- "version": "0.1.0",
9
+ "version": "0.3.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "homey-api": "3.20.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "matterbridge-homey",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Matterbridge plugin that exposes Athom Homey Pro devices to Matter",
5
5
  "author": "Stefan Schweiger",
6
6
  "license": "Apache-2.0",