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.
216 lines • 11.5 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025-2026 Alexander Thoukydides
import { DysonBitmapOctet } from './dyson-bitmap-octet.js';
import { assertIsDefined } from './utils.js';
import { DysonBitmapAnsi } from './dyson-bitmap-ansi.js';
import { inflateSync } from 'zlib';
import { Dyson360TimelineEvent } from './dyson-360-types.js';
// Maximum map size
const MAX_SIZE_CHARS = 80; // (characters)
// Character aspect ratio (for Matterbridge frontend log viewer)
const ASPECT_RATIO = 5 / 9; // (width / height)
// Dyson 360 Eye map pixels
var Dyson360EyeOctet;
(function (Dyson360EyeOctet) {
Dyson360EyeOctet[Dyson360EyeOctet["Empty"] = 0] = "Empty";
Dyson360EyeOctet[Dyson360EyeOctet["Cleaned"] = 1] = "Cleaned";
Dyson360EyeOctet[Dyson360EyeOctet["Start"] = 2] = "Start";
Dyson360EyeOctet[Dyson360EyeOctet["End"] = 3] = "End";
})(Dyson360EyeOctet || (Dyson360EyeOctet = {}));
const RGBA_360_EYE = new Map([
[0x835ED5FF, Dyson360EyeOctet.Cleaned], // Purple
[0x8763D6FF, Dyson360EyeOctet.Cleaned], // Purple (with pale grid line)
[0x8B68D7FF, Dyson360EyeOctet.Cleaned], // Purple (with grid lines crossing)
[0x455CC7FF, Dyson360EyeOctet.Start], // Blue (start location)
[0xDD4157FF, Dyson360EyeOctet.End], // Red (end location)
[0x00000000, Dyson360EyeOctet.Empty], // Transparent
[0xFFFFFF08, Dyson360EyeOctet.Empty], // Transparent (with pale grid line)
[0xFFFFFF10, Dyson360EyeOctet.Empty], // Transparent (with grid lines crossing)
[0xFFFFFFFF, Dyson360EyeOctet.Empty] // White (robot's path)
]);
// Dyson 360 Vis Nav map pixels
var Dyson360VisNavCleanedOctet;
(function (Dyson360VisNavCleanedOctet) {
Dyson360VisNavCleanedOctet[Dyson360VisNavCleanedOctet["Empty"] = 0] = "Empty";
Dyson360VisNavCleanedOctet[Dyson360VisNavCleanedOctet["Cleaned"] = 1] = "Cleaned";
Dyson360VisNavCleanedOctet[Dyson360VisNavCleanedOctet["Fault"] = 2] = "Fault";
})(Dyson360VisNavCleanedOctet || (Dyson360VisNavCleanedOctet = {}));
const RGBA_VIS_NAV_CLEANED = new Map([
[0x000000FF, Dyson360VisNavCleanedOctet.Empty], // Black
[0xFFFFFFFF, Dyson360VisNavCleanedOctet.Cleaned] // White
]);
var Dyson360VisNavPresentationOctet;
(function (Dyson360VisNavPresentationOctet) {
Dyson360VisNavPresentationOctet[Dyson360VisNavPresentationOctet["Empty"] = 0] = "Empty";
Dyson360VisNavPresentationOctet[Dyson360VisNavPresentationOctet["Zone"] = 1] = "Zone";
Dyson360VisNavPresentationOctet[Dyson360VisNavPresentationOctet["Boundary"] = 2] = "Boundary";
Dyson360VisNavPresentationOctet[Dyson360VisNavPresentationOctet["Dock"] = 3] = "Dock";
})(Dyson360VisNavPresentationOctet || (Dyson360VisNavPresentationOctet = {}));
const RGBA_VIS_NAV_PRESENTATION = new Map([
[0x000000FF, Dyson360VisNavPresentationOctet.Zone], // Black
[0xFFFFFFFF, Dyson360VisNavPresentationOctet.Boundary], // White
[0x808080FF, Dyson360VisNavPresentationOctet.Empty] // Gray
]);
const QUADRATURE_GLYPHS = {
Monospaced: ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█',
Matterbridge: ' ▀▀▀▄▌██▄█▐█▄███'
};
const GLYPHS = {
boundary: { Monospaced: '▪', Matterbridge: '╬' },
cleaned: { Monospaced: '☺', Matterbridge: '☺' }, // (quadrature block element substituted)
empty: { Monospaced: '┼', Matterbridge: '┼' },
end: { Monospaced: '●', Matterbridge: '═' },
fault: { Monospaced: '‼', Matterbridge: '▒' },
start: { Monospaced: '○', Matterbridge: '─' },
zone: { Monospaced: ' ', Matterbridge: '░' }
};
// ANSI 256-colour codes
const COLOURS = {
boundary: { fg: 15, bg: 235 }, // White on dark grey (360 Vis Nav)
cleaned: { fg: 98, bg: 16 }, // Light purple on black (360 Eye)
empty: { fg: 233, bg: 16 }, // Dark grey on black
end: { fg: 15, bg: 197 }, // White on reddish pink (360 Eye)
fault: { fg: 16, bg: 11 }, // White on light grey (360 Vis Nav)
start: { fg: 15, bg: 62 }, // White on pale blue
zone: { fg: 235, bg: 235 } // Dark grey on dark grey (360 Vis Nav)
};
// Dust level colour gradient: purple-orange-yellow-white (360 Vis Nav)
const DUST_COLOURS = [54, 89, 124, 166, 208, 214, 220, 226, 227, 228, 229, 230, 231];
// Render a Dyson 360 Eye cleaned area map
export function dysonRenderMap360Eye(_log, style, clean, cleanPNG) {
// Retrieve and parse the cleaned area image (5 mm/pixel)
const fullBitmap = DysonBitmapOctet.fromPNGMapped(cleanPNG, RGBA_360_EYE);
// Scale the image to the target log width
const renderer = new DysonBitmapAnsi([{ bitmap: fullBitmap }]);
renderer.maxWidthChars = renderer.maxHeightChars = MAX_SIZE_CHARS;
renderer.charAspectRatio = ASPECT_RATIO;
// Convert the image to text
renderer.quadratureGlyphs = QUADRATURE_GLYPHS[style];
const mapLines = renderer.toQuadrature(octet => octet !== Dyson360EyeOctet.Empty, (char, octets) => {
if (octets.includes(Dyson360EyeOctet.End))
return makeGlyph(style, 'end');
if (octets.includes(Dyson360EyeOctet.Start))
return makeGlyph(style, 'start');
if (char === ' ')
return makeGlyph(style, 'empty');
return { ...makeGlyph(style, 'cleaned'), char };
});
// Log the clean details and cleaned area map
return { charges: clean.Charges, cleanedArea: clean.Area, mapLines };
}
// Render a Dyson 360 Vis Nav cleaned area map
export function dysonRenderMap360VisNav(log, style, clean, map) {
// Check that the bitmaps are all the same resolution
const resolutions = new Set([
clean.cleanedFootprint.resolution,
clean.dustMap.resolution
]);
if (map)
resolutions.add(map.presentationMap.resolution);
if (resolutions.size !== 1)
throw new Error(`Multiple bitmap resolutions not supported (${[...resolutions].join(' ≠ ')})`);
const [mmPerPixel] = resolutions;
assertIsDefined(mmPerPixel);
// Set a single pixel
const setPixel = (bitmap, coord, octet) => {
const mmToPixels = (mm) => Math.round(mm / mmPerPixel);
const x = mmToPixels(coord.x), y = mmToPixels(coord.y);
if (0 <= x && x < bitmap.width && 0 <= y && y < bitmap.height)
bitmap.write(x, y, octet);
else
log.warn(`Coordinate outside bitmap (${coord.x}, ${coord.y} mm)`);
};
// If the clean is associated with a map then parse its presentation map
let presentationBitmap;
let presentationOrigin;
if (clean.persistentMap && map) {
// Parse the presentation map image and add any dock locations
const presentationPNG = Buffer.from(map.presentationMap.data, 'base64');
presentationBitmap = DysonBitmapOctet.fromPNGMapped(presentationPNG, RGBA_VIS_NAV_PRESENTATION);
for (const dock of map.dockLocations) {
setPixel(presentationBitmap, dock, Dyson360VisNavPresentationOctet.Dock);
}
const { cleanMapPosition } = clean.persistentMap;
const { offset } = map;
presentationOrigin = {
x: (offset.x - cleanMapPosition.x) / mmPerPixel,
y: (offset.y - cleanMapPosition.y) / mmPerPixel
};
}
else {
// No persistent map, so create an empty presentation bitmap
const emptyBuffer = Buffer.alloc(1, Dyson360VisNavPresentationOctet.Empty);
presentationBitmap = new DysonBitmapOctet(1, 1, emptyBuffer);
}
// Parse the cleaned footprint image and add any fault locations
const cleanedPNG = Buffer.from(clean.cleanedFootprint.data, 'base64');
const cleanedBitmap = DysonBitmapOctet.fromPNGMapped(cleanedPNG, RGBA_VIS_NAV_CLEANED);
for (const { faultLocation } of clean.cleanTimeline) {
if (faultLocation !== null) {
setPixel(cleanedBitmap, faultLocation, Dyson360VisNavCleanedOctet.Fault);
}
}
// Convert the dust level data into a bitmap
const { width: dustWidth, height: dustHeight } = clean.dustMap;
const dustData = clean.dustMap.dustData[0];
assertIsDefined(dustData);
const dustDataDecoded = inflateSync(Buffer.from(dustData.data, 'base64'));
const dustDataBitmap = new DysonBitmapOctet(dustWidth, dustHeight, dustDataDecoded);
// Scale all the images to target log width and orientate to match the app
const renderer = new DysonBitmapAnsi([
{ bitmap: cleanedBitmap },
{ bitmap: presentationBitmap, origin: presentationOrigin },
{ bitmap: dustDataBitmap }
]);
renderer.maxWidthChars = renderer.maxHeightChars = MAX_SIZE_CHARS;
renderer.charAspectRatio = ASPECT_RATIO;
renderer.invertY = true;
renderer.rotation = map?.zonesDefinition.persistentMapDisplayOrientation ?? 0;
// Convert the image to text
renderer.quadratureGlyphs = QUADRATURE_GLYPHS[style];
const mapLines = renderer.toQuadrature(octet => octet === Dyson360VisNavCleanedOctet.Cleaned, (char, cleaned, presentation, dustLevels) => {
// First select the representation for the presentation map
const PRESENTATION_ANSI_BG = {
[Dyson360VisNavPresentationOctet.Dock]: makeGlyph(style, 'start'),
[Dyson360VisNavPresentationOctet.Zone]: makeGlyph(style, 'zone'),
[Dyson360VisNavPresentationOctet.Boundary]: makeGlyph(style, 'boundary'),
[Dyson360VisNavPresentationOctet.Empty]: makeGlyph(style, 'empty')
};
const presentationOctet = Math.max(...presentation);
const presentationChar = PRESENTATION_ANSI_BG[presentationOctet];
// Faults take priority over everything else
if (cleaned.some(octet => octet === Dyson360VisNavCleanedOctet.Fault))
return makeGlyph(style, 'fault');
// Show the presentation map for dock locations and outside cleaned area
if (presentationOctet === Dyson360VisNavPresentationOctet.Dock || char === ' ')
return presentationChar;
// Select colour based on dust level
const dustLevel = Math.max(0, ...dustLevels) / (dustData.scaleFactor || 255);
const dustAnsiId = DUST_COLOURS[Math.floor(dustLevel * DUST_COLOURS.length)] ?? DUST_COLOURS.at(-1);
assertIsDefined(dustAnsiId);
// Always show zone boundary, but adopt the dust level colour
if (presentationOctet === Dyson360VisNavPresentationOctet.Boundary) {
return { ...presentationChar, bg: bgColour(dustAnsiId) };
}
// Otherwise show the cleaned area on the presentation map background
return { char, fg: fgColour(dustAnsiId), bg: presentationChar.bg };
});
// Count the number of charging events and cleaned area
const charges = clean.cleanTimeline.filter(e => e.eventName === Dyson360TimelineEvent.Charging).length;
const cleanedCount = cleanedBitmap.occupied(octet => octet === Dyson360VisNavCleanedOctet.Cleaned);
const cleanedArea = cleanedCount * Math.pow(mmPerPixel / 1000, 2);
// Log the clean details and cleaned area map
return { charges, cleanedArea, mapLines };
}
// Construct an ANSI colour coded glyph (using 256-colour mode IDs)
function makeGlyph(style, key) {
return {
char: GLYPHS[key][style],
fg: fgColour(COLOURS[key].fg),
bg: bgColour(COLOURS[key].bg)
};
}
// Construct ANSI colour codes (using 256-colour mode IDs)
function fgColour(id) { return `\u001B[38;5;${id}m`; }
function bgColour(id) { return `\u001B[48;5;${id}m`; }
//# sourceMappingURL=dyson-device-360-map.js.map