Files
mars3142_collection/plugin/actions/StreamAction.js
T
mars3142 1ad111159b Timemaster Plugin
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-04-20 14:26:44 +02:00

206 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class StreamAction extends ActionBase {
constructor($UD, context) {
super($UD, context);
this.config = { uri: '', username: '', password: '', deviceId: '' };
this.mqttClient = null;
this.connected = false;
this.lastError = null;
this.frameBuffer = new Uint8Array(128 * 64 / 8); // 1024 bytes
this.$UD.onDidReceiveSettings(jsn => {
if (jsn.context !== this.context) return;
const s = jsn.settings || {};
const prevKey = this.connectionKey();
this.config = Object.assign(this.config, s);
if (this.connectionKey() !== prevKey) {
this.connectMqtt();
}
});
}
connectionKey() {
return `${this.config.uri}|${this.config.username}|${this.config.deviceId}`;
}
buildTopic() {
const last4 = this.config.deviceId.replace(/:/g, '').slice(-4).toLowerCase();
return `device/system_control/${last4}/stream`;
}
setActive() {}
setParams(jsn) {
this.config = Object.assign(this.config, (jsn && jsn.param) || {});
this.$UD.getSettings(this.context);
this.connectMqtt();
}
onRun() {}
onClear() {
super.onClear();
this.disconnectMqtt();
}
disconnectMqtt() {
if (this.mqttClient) {
this.mqttClient.end(true);
this.mqttClient = null;
}
this.connected = false;
}
connectMqtt() {
this.disconnectMqtt();
if (!this.config.uri || !this.config.deviceId) {
this.renderCanvas();
return;
}
const log = (msg) => this.$UD.logMessage(`[Stream] ${msg}`, 'info');
if (typeof mqtt === 'undefined') {
this.lastError = 'mqtt lib missing';
this.renderCanvas();
return;
}
try {
const opts = { reconnectPeriod: 3000, rejectUnauthorized: false };
if (this.config.username) opts.username = this.config.username;
if (this.config.password) opts.password = this.config.password;
const topic = this.buildTopic();
log(`Connecting, topic: ${topic}`);
this.mqttClient = mqtt.connect(this.config.uri, opts);
this.mqttClient.on('connect', () => {
this.connected = true;
this.lastError = null;
this.mqttClient.subscribe(topic);
log(`Subscribed to ${topic}`);
this.renderCanvas();
});
this.mqttClient.on('message', (t, payload) => {
this.decodeFrame(payload);
this.renderCanvas();
});
this.mqttClient.on('reconnect', () => {
this.connected = false;
this.renderCanvas();
});
this.mqttClient.on('error', (err) => {
this.connected = false;
this.lastError = err ? err.message : 'unknown';
log(`Error: ${this.lastError}`);
this.renderCanvas();
});
this.mqttClient.on('close', () => {
this.connected = false;
this.renderCanvas();
});
this.renderCanvas();
} catch (e) {
this.lastError = e.message;
this.$UD.logMessage(`[Stream] Exception: ${e.message}`, 'error');
this.mqttClient = null;
this.renderCanvas();
}
}
// Payload format (from u8g2_mqtt.cpp):
// byte[0]: 0x01 = I-frame (full), 0x00 = P-frame (XOR diff from previous)
// byte[1..]: RLE pairs — count, value — repeated
// u8g2 buffer layout: 8 pages × 128 cols, LSB = top row of page
decodeFrame(payload) {
const BUFFER_SIZE = 1024; // 128 * 64 / 8
const isKeyframe = payload[0] === 0x01;
// RLE decode
const decoded = new Uint8Array(BUFFER_SIZE);
let pos = 0;
let i = 1;
while (i + 1 <= payload.length && pos < BUFFER_SIZE) {
const count = payload[i++];
const value = payload[i++];
for (let j = 0; j < count && pos < BUFFER_SIZE; j++) {
decoded[pos++] = value;
}
}
if (isKeyframe) {
this.frameBuffer = decoded;
} else {
// P-frame: XOR diff applied to previous frame
const newBuf = new Uint8Array(BUFFER_SIZE);
for (let k = 0; k < BUFFER_SIZE; k++) {
newBuf[k] = this.frameBuffer[k] ^ decoded[k];
}
this.frameBuffer = newBuf;
}
}
renderCanvas() {
const { canvas, ctx } = this.createCanvas();
// Black background
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, 196, 196);
if (!this.connected) {
ctx.fillStyle = this.lastError ? '#ff6b6b' : '#666666';
ctx.font = '22px "Source Han Sans SC"';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const missing = !this.config.uri || !this.config.deviceId;
ctx.fillText(
missing ? this.$UD.t('Not configured') : this.$UD.t('Connecting…'),
98, 98
);
this.setIcon(canvas);
return;
}
// Draw 128×64 image scaled 1.5× → 192×96, centered on 196×196
const SCALE = 1.5;
const IMG_W = 128, IMG_H = 64;
const offsetX = Math.round((196 - IMG_W * SCALE) / 2); // 2
const offsetY = Math.round((196 - IMG_H * SCALE) / 2); // 50
// Build ImageData at native resolution
const offscreen = document.createElement('canvas');
offscreen.width = IMG_W;
offscreen.height = IMG_H;
const offCtx = offscreen.getContext('2d');
const imgData = offCtx.createImageData(IMG_W, IMG_H);
const buf = this.frameBuffer;
for (let page = 0; page < 8; page++) {
for (let col = 0; col < 128; col++) {
const byte = buf[page * 128 + col];
for (let bit = 0; bit < 8; bit++) {
const v = (byte >> bit) & 1 ? 255 : 0;
const idx = ((page * 8 + bit) * 128 + col) * 4;
imgData.data[idx] = v;
imgData.data[idx + 1] = v;
imgData.data[idx + 2] = v;
imgData.data[idx + 3] = 255;
}
}
}
offCtx.putImageData(imgData, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(offscreen, offsetX, offsetY, IMG_W * SCALE, IMG_H * SCALE);
this.setIcon(canvas);
}
}