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 +26 -0
- package/README.md +34 -5
- package/dist/features.js +151 -4
- package/dist/module.js +78 -31
- package/matterbridge-homey.config.json +1 -0
- package/matterbridge-homey.schema.json +6 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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.
|
|
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`),
|
|
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
|
-
|
|
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`.
|
|
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 {
|
|
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 &&
|
|
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.
|
|
82
|
-
|
|
83
|
-
this.
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
}
|
|
@@ -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.",
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "matterbridge-homey",
|
|
3
|
-
"version": "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.
|
|
9
|
+
"version": "0.3.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"homey-api": "3.20.0"
|