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.
161 lines • 7.56 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025-2026 Alexander Thoukydides
import { assertIsDefined } from './utils.js';
// Render a collection of overlaid bitmaps as text using ANSI colour codes
export class DysonBitmapAnsi {
bitmaps;
// Output size in characters
maxWidthChars = Infinity; // characters
maxHeightChars = Infinity; // characters
charAspectRatio = 1; // character width/height
// Transformation applied to the output
invertY = false;
rotation = 0;
// String applied to the end of every line to reset all attributes
eolAnsiReset = '\u001B[0m';
// Glyphs used to render quadrature block elements
quadratureGlyphs; // (16 characters)
// Construct a bitmap renderer
constructor(bitmaps) {
this.bitmaps = bitmaps;
}
// Render overlaid identically sized bitmaps as text with ANSI colour codes
toChar(filter) {
const { width, height, readPixels } = this.prepareBitmaps(1);
const filterCoord = (x, y) => filter(...readPixels(x, y));
return this.renderAnsi(width, height, filterCoord);
}
// Render overlaid bitmaps using quadrant block elements
toQuadrature(filterPixel, filterGlyph) {
// Ensure that suitable block element glyphs have been configured
const glyphs = this.quadratureGlyphs;
if (glyphs?.length !== 16)
throw new Error('Quadrature glyphs must be exactly 16 characters long');
// Convert quadrant occupancy into a block element
const makeQuadrant = ({ tl, tr, bl, br }) => {
const index = (tl ? 1 : 0) + (tr ? 2 : 0) + (bl ? 4 : 0) + (br ? 8 : 0);
const char = glyphs[index];
assertIsDefined(char);
return char;
};
// Process the bitmap with 2x2 pixels per character
const { width, height, readPixels } = this.prepareBitmaps(2);
const filterCoord = (x, y) => {
// Pixel value from each bitmap corresponding to each quadrant
const sample = (dx, dy) => {
const px = 2 * x + dx;
const py = 2 * y + dy;
if (width <= px || height <= py)
return undefined;
return readPixels(px, py);
};
const samples = { tl: sample(0, 0), tr: sample(1, 0), bl: sample(0, 1), br: sample(1, 1) };
// Select the most appropriate quadrant block element
const occupied = (s) => s ? filterPixel(...s) : false;
const occupancy = { tl: occupied(samples.tl), tr: occupied(samples.tr), bl: occupied(samples.bl), br: occupied(samples.br) };
const glyph = makeQuadrant(occupancy);
// Select the final character and colour codes
const allPixels = this.bitmaps.map((_, i) => [samples.tl, samples.tr, samples.bl, samples.br].flatMap(s => s ? [s[i]] : []));
return filterGlyph(glyph, ...allPixels);
};
return this.renderAnsi(Math.ceil(width / 2), Math.ceil(height / 2), filterCoord);
}
// Render a rectangular block of text with ANSI colour codes
renderAnsi(width, height, filter) {
const lines = [];
for (let y = 0; y < height; ++y) {
let line = '';
const prevAnsi = {};
for (let x = 0; x < width; ++x) {
const { char, fg, bg } = filter(x, y);
if (fg && fg !== prevAnsi.fg) {
prevAnsi.fg = fg;
line += fg;
}
if (bg && bg !== prevAnsi.bg) {
prevAnsi.bg = bg;
line += bg;
}
line += char;
}
lines.push(line + this.eolAnsiReset);
}
return lines;
}
// Scale, crop, and transform the bitmaps to fit the target console width
prepareBitmaps(pixelsPerChar) {
// Scaling operates in pixel units
const maxWidth = this.maxWidthChars * pixelsPerChar;
const maxHeight = this.maxHeightChars * pixelsPerChar;
// Will width and height be swapped when the output is rendered
const swapXY = this.rotation % 180 === 90;
const bitmaps = swapXY
? this.scaleBitmaps(maxHeight, maxWidth, 1 / this.charAspectRatio)
: this.scaleBitmaps(maxWidth, maxHeight, this.charAspectRatio);
const { width, height } = bitmaps[0];
const effectiveSize = swapXY ? { width: height, height: width } : { width, height };
// Make a pixel reader that applies the transformation
const readPixels = (x, y) => {
switch (this.rotation) {
case 0: break;
case 90:
[x, y] = [width - 1 - y, x];
break;
case 180:
[x, y] = [width - 1 - x, height - 1 - y];
break;
case 270:
[x, y] = [y, height - 1 - x];
break;
}
if (this.invertY)
y = height - 1 - y;
return bitmaps.map(bitmap => bitmap.read(x, y));
};
return { ...effectiveSize, readPixels };
}
// Scale and crop the bitmaps for fit specified dimensions
scaleBitmaps(maxWidth, maxHeight, extraScaleY) {
// Default filters (assume non-zero is occupied and pick highest value)
const defaultOccupancy = octet => octet !== 0;
const defaultResample = octets => octets.length ? Math.max(...octets) : 0;
// Collect the bounding boxes of each bitmap that has content
const allBounds = [];
for (const { bitmap, occupancy, origin } of this.bitmaps) {
const bounds = bitmap.boundingBox(occupancy ?? defaultOccupancy);
if (bounds)
allBounds.push({
minX: bounds.x + (origin?.x ?? 0),
minY: bounds.y + (origin?.y ?? 0),
maxX: bounds.x + (origin?.x ?? 0) + bounds.width,
maxY: bounds.y + (origin?.y ?? 0) + bounds.height
});
}
if (!allBounds.length)
throw new Error('No bitmaps have content');
// Merge the bounding boxes into a single bounding box
const srcX = Math.min(...allBounds.map(({ minX }) => minX));
const srcY = Math.min(...allBounds.map(({ minY }) => minY));
const srcWidth = Math.max(...allBounds.map(({ maxX }) => maxX)) - srcX;
const srcHeight = Math.max(...allBounds.map(({ maxY }) => maxY)) - srcY;
// Choose the output size (ensuring resolution does not increase)
const maxScaleX = Math.min(1, maxWidth / srcWidth);
const maxScaleY = Math.min(1, maxHeight / srcHeight);
const scaleX = Math.min(maxScaleX, maxScaleY / extraScaleY);
const destSize = {
width: Math.round(srcWidth * scaleX),
height: Math.round(srcHeight * scaleX * extraScaleY)
};
// Resample each of the bitmaps to a common size (in quadrant blocks, not characters)
return this.bitmaps.map(({ bitmap, origin, resample }) => {
const bitmapBounds = {
x: srcX - (origin?.x ?? 0),
y: srcY - (origin?.y ?? 0),
width: srcWidth,
height: srcHeight
};
return bitmap.resample(bitmapBounds, destSize, resample ?? defaultResample);
});
}
}
//# sourceMappingURL=dyson-bitmap-ansi.js.map