From 582c82d66f1a2efadeffc5a6d43e1328d20f9b87 Mon Sep 17 00:00:00 2001 From: Darius Date: Thu, 5 Feb 2026 22:40:50 +0100 Subject: [PATCH] implement sse --- package-lock.json | 28 +++--- src/homeassistant/service.ts | 188 ++++++++++++++++++----------------- src/homepage/routes.ts | 15 +-- src/homepage/service.ts | 119 ++++++++++++++++++++-- src/index.ts | 4 +- src/sse/routes.ts | 77 +++++++------- 6 files changed, 262 insertions(+), 169 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa70306..679daca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,8 +26,8 @@ } }, "node_modules/@dpu/shared": { - "version": "1.5.5", - "resolved": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git#921882054daa8ef862ee25fc098a295d2c7f0e04", + "version": "1.6.2", + "resolved": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git#ceadd4e5a2db3e94234a455d66338fb94fccea40", "dependencies": { "axios": "^1.7.9", "chalk": "^5.6.2", @@ -752,9 +752,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz", - "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", + "version": "24.10.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.11.tgz", + "integrity": "sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -944,9 +944,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", + "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -1325,9 +1325,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", - "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.3.tgz", + "integrity": "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg==", "dev": true, "license": "MIT", "dependencies": { @@ -1820,9 +1820,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/src/homeassistant/service.ts b/src/homeassistant/service.ts index b72f3c8..8e9cc6b 100644 --- a/src/homeassistant/service.ts +++ b/src/homeassistant/service.ts @@ -1,9 +1,9 @@ import { - API_HA_DeskPosition, - BaseService, - type HomeAssistantDeskPositionResult, - type HomeAssistantEntity, - type ServiceResult, + type API_HA_DeskPosition, + BaseService, + type HomeAssistantDeskPositionResult, + type HomeAssistantEntity, + type ServiceResult, } from "@dpu/shared"; import { logWarning } from "@dpu/shared/dist/logger.js"; import { calculateSecondsBetween } from "@dpu/shared/dist/timehelper.js"; @@ -11,105 +11,107 @@ import { Config } from "../config.js"; import type { HomeAssistantClient } from "./client.js"; export class HomeAssistantService extends BaseService { - async startStandingAutomation(): Promise> { - try { - const positionResult = await this.getDeskPosition(); + async startStandingAutomation(): Promise> { + try { + const positionResult = await this.getDeskPosition(); - if (!positionResult.successful) { - throw Error(positionResult.result as string); - } + if (!positionResult.successful) { + throw Error(positionResult.result as string); + } - const position = positionResult.result as HomeAssistantDeskPositionResult; + const position = positionResult.result as HomeAssistantDeskPositionResult; - if (position.as_boolean) { - throw Error( - `desk is already in standing position and has been for ${position.last_changed.toReadable(true)}`, - ); - } + if (position.as_boolean) { + throw Error( + `desk is already in standing position and has been for ${position.last_changed.toReadable(true)}`, + ); + } - if (position.last_changed.seconds < 300) { - throw Error("desk has moved too recently"); - } + if (position.last_changed.seconds < 300) { + throw Error("desk has moved too recently"); + } - const result = await this.getClient().triggerWebhook( - Config.homeassistant.id_webhook_stand, - ); + const result = await this.getClient().triggerWebhook( + Config.homeassistant.id_webhook_stand, + ); - return this.getSuccessfulResult(result); - } catch (error) { - const error_message = `error starting stand automation. ${error instanceof Error ? error.message : error}`; - logWarning(error_message); - return this.getErrorResult(error_message); - } - } + return this.getSuccessfulResult(result); + } catch (error) { + const error_message = `error starting stand automation. ${error instanceof Error ? error.message : error}`; + logWarning(error_message); + return this.getErrorResult(error_message); + } + } - async getDeskPosition(): Promise< - ServiceResult - > { - try { - const raw = await this.getClient().getEntityState( - Config.homeassistant.id_desk_sensor_binary, - ); + async getDeskPosition(): Promise< + ServiceResult + > { + try { + const raw = await this.getClient().getEntityState( + Config.homeassistant.id_desk_sensor_binary, + ); - const position = Number(raw.state); + const position = Number(raw.state); - const result = { - raw, - as_boolean: position === 1, - as_text: () => { - if (position === 1) return "standing"; - if (position === 0) return "sitting"; - return "unknown"; - }, - last_changed: calculateSecondsBetween( - new Date(raw.last_changed).getTime(), - Date.now(), - ), - }; + const result = { + raw, + as_boolean: position === 1, + as_text: () => { + if (position === 1) return "standing"; + if (position === 0) return "sitting"; + return "unknown"; + }, + last_changed: calculateSecondsBetween( + new Date(raw.last_changed).getTime(), + Date.now(), + ), + }; - return this.getSuccessfulResult(result); - } catch (error) { - const error_message = "error getting desk position"; - logWarning(error_message, error); - return this.getErrorResult(error_message); - } - } + return this.getSuccessfulResult(result); + } catch (error) { + const error_message = "error getting desk position"; + logWarning(error_message, error); + return this.getErrorResult(error_message); + } + } - convertPosResultToApiAnswer(position: HomeAssistantDeskPositionResult): API_HA_DeskPosition { - return { - position: position.as_text(), - is_standing: position.as_boolean, - last_changed: position.last_changed.toReadable(true), - } - } + convertPosResultToApiAnswer( + position: HomeAssistantDeskPositionResult, + ): API_HA_DeskPosition { + return { + position: position.as_text(), + is_standing: position.as_boolean, + last_changed: position.last_changed.toReadable(true), + }; + } - async getTemperatureText(): Promise> { - try { - const entities = await this.getTemperatures(); - const values = entities - .map((entity) => parseFloat(entity.state)) - .filter((value) => !Number.isNaN(value)); - const average = - values.length > 0 - ? values.reduce((sum, value) => sum + value, 0) / values.length - : 0; - const result = average.toFixed(2); - return this.getSuccessfulResult(result); - } catch (error) { - const error_message = "error getting temperature as text"; - logWarning(error_message, error); - return this.getErrorResult(error_message); - } - } + async getTemperatureText(): Promise> { + try { + const entities = await this.getTemperatures(); + const values = entities + .map((entity) => parseFloat(entity.state)) + .filter((value) => !Number.isNaN(value)); + const average = + values.length > 0 + ? values.reduce((sum, value) => sum + value, 0) / values.length + : 0; + const result = average.toFixed(2); + return this.getSuccessfulResult(result); + } catch (error) { + const error_message = "error getting temperature as text"; + logWarning(error_message, error); + return this.getErrorResult(error_message); + } + } - private async getTemperatures(): Promise { - try { - return await this.getClient().getEntityStates( - Config.homeassistant.id_room_sensors, - ); - } catch (error) { - logWarning("error getting temperatures:", error); - return []; - } - } + private async getTemperatures(): Promise { + try { + return await this.getClient().getEntityStates( + Config.homeassistant.id_room_sensors, + ); + } catch (error) { + logWarning("error getting temperatures:", error); + return []; + } + } } diff --git a/src/homepage/routes.ts b/src/homepage/routes.ts index 8de3694..634fb9d 100644 --- a/src/homepage/routes.ts +++ b/src/homepage/routes.ts @@ -1,21 +1,14 @@ -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import type { TidalService } from "../tidal/service.js"; -import { HomeAssistantService } from "../homeassistant/service.js"; -import { HomepageService } from "./service.js"; -import { API_HA_DeskPosition, TidalGetCurrent } from "@dpu/shared"; +import { type HomepageService } from "./service.js"; +import { type API_HA_DeskPosition, type TidalGetCurrent } from "@dpu/shared"; export async function homepageRoutes( fastify: FastifyInstance, { hpService, - verifyAPIKey, }: { - hpService: HomepageService - verifyAPIKey: ( - request: FastifyRequest, - reply: FastifyReply, - ) => Promise; + hpService: HomepageService; }, ) { fastify.get( diff --git a/src/homepage/service.ts b/src/homepage/service.ts index 77957b4..e78f8f1 100644 --- a/src/homepage/service.ts +++ b/src/homepage/service.ts @@ -2,6 +2,7 @@ import { BaseService, FullInformation, HomeAssistantDeskPositionResult, + SseClientChangeEvent, SseService, TidalGetCurrent, type ServiceResult, @@ -14,33 +15,135 @@ export class HomepageService extends BaseService { private haService: HomeAssistantService; private tidalService: TidalService; private sseService: SseService; + private pollingInterval: ReturnType | null = null; + private songEndTimeout: ReturnType | null = null; + private lastPoll: FullInformation | null = null; - constructor(haService: HomeAssistantService, tidalService: TidalService, sseService: SseService) { + constructor( + haService: HomeAssistantService, + tidalService: TidalService, + sseService: SseService, + ) { super(null); this.haService = haService; this.tidalService = tidalService; this.sseService = sseService; + this.listenForClientChange(); } async getFullInformation(): Promise> { try { const [desk, temp, song] = await Promise.all([ - this.haService.getDeskPosition(), - this.haService.getTemperatureText(), - this.tidalService.getSong() + this.haService.getDeskPosition(), + this.haService.getTemperatureText(), + this.tidalService.getSong(), ]); const result = { - ha_desk_position: desk.successful ? this.haService.convertPosResultToApiAnswer(desk.result as HomeAssistantDeskPositionResult) : null, + ha_desk_position: desk.successful + ? this.haService.convertPosResultToApiAnswer( + desk.result as HomeAssistantDeskPositionResult, + ) + : null, ha_temp: temp.successful ? temp.result : null, - tidal_current: song ? song.result as TidalGetCurrent : null, - } + tidal_current: song ? (song.result as TidalGetCurrent) : null, + }; - return this.getSuccessfulResult(result); + return this.getSuccessfulResult(this.updateLastPoll(result)); } catch { const error_message = "error getting all information"; logWarning(error_message); return this.getErrorResult(error_message); } } + + updateLastPoll(newPoll: FullInformation) { + const updates = []; + if ( + this.lastPoll?.ha_desk_position?.is_standing !== + newPoll.ha_desk_position?.is_standing + ) { + updates.push({ + component: "ha_desk_position", + data: newPoll.ha_desk_position, + }); + } + + if (this.lastPoll?.ha_temp !== newPoll.ha_temp) { + updates.push({ + component: "ha_temp", + data: newPoll.ha_temp, + }); + } + + if ( + this.lastPoll?.tidal_current?.title !== newPoll.tidal_current?.title || + this.lastPoll?.tidal_current?.status !== newPoll.tidal_current?.status + ) { + updates.push({ + component: "tidal_current", + data: newPoll.tidal_current, + }); + } + + this.sseService.notifyClients({ + type: "update", + data: updates, + }); + + this.lastPoll = newPoll; + this.scheduleSongEndPoll(newPoll.tidal_current); + return newPoll; + } + + private scheduleSongEndPoll(tidal: TidalGetCurrent | null): void { + this.clearSongEndPoll(); + + if (!tidal || tidal.status === "paused") { + return; + } + + const remainingSeconds = tidal.durationInSeconds - tidal.currentInSeconds; + if (remainingSeconds > 0) { + this.songEndTimeout = setTimeout( + () => { + this.songEndTimeout = null; + this.getFullInformation(); + }, + (remainingSeconds + 1) * 1000, + ); + } + } + + private clearSongEndPoll(): void { + if (this.songEndTimeout) { + clearTimeout(this.songEndTimeout); + this.songEndTimeout = null; + } + } + + listenForClientChange(): void { + this.sseService.onClientChange((clientChange: SseClientChangeEvent) => { + if (clientChange.clientCount === 0) { + this.stopPolling(); + } else { + if (!this.pollingInterval) { + this.startPolling(); + } + } + }); + } + + startPolling(): void { + this.pollingInterval = setInterval(() => { + this.getFullInformation(); + }, 30000); + } + + stopPolling(): void { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + } + this.clearSongEndPoll(); + } } diff --git a/src/index.ts b/src/index.ts index 5dfd8e0..275ce72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,8 +103,8 @@ const port = parseInt(Config.port, 10); // Register routes await fastify.register(homeAssistantRoutes, { haService, verifyAPIKey }); await fastify.register(tidalRoutes, { tidalService, verifyAPIKey }); -await fastify.register(sseRoutes, { sseService, verifyAPIKey }); -await fastify.register(homepageRoutes, { hpService, verifyAPIKey }); +await fastify.register(sseRoutes, { sseService }); +await fastify.register(homepageRoutes, { hpService }); fastify.get( "/ping", diff --git a/src/sse/routes.ts b/src/sse/routes.ts index ba8082c..e06784e 100644 --- a/src/sse/routes.ts +++ b/src/sse/routes.ts @@ -1,49 +1,44 @@ -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { logInfo, SseEvent, SseService } from "@dpu/shared"; -import { randomUUID } from "crypto"; +import type { FastifyInstance } from "fastify"; +import { logInfo, type SseEvent, type SseService } from "@dpu/shared"; +import { randomUUID } from "node:crypto"; export async function sseRoutes( - fastify: FastifyInstance, - { - sseService, - verifyAPIKey, - }: { - sseService: SseService; - verifyAPIKey: ( - request: FastifyRequest, - reply: FastifyReply, - ) => Promise; - }, + fastify: FastifyInstance, + { + sseService, + }: { + sseService: SseService; + }, ) { - fastify.get( - "/dpu/events", - { - schema: { - description: "Register for SSE", - tags: ["sse"], - hide: true - }, - sse: true - }, - async (request, reply) => { - reply.sse.keepAlive() + fastify.get( + "/dpu/events", + { + schema: { + description: "Register for SSE", + tags: ["sse"], + hide: true, + }, + sse: true, + }, + async (_request, reply) => { + reply.sse.keepAlive(); - const clientId = randomUUID(); - const sendEvent = (data: SseEvent) => { - reply.sse.send({ - data: JSON.stringify(data) - }); - }; + const clientId = randomUUID(); + const sendEvent = (data: SseEvent) => { + reply.sse.send({ + data: JSON.stringify(data), + }); + }; - sseService.addClient({ id: clientId, send: sendEvent }); + sseService.addClient({ id: clientId, send: sendEvent }); - await reply.sse.send({ data: 'Connected' }); - logInfo(`Connection for client ${clientId} established`); + await reply.sse.send({ data: "Connected" }); + logInfo(`Connection for client ${clientId} established`); - reply.sse.onClose(() => { - sseService.removeClient(clientId); - logInfo(`Connection for client ${clientId} closed`); - }) - }, - ); + reply.sse.onClose(() => { + sseService.removeClient(clientId); + logInfo(`Connection for client ${clientId} closed`); + }); + }, + ); }