diff --git a/plugin/actions/StreamAction.js b/plugin/actions/StreamAction.js new file mode 100644 index 0000000..a6fd765 --- /dev/null +++ b/plugin/actions/StreamAction.js @@ -0,0 +1,205 @@ +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); + } +} diff --git a/plugin/actions/TimemasterAction.js b/plugin/actions/TimemasterAction.js new file mode 100644 index 0000000..44e8633 --- /dev/null +++ b/plugin/actions/TimemasterAction.js @@ -0,0 +1,492 @@ +class TimemasterAction extends ActionBase { + constructor($UD, context) { + super($UD, context); + this.config = { uri: '', username: '', password: '', refreshRate: '3' }; + this.ws = null; + this.tickTimer = null; + this.pingTimer = null; + this.reconnectTimer = null; + this.connected = false; + this.handshakeDone = false; + this.isError = false; + this.errorText = null; + this.noServer = false; + this.balance = null; + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = 0; + this.kommt = null; + this.geht = null; + + 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); + this.startRefresh(); + if (this.connectionKey() !== prevKey) { + this.accessToken = null; + this.refreshToken = null; + this.reconnect(); + } + }); + } + + connectionKey() { + return `${this.config.uri}|${this.config.username}`; + } + + setActive() {} + + setParams(jsn) { + this.config = Object.assign(this.config, (jsn && jsn.param) || {}); + this.$UD.getSettings(this.context); + this.startTick(); + this.startRefresh(); + this.reconnect(); + } + + onRun() { + if (!this.connected) { + this.reconnect(); + } else { + this.bookingToggle(); + } + } + + onClear() { + super.onClear(); + this.stopTick(); + this.stopRefresh(); + this.stopPing(); + this.cancelReconnect(); + this.disconnectWs(); + } + + startRefresh() { + this.stopRefresh(); + const duration = this.getRefreshDuration(this.config.refreshRate); + if (duration > 0) { + this.refreshTimer = setInterval(() => this.reconnect(), duration * 60 * 1000); + } + } + + stopRefresh() { + if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; } + } + + startTick() { + this.stopTick(); + this.tickTimer = setInterval(() => this.render(), 1000); + } + + stopTick() { + if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } + } + + startPing() { + this.stopPing(); + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN && this.handshakeDone) { + this.ws.send(JSON.stringify({ type: 6 }) + '\x1e'); + } + }, 15000); + } + + stopPing() { + if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } + } + + cancelReconnect() { + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + } + + disconnectWs() { + this.stopPing(); + this.cancelReconnect(); + if (this.ws) { + this.ws.onclose = null; + this.ws.close(); + this.ws = null; + } + this.connected = false; + this.handshakeDone = false; + } + + host() { + let u = (this.config.uri || '').trim(); + u = u.replace(/^wss?:\/\//, '').replace(/^https?:\/\//, ''); + return u.split('/')[0]; + } + + tokenUrl() { + return `https://${this.host()}/TimemasterBackend/api/authentication/token`; + } + + wsUrl() { + return `wss://${this.host()}/TimemasterBackend/tm-hub`; + } + + bookingUrl() { + return `https://${this.host()}/TimemasterBackend/api/bookings/do`; + } + + async reconnect() { + this.disconnectWs(); + if (!this.config.uri || !this.config.username) { + this.render(); + return; + } + try { + await this.ensureToken(); + this.openWs(); + } catch (e) { + this.$UD.logMessage(`[Timemaster] login error: ${e.message}`, 'error'); + this.isError = true; + this.errorText = e.message; + this.render(); + this.reconnectTimer = setTimeout(() => this.reconnect(), 15000); + } + } + + async ensureToken() { + const now = Date.now() / 1000; + if (this.accessToken && this.tokenExpiry > now + 60) return; + + // Try refresh token first if available + if (this.refreshToken) { + try { + await this.refreshAccessToken(); + return; + } catch (e) { + this.refreshToken = null; + } + } + + // Full login via XHR (avoids CORS preflight issues in Electron) + const json = await this.xhrPost(this.tokenUrl(), { + Client: 'timemaster', + ClientSecret: '*G-KaPdSgVkYp3s6v9y$B?E(H+MbQeTh', + Username: this.config.username, + Password: this.config.password, + }); + await this.applyTokenResponse(json); + } + + async refreshAccessToken() { + const json = await this.xhrPost(this.tokenUrl(), { + Client: 'timemaster', + ClientSecret: '*G-KaPdSgVkYp3s6v9y$B?E(H+MbQeTh', + RefreshToken: this.refreshToken, + }); + await this.applyTokenResponse(json); + } + + xhrPost(url, body, extraHeaders = {}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + xhr.withCredentials = true; + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Accept', 'application/json'); + for (const [k, v] of Object.entries(extraHeaders)) xhr.setRequestHeader(k, v); + xhr.onload = () => { + console.log(`[Timemaster] POST ${url} → ${xhr.status}`, xhr.responseText.slice(0, 200)); + if (xhr.status >= 200 && xhr.status < 300) { + try { resolve(JSON.parse(xhr.responseText)); } + catch (e) { reject(new Error('Invalid JSON response')); } + } else { + reject(new Error(`HTTP ${xhr.status}`)); + } + }; + xhr.onerror = () => { + console.error(`[Timemaster] POST ${url} → network error`); + this.noServer = true; + reject(new Error('Network error')); + }; + xhr.send(JSON.stringify(body)); + }); + } + + async applyTokenResponse(json) { + if (!json.access_token) throw new Error('No token in response'); + this.accessToken = json.access_token; + this.refreshToken = json.refresh_token || null; + this.tokenExpiry = Date.now() / 1000 + (json.expires_in || 1800); + this.isError = false; + this.errorText = null; + this.noServer = false; + } + + openWs() { + try { + const wsUri = `${this.wsUrl()}?access_token=${encodeURIComponent(this.accessToken)}`; + + this.ws = new WebSocket(wsUri); + + this.ws.onopen = () => { + this.isError = false; + this.errorText = null; + // SignalR JSON protocol handshake + this.ws.send(JSON.stringify({ protocol: 'json', version: 1 }) + '\x1e'); + this.render(); + }; + + this.ws.onmessage = (event) => { + const parts = event.data.split('\x1e').filter(Boolean); + for (const part of parts) { + try { + this.handleSignalRMessage(JSON.parse(part)); + } catch (e) { + this.$UD.logMessage(`[Timemaster] parse: ${e.message}`, 'error'); + } + } + this.render(); + }; + + this.ws.onerror = () => { + this.isError = true; + this.noServer = true; + this.connected = false; + this.render(); + }; + + this.ws.onclose = () => { + this.connected = false; + this.handshakeDone = false; + this.stopPing(); + this.render(); + this.reconnectTimer = setTimeout(() => this.reconnect(), 5000); + }; + + this.render(); + } catch (e) { + this.$UD.logMessage(`[Timemaster] ws open: ${e.message}`, 'error'); + this.isError = true; + this.errorText = e.message; + this.ws = null; + this.render(); + } + } + + handleSignalRMessage(msg) { + if (!this.handshakeDone) { + this.handshakeDone = true; + this.connected = true; + this.startPing(); + return; + } + + if (msg.type === 6) return; + + if (msg.type === 7) { + if (this.ws) this.ws.close(); + return; + } + + if (msg.type === 1 && Array.isArray(msg.arguments)) { + if (msg.target === 'Info.UpdateLastWorkingHours') { + console.log(`[Timemaster] ${msg.target}:`, JSON.stringify(msg.arguments).slice(0, 200)); + for (const arg of msg.arguments) { + this.parseWorkingHours(arg); + } + } else if (msg.target === 'Info.UpdateTimeAccountInformation') { + for (const arg of msg.arguments) { + if (arg && arg.Balance !== undefined) { + this.balance = this.parseBalance(arg.Balance); + } + } + } + } + } + + parseWorkingHours(data) { + if (!data || typeof data !== 'object') return; + + const day = data.CurrentWorkingDay; + if (!day || !Array.isArray(day.BookingPairs) || day.BookingPairs.length === 0) { + this.kommt = null; + this.geht = null; + this.pairs = []; + return; + } + + const pairs = day.BookingPairs; + this.pairs = pairs.map(p => ({ + start: p.First ? this.parseTime(p.First.CalculationTimestamp) : null, + end: p.Second ? this.parseTime(p.Second.CalculationTimestamp) : null, + })); + + this.kommt = this.pairs[0].start; + this.geht = this.pairs[this.pairs.length - 1].end; + } + + parseBalance(value) { + if (!value) return null; + const neg = value.startsWith('-'); + const s = neg ? value.slice(1) : value; + // Format: "HH:MM:SS" or "D.HH:MM:SS" + const m = s.match(/^(?:(\d+)\.)?(\d+):(\d+)/); + if (!m) return null; + const days = parseInt(m[1] || '0'); + const hours = parseInt(m[2]) + days * 24; + const mins = parseInt(m[3]); + const total = hours * 60 + mins; + return neg ? -total : total; + } + + formatBalance(minutes) { + const abs = Math.abs(minutes); + const h = Math.floor(abs / 60); + const m = Math.floor(abs % 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + } + + parseTime(value) { + if (!value) return null; + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; + } + + async bookingToggle() { + if (!this.accessToken) return; + // kommt set + geht not set → currently clocked in → send Geht (stop, type 2) + // otherwise → send Kommt (start, type 9) + const bookingType = (this.kommt && !this.geht) ? 2 : 9; + try { + await this.ensureToken(); + await this.xhrPost( + this.bookingUrl(), + { BookingType: bookingType, CalculationTimestamp: new Date().toISOString() }, + { Authorization: `Bearer ${this.accessToken}` } + ); + console.log(`[Timemaster] booking sent type=${bookingType}`); + } catch (e) { + console.error(`[Timemaster] booking error: ${e.message}`); + } + } + + elapsedMinutes() { + if (!this.pairs || this.pairs.length === 0) return null; + const now = new Date(); + let total = 0; + for (const p of this.pairs) { + if (!p.start) continue; + const end = p.end || now; + total += Math.max(0, end - p.start); + } + return total / 60000; + } + + formatHHMM(minutes) { + const h = Math.floor(minutes / 60); + const m = Math.floor(minutes % 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + } + + formatTime(date) { + if (!date) return '--:--'; + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + } + + render() { + const { canvas, ctx } = this.createCanvas(); + + const cx = 98, cy = 96; + const r = 68; + const lw = 14; + const MAX_MIN = 8 * 60; + + const startAngle = 135 * Math.PI / 180; + const sweep = 270 * Math.PI / 180; + const endAngle = startAngle + sweep; + + // Background track + ctx.beginPath(); + ctx.arc(cx, cy, r, startAngle, endAngle, false); + ctx.strokeStyle = '#333333'; + ctx.lineWidth = lw; + ctx.lineCap = 'round'; + ctx.stroke(); + + const minutes = this.elapsedMinutes(); + + if (this.connected && minutes !== null) { + const pct = Math.min(1, minutes / MAX_MIN); + const green6h = 6 / 8; + const yellow75h = 7.5 / 8; + const valueAngle = startAngle + pct * sweep; + const greenEnd = startAngle + green6h * sweep; + const yellowEnd = startAngle + yellow75h * sweep; + + if (pct > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, r, startAngle, Math.min(valueAngle, greenEnd), false); + ctx.strokeStyle = '#4caf50'; + ctx.lineWidth = lw; + ctx.lineCap = 'round'; + ctx.stroke(); + } + if (pct > green6h) { + ctx.beginPath(); + ctx.arc(cx, cy, r, greenEnd, Math.min(valueAngle, yellowEnd), false); + ctx.strokeStyle = '#ffeb3b'; + ctx.lineWidth = lw; + ctx.lineCap = 'butt'; + ctx.stroke(); + } + if (pct > yellow75h) { + ctx.beginPath(); + ctx.arc(cx, cy, r, yellowEnd, valueAngle, false); + ctx.strokeStyle = '#f44336'; + ctx.lineWidth = lw; + ctx.lineCap = 'butt'; + ctx.stroke(); + } + + const working = !this.geht; + const color = pct <= green6h ? '#4caf50' : pct <= yellow75h ? '#ffeb3b' : '#f44336'; + + ctx.fillStyle = working ? color : '#888888'; + ctx.font = 'bold 44px "Source Han Sans SC"'; + ctx.fillText(this.formatHHMM(minutes), cx, cy); + + // Time balance + if (this.balance !== null) { + ctx.fillStyle = this.balance >= 0 ? '#4caf50' : '#f44336'; + ctx.font = '20px "Source Han Sans SC"'; + ctx.fillText(this.formatBalance(this.balance), cx, cy + 34); + } + } else if (!this.connected) { + const missing = !this.config.uri || !this.config.username; + if (this.noServer) { + ctx.fillStyle = '#ff6b6b'; + ctx.font = 'bold 22px "Source Han Sans SC"'; + ctx.fillText('VPN?', cx, cy - 10); + ctx.fillStyle = '#888888'; + ctx.font = '16px "Source Han Sans SC"'; + ctx.fillText(this.$UD.t('No Host'), cx, cy + 16); + } else { + ctx.fillStyle = this.isError ? '#ff6b6b' : '#666666'; + ctx.font = `bold ${this.isError ? '18' : '22'}px "Source Han Sans SC"`; + ctx.fillText( + this.isError + ? (this.errorText || 'ERR').slice(0, 16) + : missing ? this.$UD.t('Not configured') : this.$UD.t('Connecting…'), + cx, cy + ); + } + } else { + ctx.fillStyle = '#666666'; + ctx.font = 'bold 22px "Source Han Sans SC"'; + ctx.fillText('Warten…', cx, cy); + } + + // Bottom label + ctx.fillStyle = '#7ec8e3'; + ctx.font = 'bold 18px "Source Han Sans SC"'; + ctx.fillText('Timemaster', cx, 174); + + this.setIcon(canvas); + } +} diff --git a/property-inspector/stream/inspector.html b/property-inspector/stream/inspector.html new file mode 100644 index 0000000..a4da72b --- /dev/null +++ b/property-inspector/stream/inspector.html @@ -0,0 +1,37 @@ + + +
+ + +