Compare commits

..

9 Commits

Author SHA1 Message Date
Darius
07719bbc1f add seconds to standing/sitting time 2026-02-05 22:53:56 +01:00
Darius
7b6a056141 dont send empty updates 2026-02-05 22:45:14 +01:00
Darius
582c82d66f implement sse 2026-02-05 22:40:50 +01:00
Darius
6e1c5e4e67 change route 2026-02-05 20:05:06 +01:00
Darius
0043b75c95 add better sse handling 2026-02-05 04:34:14 +01:00
Darius
4db61998bd hide event register from schema 2026-02-05 01:10:30 +01:00
Darius
7fd3e4b9d3 fix schema and add nullable 2026-02-05 01:06:51 +01:00
Darius
6b5b7a037d update deps 2026-02-05 01:00:38 +01:00
Darius
fd7a80f525 add all needed routes and stuff 2026-02-05 00:53:23 +01:00
9 changed files with 540 additions and 262 deletions

44
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git", "@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git",
"@fastify/sse": "^0.4.0",
"@fastify/swagger": "^9.6.1", "@fastify/swagger": "^9.6.1",
"@fastify/swagger-ui": "^5.2.3", "@fastify/swagger-ui": "^5.2.3",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
@@ -25,8 +26,8 @@
} }
}, },
"node_modules/@dpu/shared": { "node_modules/@dpu/shared": {
"version": "1.4.1", "version": "1.6.4",
"resolved": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git#b55e1dd0a651c4cc3a7aabd8a44d49eb654f0189", "resolved": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git#60bcd23f5b77034777a792bbe8f33a62e92ba246",
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
"chalk": "^5.6.2", "chalk": "^5.6.2",
@@ -628,6 +629,21 @@
"mime": "^3" "mime": "^3"
} }
}, },
"node_modules/@fastify/sse": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@fastify/sse/-/sse-0.4.0.tgz",
"integrity": "sha512-bBV96iT2kHEw6h3i8IMkZGaqA7Gk81ugUzTNctXuE6N2BEC/qBnUuzlD/O17V43OkJP73h0/kf3Bp/asXlSuFA==",
"license": "MIT",
"dependencies": {
"fastify-plugin": "^5.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"fastify": "^5.x"
}
},
"node_modules/@fastify/static": { "node_modules/@fastify/static": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
@@ -736,9 +752,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.10", "version": "24.10.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.11.tgz",
"integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", "integrity": "sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -928,9 +944,9 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.2.3", "version": "17.2.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -1309,9 +1325,9 @@
} }
}, },
"node_modules/get-tsconfig": { "node_modules/get-tsconfig": {
"version": "4.13.1", "version": "4.13.3",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.3.tgz",
"integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", "integrity": "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1804,9 +1820,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"

View File

@@ -14,6 +14,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git", "@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git",
"@fastify/sse": "^0.4.0",
"@fastify/swagger": "^9.6.1", "@fastify/swagger": "^9.6.1",
"@fastify/swagger-ui": "^5.2.3", "@fastify/swagger-ui": "^5.2.3",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",

View File

@@ -4,108 +4,104 @@ import { z } from "zod";
import type { HomeAssistantService } from "../homeassistant/service.js"; import type { HomeAssistantService } from "../homeassistant/service.js";
export async function homeAssistantRoutes( export async function homeAssistantRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
{ {
haService, haService,
verifyAPIKey, verifyAPIKey,
}: { }: {
haService: HomeAssistantService; haService: HomeAssistantService;
verifyAPIKey: ( verifyAPIKey: (
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply,
) => Promise<void>; ) => Promise<void>;
}, },
) { ) {
fastify.get( fastify.get(
"/homeassistant/desk/position", "/homeassistant/desk/position",
{ {
schema: { schema: {
description: "Get current desk position", description: "Get current desk position",
tags: ["homeassistant"], tags: ["homeassistant"],
response: { response: {
200: z.object({ 200: z.object({
position: z.string(), position: z.string(),
is_standing: z.boolean(), is_standing: z.boolean(),
last_changed: z.string(), last_changed: z.string(),
}), last_changed_seconds: z.number(),
418: z.object({ }),
error: z.string(), 418: z.object({
}), error: z.string(),
}, }),
}, },
}, },
async (_request, reply) => { },
const service_result = await haService.getDeskPosition(); async (_request, reply) => {
const service_result = await haService.getDeskPosition();
if (!service_result.successful) { if (!service_result.successful) {
reply.code(418); reply.code(418);
return { error: service_result.result }; return { error: service_result.result };
} }
const position_result = return haService.convertPosResultToApiAnswer(
service_result.result as HomeAssistantDeskPositionResult; service_result.result as HomeAssistantDeskPositionResult,
);
},
);
return { fastify.post(
position: position_result.as_text(), "/homeassistant/desk/stand",
is_standing: position_result.as_boolean, {
last_changed: position_result.last_changed.toReadable(true), preHandler: verifyAPIKey,
}; schema: {
}, description: "Trigger standing desk automation",
); tags: ["homeassistant"],
response: {
200: z.unknown(),
401: z.object({
error: z.literal("Invalid API key"),
}),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.startStandingAutomation();
fastify.post( if (!service_result.successful) {
"/homeassistant/desk/stand", reply.code(418);
{ return { error: service_result.result };
preHandler: verifyAPIKey, }
schema: {
description: "Trigger standing desk automation",
tags: ["homeassistant"],
response: {
200: z.unknown(),
401: z.object({
error: z.literal("Invalid API key"),
}),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.startStandingAutomation();
if (!service_result.successful) { return service_result.result;
reply.code(418); },
return { error: service_result.result }; );
}
return service_result.result; fastify.get(
}, "/homeassistant/temperature",
); {
schema: {
description: "Get current room temperature",
tags: ["homeassistant"],
response: {
200: z.string(),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.getTemperatureText();
fastify.get( if (!service_result.successful) {
"/homeassistant/temperature", reply.code(418);
{ return { error: service_result.result };
schema: { }
description: "Get current room temperature",
tags: ["homeassistant"],
response: {
200: z.string(),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.getTemperatureText();
if (!service_result.successful) { return service_result.result;
reply.code(418); },
return { error: service_result.result }; );
}
return service_result.result;
},
);
} }

View File

@@ -1,8 +1,9 @@
import { import {
BaseService, type API_HA_DeskPosition,
type HomeAssistantDeskPositionResult, BaseService,
type HomeAssistantEntity, type HomeAssistantDeskPositionResult,
type ServiceResult, type HomeAssistantEntity,
type ServiceResult,
} from "@dpu/shared"; } from "@dpu/shared";
import { logWarning } from "@dpu/shared/dist/logger.js"; import { logWarning } from "@dpu/shared/dist/logger.js";
import { calculateSecondsBetween } from "@dpu/shared/dist/timehelper.js"; import { calculateSecondsBetween } from "@dpu/shared/dist/timehelper.js";
@@ -10,97 +11,108 @@ import { Config } from "../config.js";
import type { HomeAssistantClient } from "./client.js"; import type { HomeAssistantClient } from "./client.js";
export class HomeAssistantService extends BaseService<HomeAssistantClient> { export class HomeAssistantService extends BaseService<HomeAssistantClient> {
async startStandingAutomation(): Promise<ServiceResult<unknown | string>> { async startStandingAutomation(): Promise<ServiceResult<unknown | string>> {
try { try {
const positionResult = await this.getDeskPosition(); const positionResult = await this.getDeskPosition();
if (!positionResult.successful) { if (!positionResult.successful) {
throw Error(positionResult.result as string); throw Error(positionResult.result as string);
} }
const position = positionResult.result as HomeAssistantDeskPositionResult; const position = positionResult.result as HomeAssistantDeskPositionResult;
if (position.as_boolean) { if (position.as_boolean) {
throw Error( throw Error(
`desk is already in standing position and has been for ${position.last_changed.toReadable(true)}`, `desk is already in standing position and has been for ${position.last_changed.toReadable(true)}`,
); );
} }
if (position.last_changed.seconds < 300) { if (position.last_changed.seconds < 300) {
throw Error("desk has moved too recently"); throw Error("desk has moved too recently");
} }
const result = await this.getClient().triggerWebhook( const result = await this.getClient().triggerWebhook(
Config.homeassistant.id_webhook_stand, Config.homeassistant.id_webhook_stand,
); );
return this.getSuccessfulResult(result); return this.getSuccessfulResult(result);
} catch (error) { } catch (error) {
const error_message = `error starting stand automation. ${error instanceof Error ? error.message : error}`; const error_message = `error starting stand automation. ${error instanceof Error ? error.message : error}`;
logWarning(error_message); logWarning(error_message);
return this.getErrorResult(error_message); return this.getErrorResult(error_message);
} }
} }
async getDeskPosition(): Promise< async getDeskPosition(): Promise<
ServiceResult<HomeAssistantDeskPositionResult | string> ServiceResult<HomeAssistantDeskPositionResult | string>
> { > {
try { try {
const raw = await this.getClient().getEntityState( const raw = await this.getClient().getEntityState(
Config.homeassistant.id_desk_sensor_binary, Config.homeassistant.id_desk_sensor_binary,
); );
const position = Number(raw.state); const position = Number(raw.state);
const result = { const result = {
raw, raw,
as_boolean: position === 1, as_boolean: position === 1,
as_text: () => { as_text: () => {
if (position === 1) return "standing"; if (position === 1) return "standing";
if (position === 0) return "sitting"; if (position === 0) return "sitting";
return "unknown"; return "unknown";
}, },
last_changed: calculateSecondsBetween( last_changed: calculateSecondsBetween(
new Date(raw.last_changed).getTime(), new Date(raw.last_changed).getTime(),
Date.now(), Date.now(),
), ),
}; };
return this.getSuccessfulResult(result); return this.getSuccessfulResult(result);
} catch (error) { } catch (error) {
const error_message = "error getting desk position"; const error_message = "error getting desk position";
logWarning(error_message, error); logWarning(error_message, error);
return this.getErrorResult(error_message); return this.getErrorResult(error_message);
} }
} }
async getTemperatureText(): Promise<ServiceResult<string>> { convertPosResultToApiAnswer(
try { position: HomeAssistantDeskPositionResult,
const entities = await this.getTemperatures(); ): API_HA_DeskPosition {
const values = entities return {
.map((entity) => parseFloat(entity.state)) position: position.as_text(),
.filter((value) => !Number.isNaN(value)); is_standing: position.as_boolean,
const average = last_changed: position.last_changed.toReadable(true),
values.length > 0 last_changed_seconds: position.last_changed.seconds,
? 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<HomeAssistantEntity[]> { async getTemperatureText(): Promise<ServiceResult<string>> {
try { try {
return await this.getClient().getEntityStates( const entities = await this.getTemperatures();
Config.homeassistant.id_room_sensors, const values = entities
); .map((entity) => parseFloat(entity.state))
} catch (error) { .filter((value) => !Number.isNaN(value));
logWarning("error getting temperatures:", error); const average =
return []; 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<HomeAssistantEntity[]> {
try {
return await this.getClient().getEntityStates(
Config.homeassistant.id_room_sensors,
);
} catch (error) {
logWarning("error getting temperatures:", error);
return [];
}
}
} }

43
src/homepage/routes.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { type HomepageService } from "./service.js";
import { type API_HA_DeskPosition, type TidalGetCurrent } from "@dpu/shared";
export async function homepageRoutes(
fastify: FastifyInstance,
{
hpService,
}: {
hpService: HomepageService;
},
) {
fastify.get(
"/homepage/status",
{
schema: {
description: "Get all current information for dpu status page",
tags: ["homepage"],
response: {
200: z.object({
ha_desk_position: z.custom<API_HA_DeskPosition>().nullable(),
ha_temp: z.string().nullable(),
tidal_current: z.custom<TidalGetCurrent>().nullable(),
}),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await hpService.getFullInformation();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
}

151
src/homepage/service.ts Normal file
View File

@@ -0,0 +1,151 @@
import {
BaseService,
FullInformation,
HomeAssistantDeskPositionResult,
SseClientChangeEvent,
SseService,
TidalGetCurrent,
type ServiceResult,
} from "@dpu/shared";
import { logWarning } from "@dpu/shared/dist/logger.js";
import { HomeAssistantService } from "../homeassistant/service";
import { TidalService } from "../tidal/service";
export class HomepageService extends BaseService<null> {
private haService: HomeAssistantService;
private tidalService: TidalService;
private sseService: SseService;
private pollingInterval: ReturnType<typeof setInterval> | null = null;
private songEndTimeout: ReturnType<typeof setTimeout> | null = null;
private lastPoll: FullInformation | null = null;
constructor(
haService: HomeAssistantService,
tidalService: TidalService,
sseService: SseService,
) {
super(null);
this.haService = haService;
this.tidalService = tidalService;
this.sseService = sseService;
this.listenForClientChange();
}
async getFullInformation(): Promise<ServiceResult<FullInformation | string>> {
try {
const [desk, temp, song] = await Promise.all([
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_temp: temp.successful ? temp.result : null,
tidal_current: song ? (song.result as TidalGetCurrent) : null,
};
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,
});
}
if (updates.length > 0) {
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();
}
}

View File

@@ -5,10 +5,10 @@ import type { FastifyReply, FastifyRequest } from "fastify";
import Fastify from "fastify"; import Fastify from "fastify";
import fastifyAxios from "fastify-axios"; import fastifyAxios from "fastify-axios";
import { import {
jsonSchemaTransform, jsonSchemaTransform,
serializerCompiler, serializerCompiler,
validatorCompiler, validatorCompiler,
type ZodTypeProvider, type ZodTypeProvider,
} from "fastify-type-provider-zod"; } from "fastify-type-provider-zod";
import { SwaggerTheme, SwaggerThemeNameEnum } from "swagger-themes"; import { SwaggerTheme, SwaggerThemeNameEnum } from "swagger-themes";
import { z } from "zod"; import { z } from "zod";
@@ -17,8 +17,13 @@ import { HomeAssistantClient } from "./homeassistant/client.js";
import { homeAssistantRoutes } from "./homeassistant/routes.js"; import { homeAssistantRoutes } from "./homeassistant/routes.js";
import { HomeAssistantService } from "./homeassistant/service.js"; import { HomeAssistantService } from "./homeassistant/service.js";
import { TidalClient } from "./tidal/client.js"; import { TidalClient } from "./tidal/client.js";
import { tidalRoutes } from "./tidal/routes.js";
import { TidalService } from "./tidal/service.js"; import { TidalService } from "./tidal/service.js";
import { tidalRoutes } from "./tidal/routes.js";
import { sseRoutes } from "./sse/routes.js";
import { SseService } from "@dpu/shared";
import { HomepageService } from "./homepage/service.js";
import { homepageRoutes } from "./homepage/routes.js";
import fastifySSE from "@fastify/sse";
const fastify = Fastify().withTypeProvider<ZodTypeProvider>(); const fastify = Fastify().withTypeProvider<ZodTypeProvider>();
@@ -26,65 +31,71 @@ fastify.setValidatorCompiler(validatorCompiler);
fastify.setSerializerCompiler(serializerCompiler); fastify.setSerializerCompiler(serializerCompiler);
await fastify.register(fastifySwagger, { await fastify.register(fastifySwagger, {
openapi: { openapi: {
info: { info: {
title: "DPU API", title: "DPU API",
description: "API Documentation", description: "API Documentation",
version: "1.0.0", version: "1.0.0",
}, },
servers: [ servers: [
{ url: "http://localhost:8080", description: "dev" }, { url: "http://localhost:8080", description: "dev" },
{ url: "https://dpu.dariusbag.dev/api", description: "prod" }, { url: "https://dpu.dariusbag.dev/api", description: "prod" },
], ],
}, },
transform: jsonSchemaTransform, transform: jsonSchemaTransform,
}); });
const theme = new SwaggerTheme(); const theme = new SwaggerTheme();
const content = theme.getBuffer(SwaggerThemeNameEnum.ONE_DARK); const content = theme.getBuffer(SwaggerThemeNameEnum.ONE_DARK);
await fastify.register(fastifySwaggerUi, { await fastify.register(fastifySwaggerUi, {
routePrefix: "/docs", routePrefix: "/docs",
indexPrefix: `${Config.env_dev ? "" : "/api"}`, indexPrefix: `${Config.env_dev ? "" : "/api"}`,
uiConfig: { uiConfig: {
docExpansion: "list", docExpansion: "list",
deepLinking: false, deepLinking: false,
}, },
theme: { theme: {
css: [{ filename: "theme.css", content: content }], css: [{ filename: "theme.css", content: content }],
}, },
}); });
await fastify.register(fastifyAxios, { await fastify.register(fastifyAxios, {
clients: { clients: {
homeassistant: { homeassistant: {
baseURL: Config.homeassistant.api_url, baseURL: Config.homeassistant.api_url,
headers: { headers: {
Authorization: `Bearer ${Config.homeassistant.api_token}`, Authorization: `Bearer ${Config.homeassistant.api_token}`,
}, },
}, },
tidal: { tidal: {
baseURL: `${Config.tidal.host}:${Config.tidal.port}`, baseURL: `${Config.tidal.host}:${Config.tidal.port}`,
}, },
}, },
}); });
await fastify.register(fastifySSE);
const haClient = new HomeAssistantClient(fastify.axios.homeassistant); const haClient = new HomeAssistantClient(fastify.axios.homeassistant);
const haService = new HomeAssistantService(haClient); const haService = new HomeAssistantService(haClient);
const tidalClient = new TidalClient(fastify.axios.tidal); const tidalClient = new TidalClient(fastify.axios.tidal);
const tidalService = new TidalService(tidalClient); const tidalService = new TidalService(tidalClient);
async function verifyAPIKey( const sseService = new SseService();
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const apiKey = request.headers["x-api-key"];
if (!apiKey || apiKey !== Config.api_key) { const hpService = new HomepageService(haService, tidalService, sseService);
logInfo("POST Request with wrong API key received");
return reply.code(401).send({ error: "Invalid API key" }); async function verifyAPIKey(
} request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const apiKey = request.headers["x-api-key"];
if (!apiKey || apiKey !== Config.api_key) {
logInfo("POST Request with wrong API key received");
return reply.code(401).send({ error: "Invalid API key" });
}
} }
const port = parseInt(Config.port, 10); const port = parseInt(Config.port, 10);
@@ -92,28 +103,30 @@ const port = parseInt(Config.port, 10);
// Register routes // Register routes
await fastify.register(homeAssistantRoutes, { haService, verifyAPIKey }); await fastify.register(homeAssistantRoutes, { haService, verifyAPIKey });
await fastify.register(tidalRoutes, { tidalService, verifyAPIKey }); await fastify.register(tidalRoutes, { tidalService, verifyAPIKey });
await fastify.register(sseRoutes, { sseService });
await fastify.register(homepageRoutes, { hpService });
fastify.get( fastify.get(
"/ping", "/ping",
{ {
schema: { schema: {
description: "Health check endpoint", description: "Health check endpoint",
tags: ["default"], tags: ["default"],
response: { response: {
200: z.literal("pong"), 200: z.literal("pong"),
}, },
}, },
}, },
async (_request, _reply) => { async (_request, _reply) => {
return "pong" as const; return "pong" as const;
}, },
); );
await fastify.ready(); await fastify.ready();
fastify.listen({ port: port, host: "0.0.0.0" }, (err, address) => { fastify.listen({ port: port, host: "0.0.0.0" }, (err, address) => {
if (err) { if (err) {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
} }
console.log(`Server listening at ${address}`); console.log(`Server listening at ${address}`);
}); });

44
src/sse/routes.ts Normal file
View File

@@ -0,0 +1,44 @@
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,
}: {
sseService: SseService;
},
) {
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),
});
};
sseService.addClient({ id: clientId, send: sendEvent });
await reply.sse.send({ data: "Connected" });
logInfo(`Connection for client ${clientId} established`);
reply.sse.onClose(() => {
sseService.removeClient(clientId);
logInfo(`Connection for client ${clientId} closed`);
});
},
);
}

View File

@@ -11,15 +11,17 @@ export class TidalService extends BaseService<TidalClient> {
const req = await this.getSong(); const req = await this.getSong();
if (req.successful) { if (req.successful) {
const song = req.result as TidalGetCurrent; const song = req.result as TidalGetCurrent;
const status = song.status === "playing" ? "▶️" : "⏸️"; return this.getSuccessfulResult(this.formatSong(song));
return this.getSuccessfulResult(
`listening to ${song.title} by ${song.artists}. ${status} ${song.current}/${song.duration}. link: ${song.url}`,
);
} else { } else {
return this.getErrorResult(req.result as string); return this.getErrorResult(req.result as string);
} }
} }
formatSong(song: TidalGetCurrent): string {
const status = song.status === "playing" ? "▶️" : "⏸️";
return `listening to ${song.title} by ${song.artists}. ${status} ${song.current}/${song.duration}. link: ${song.url}`;
}
async getSong(): Promise<ServiceResult<TidalGetCurrent | string>> { async getSong(): Promise<ServiceResult<TidalGetCurrent | string>> {
try { try {
const response = await this.getClient().get<TidalGetCurrent>("current"); const response = await this.getClient().get<TidalGetCurrent>("current");