UNPKG

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
// 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