matterbridge-dyson-robot
Version:
A Matterbridge plugin that connects Dyson robot vacuums and air treatment devices to the Matter smart home ecosystem via their local or cloud MQTT APIs.
287 lines • 13.6 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025-2026 Alexander Thoukydides
import { Dyson360ZoneCleanStatus, Dyson360ZoneIcon } from './dyson-360-types.js';
import { AreaNamespaceTag } from 'matterbridge/matter';
import { assertIsDefined } from './utils.js';
import { ServiceArea } from 'matterbridge/matter/clusters';
import { SelectAreaError } from './error-360.js';
import { formatAreaName } from './endpoint-360.js';
import { logError } from './log-error.js';
const LOCATION_TYPE_MAP = {
[Dyson360ZoneIcon.Balcony]: AreaNamespaceTag.Balcony.tag,
[Dyson360ZoneIcon.Bathroom]: AreaNamespaceTag.Bathroom.tag,
[Dyson360ZoneIcon.Bedroom]: [
[/^Guest room$/i, AreaNamespaceTag.GuestBedroom.tag],
[/^/, AreaNamespaceTag.Bedroom.tag]
],
[Dyson360ZoneIcon.DiningRoom]: AreaNamespaceTag.Dining.tag,
[Dyson360ZoneIcon.Hallway]: AreaNamespaceTag.Hallway.tag,
[Dyson360ZoneIcon.Kitchen]: AreaNamespaceTag.Kitchen.tag,
[Dyson360ZoneIcon.LivingRoom]: AreaNamespaceTag.LivingRoom.tag,
[Dyson360ZoneIcon.MainBedroom]: AreaNamespaceTag.PrimaryBedroom.tag,
[Dyson360ZoneIcon.Study]: AreaNamespaceTag.Study.tag,
[Dyson360ZoneIcon.Toilet]: AreaNamespaceTag.Toilet.tag,
[Dyson360ZoneIcon.UtilityRoom]: AreaNamespaceTag.UtilityRoom.tag,
[Dyson360ZoneIcon.Work]: AreaNamespaceTag.Office.tag,
[Dyson360ZoneIcon.Custom]: null
};
// Mapping of Dyson zone cleaning status to Matter Service Area cleaning status
const PROGRESS_MAP = {
[Dyson360ZoneCleanStatus.NotRequested]: ServiceArea.OperationalStatus.Skipped,
[Dyson360ZoneCleanStatus.Unable]: ServiceArea.OperationalStatus.Skipped,
[Dyson360ZoneCleanStatus.Pending]: ServiceArea.OperationalStatus.Pending,
[Dyson360ZoneCleanStatus.InProgress]: ServiceArea.OperationalStatus.Operating,
[Dyson360ZoneCleanStatus.Complete]: ServiceArea.OperationalStatus.Completed
};
// Mixin to add zone cleaning to a Dyson robot vacuum device
export function DysonDevice360ZonesMixin(Base) {
class DysonDevice360WithZones extends Base {
// The current attribute values for the Service Area cluster
supportedMaps = [];
supportedAreas = [];
// Map of current Matter identifiers to latest Dyson maps and zones
mapFromMatter = new Map();
zoneFromMatter = new Map();
// Map of Dyson maps and zones to Matter identifiers (inc. obsolete)
mapToMatter = new Map();
zoneToMatter = new Map();
// The next map and area identifiers to allocate
nextMapId = 1;
nextAreaId = 1;
// Mixin constructor
constructor(...args) {
super(...args);
// Perform an initial fetch of the persistent maps
void (async () => {
try {
await this.updateMaps();
}
catch (err) {
logError(this.log, 'Retrieving maps', err);
}
})();
}
// Attach command handlers to the endpoint
attachCommandHandlers(endpoint) {
const handlers = super.attachCommandHandlers(endpoint);
handlers.attachSelectAreasHandler(this.makeCleaningProgramme.bind(this), (areaId) => formatAreaName(this.supportedMaps, this.supportedAreas, areaId));
return handlers;
}
// Indicates whether the device supports Service Area map features
supportsMaps = () => true;
// Update cluster attributes when the MQTT status is updated
async updateClusterAttributes(status) {
await super.updateClusterAttributes(status);
// If a map is being used then ensure the latest maps are loaded
let currentArea = null;
let progress = [];
const selectedAreas = [];
const { persistentMapId, zonesDefinitionVersion } = status;
if (persistentMapId && await this.checkMap(persistentMapId, zonesDefinitionVersion)) {
const { zoneId, zoneStatus, cleaningProgramme } = status;
// If the current zone is known then map it to a Matter area
if (zoneId)
currentArea = this.findAreaId(persistentMapId, zoneId);
// Index the zone status by Matter area identifier
const progressMap = new Map();
for (const { zoneId, cleanStatus } of zoneStatus ?? []) {
const areaId = this.findAreaId(persistentMapId, zoneId);
if (areaId)
progressMap.set(areaId, PROGRESS_MAP[cleanStatus]);
}
// If zone cleaning then map selected areas to Matter areas
const cleaningZones = new Set([
...(cleaningProgramme?.orderedZones ?? []),
...(cleaningProgramme?.unorderedZones ?? [])
]);
for (const zoneId of cleaningZones) {
const areaId = this.findAreaId(persistentMapId, zoneId);
if (areaId) {
selectedAreas.push(areaId);
const status = progressMap.get(areaId) ?? ServiceArea.OperationalStatus.Pending;
progress.push({ areaId, status });
}
}
// If not zone cleaning provide any progress status reported
if (selectedAreas.length === 0) {
progress = Array.from(progressMap.entries(), ([areaId, status]) => ({ areaId, status }));
}
}
// Update the Service Area cluster attributes
await this.endpoint?.updateServiceArea({
currentArea,
progress,
selectedAreas,
supportedAreas: this.supportedAreas,
supportedMaps: this.supportedMaps
});
}
// Attempt to convert selected areas into a Dyson cleaning programme
async makeCleaningProgramme(areaIds) {
// Ensure that the latest maps are being used
if (await this.updateMaps()) {
// New maps retrieved, so update the supported maps and areas
await this.endpoint?.updateServiceArea({
currentArea: null,
progress: [],
selectedAreas: [],
supportedAreas: this.supportedAreas,
supportedMaps: this.supportedMaps
});
}
// Map the Matter area identifiers to Dyson map and zone identifiers
const maps = new Set();
const unorderedZones = [];
for (const areaId of areaIds) {
const zone = this.zoneFromMatter.get(areaId);
if (!zone)
throw new SelectAreaError.UnsupportedArea(`${areaId} is not a supported area`);
maps.add(zone[0]);
unorderedZones.push(zone[1].id);
}
if (maps.size !== 1)
throw new SelectAreaError.InvalidSet('Areas must all be from the same map');
const [map] = maps;
assertIsDefined(map);
const { id, zonesDefinitionLastUpdatedDate } = map;
// Build the cleaning programme
return {
orderedZones: [],
persistentMapId: id,
unorderedZones,
zonesDefinitionLastUpdatedDate
};
}
// Check whether the persistent maps have changed and update if necessary
async checkMap(persistentMapId, rvcVersion) {
const getMyVersion = () => {
const map = [...this.mapFromMatter.values()].find(({ id }) => id === persistentMapId);
const myVersion = map?.zonesDefinitionLastUpdatedDate;
return myVersion === null ? '' /* sorts before all dates */ : myVersion;
};
// First check whether the matching map and version is already known
let myVersion = getMyVersion();
if (myVersion && (!rvcVersion || rvcVersion <= myVersion)) {
if (rvcVersion && rvcVersion < myVersion) {
this.log.info(`RVC map ${persistentMapId} is out of date (${rvcVersion} < ${myVersion})`);
}
return true;
}
// Retrieve the latest maps and then check again
await this.updateMaps();
myVersion = getMyVersion();
if (myVersion) {
// Tolerate but warn of version mismatches
if (rvcVersion && rvcVersion < myVersion) {
this.log.info(`RVC map ${persistentMapId} is out of date (${rvcVersion} < ${myVersion})`);
}
else if (rvcVersion && myVersion < rvcVersion) {
this.log.warn(`RVC map ${persistentMapId} is more recent than cloud (${rvcVersion} > ${myVersion})`);
}
return true;
}
if (this.api)
this.log.warn(`RVC map ${persistentMapId} does not exist`);
return false;
}
// Retrieve the latest persistent maps
async updateMaps() {
// Retrieve the latest persistent map metadata
if (!this.api)
return false;
const metadata = await this.api.getPersistentMapMetadata360();
// Check for any changes
let changed = false;
const oldMetadata = [...this.mapFromMatter.values()];
for (const { id, zonesDefinitionLastUpdatedDate } of metadata) {
const prevUpdateDate = oldMetadata.find(map => map.id === id)?.zonesDefinitionLastUpdatedDate;
if (prevUpdateDate === zonesDefinitionLastUpdatedDate)
continue;
changed = true;
this.log.info(`Map ${id} ${prevUpdateDate ? 'updated' : 'added'}`);
}
for (const { id } of oldMetadata) {
if (metadata.some(map => map.id === id))
continue;
changed = true;
this.log.info(`Map ${id} deleted`);
}
if (changed)
this.rebuildMatterMaps(metadata);
return changed;
}
// Rebuild the Matter mapping for maps and zones
rebuildMatterMaps(maps) {
// Discard any previous mappings
this.mapFromMatter.clear();
this.zoneFromMatter.clear();
this.supportedMaps = [];
this.supportedAreas = [];
// Process each map and zone to build new mappings
for (const map of maps) {
// Create a Matter map entry
const mapId = this.makeMapId(map);
const supportedMap = {
mapId,
name: map.name?.substring(0, 64) ?? ''
};
this.supportedMaps.push(supportedMap);
this.mapFromMatter.set(mapId, map);
// Create a Matter area entry for each zone
for (const zone of map.zones) {
const areaId = this.makeAreaId(map, zone);
const supportedArea = {
mapId,
areaId,
areaInfo: {
locationInfo: {
locationName: zone.name.substring(0, 128),
floorNumber: null,
areaType: this.makeLocationAreaType(zone)
},
landmarkInfo: null
}
};
this.supportedAreas.push(supportedArea);
this.zoneFromMatter.set(areaId, [map, zone]);
}
}
}
// Lookup or create a Matter map identifier for a Dyson map
makeMapId(map) {
const mapKey = map.id;
const mapId = this.mapToMatter.get(mapKey) ?? this.nextMapId++;
this.mapToMatter.set(mapKey, mapId);
return mapId;
}
// Lookup or create a Matter area identifier for a Dyson zone
makeAreaId(map, zone) {
let zoneKey = `${map.id}|${zone.name}`;
// Custom zones might not be unique, so distinguish by area too
if (zone.icon === Dyson360ZoneIcon.Custom)
zoneKey += `|${zone.area}`;
const areaId = this.zoneToMatter.get(zoneKey) ?? this.nextAreaId++;
this.zoneToMatter.set(zoneKey, areaId);
return areaId;
}
// Attempt to map Dyson map and zone identifiers to a Matter area identifier
findAreaId(persistentMapId, zoneId) {
for (const [areaId, [map, zone]] of this.zoneFromMatter.entries()) {
if ((map.id === persistentMapId) && (zone.id === zoneId)) {
return areaId;
}
}
return null;
}
// Map a Dyson zone to a Matter common area tag
makeLocationAreaType(zone) {
const mapping = LOCATION_TYPE_MAP[zone.icon];
if (!Array.isArray(mapping))
return mapping;
const match = mapping.find(([re]) => re.test(zone.name));
return match ? match[1] : null;
}
}
return DysonDevice360WithZones;
}
//# sourceMappingURL=dyson-device-360-zones.js.map