diff --git a/package-lock.json b/package-lock.json index 208650f..954f71b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,8 +25,8 @@ } }, "node_modules/@dpu/shared": { - "version": "1.4.1", - "resolved": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git#b55e1dd0a651c4cc3a7aabd8a44d49eb654f0189", + "version": "1.5.3", + "resolved": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git#3dd61ab4e801744d92b68138e4fe2a1655d21877", "dependencies": { "axios": "^1.7.9", "chalk": "^5.6.2", diff --git a/src/homeassistant/routes.ts b/src/homeassistant/routes.ts index 26857a2..d768dd2 100644 --- a/src/homeassistant/routes.ts +++ b/src/homeassistant/routes.ts @@ -42,14 +42,7 @@ export async function homeAssistantRoutes( return { error: service_result.result }; } - const position_result = - service_result.result as HomeAssistantDeskPositionResult; - - return { - position: position_result.as_text(), - is_standing: position_result.as_boolean, - last_changed: position_result.last_changed.toReadable(true), - }; + return haService.convertPosResultToApiAnswer(service_result.result as HomeAssistantDeskPositionResult); }, ); diff --git a/src/homeassistant/service.ts b/src/homeassistant/service.ts index d6e022c..b72f3c8 100644 --- a/src/homeassistant/service.ts +++ b/src/homeassistant/service.ts @@ -1,4 +1,5 @@ import { + API_HA_DeskPosition, BaseService, type HomeAssistantDeskPositionResult, type HomeAssistantEntity, @@ -74,6 +75,14 @@ export class HomeAssistantService extends BaseService { } } + 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(); diff --git a/src/homepage/routes.ts b/src/homepage/routes.ts new file mode 100644 index 0000000..28465fb --- /dev/null +++ b/src/homepage/routes.ts @@ -0,0 +1,52 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { TidalService } from "../tidal/service.js"; +import { HomeAssistantService } from "../homeassistant/service.js"; +import { HomepageService } from "./service.js"; + +export async function homepageRoutes( + fastify: FastifyInstance, + { + hpService, + verifyAPIKey, + }: { + hpService: HomepageService + verifyAPIKey: ( + request: FastifyRequest, + reply: FastifyReply, + ) => Promise; + }, +) { + fastify.get( + "/homepage/status", + { + schema: { + description: "Get all current information for dpu status page", + tags: ["homepage"], + response: { + 200: z.object({ + title: z.string(), + artists: z.string(), + status: z.string(), + current: z.string(), + duration: z.string(), + url: z.string(), + }), + 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; + }, + ); +} diff --git a/src/homepage/service.ts b/src/homepage/service.ts new file mode 100644 index 0000000..ab72a03 --- /dev/null +++ b/src/homepage/service.ts @@ -0,0 +1,47 @@ +import { + BaseClient, + BaseService, + FullInformation, + HomeAssistantDeskPositionResult, + 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 { + private haService: HomeAssistantService; + private tidalService: TidalService; + private sseService: SseService; + + constructor(haService: HomeAssistantService, tidalService: TidalService, sseService: SseService) { + super(null); + this.haService = haService; + this.tidalService = tidalService; + this.sseService = sseService; + } + + async getFullInformation(): Promise> { + 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(result); + } catch { + const error_message = "error getting all information"; + logWarning(error_message); + return this.getErrorResult(error_message); + } + } +} diff --git a/src/index.ts b/src/index.ts index e0f922c..15ef162 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,10 @@ import type { FastifyReply, FastifyRequest } from "fastify"; import Fastify from "fastify"; import fastifyAxios from "fastify-axios"; import { - jsonSchemaTransform, - serializerCompiler, - validatorCompiler, - type ZodTypeProvider, + jsonSchemaTransform, + serializerCompiler, + validatorCompiler, + type ZodTypeProvider, } from "fastify-type-provider-zod"; import { SwaggerTheme, SwaggerThemeNameEnum } from "swagger-themes"; import { z } from "zod"; @@ -17,8 +17,12 @@ import { HomeAssistantClient } from "./homeassistant/client.js"; import { homeAssistantRoutes } from "./homeassistant/routes.js"; import { HomeAssistantService } from "./homeassistant/service.js"; import { TidalClient } from "./tidal/client.js"; -import { tidalRoutes } from "./tidal/routes.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"; const fastify = Fastify().withTypeProvider(); @@ -26,47 +30,47 @@ fastify.setValidatorCompiler(validatorCompiler); fastify.setSerializerCompiler(serializerCompiler); await fastify.register(fastifySwagger, { - openapi: { - info: { - title: "DPU API", - description: "API Documentation", - version: "1.0.0", - }, - servers: [ - { url: "http://localhost:8080", description: "dev" }, - { url: "https://dpu.dariusbag.dev/api", description: "prod" }, - ], - }, - transform: jsonSchemaTransform, + openapi: { + info: { + title: "DPU API", + description: "API Documentation", + version: "1.0.0", + }, + servers: [ + { url: "http://localhost:8080", description: "dev" }, + { url: "https://dpu.dariusbag.dev/api", description: "prod" }, + ], + }, + transform: jsonSchemaTransform, }); const theme = new SwaggerTheme(); const content = theme.getBuffer(SwaggerThemeNameEnum.ONE_DARK); await fastify.register(fastifySwaggerUi, { - routePrefix: "/docs", - indexPrefix: `${Config.env_dev ? "" : "/api"}`, - uiConfig: { - docExpansion: "list", - deepLinking: false, - }, - theme: { - css: [{ filename: "theme.css", content: content }], - }, + routePrefix: "/docs", + indexPrefix: `${Config.env_dev ? "" : "/api"}`, + uiConfig: { + docExpansion: "list", + deepLinking: false, + }, + theme: { + css: [{ filename: "theme.css", content: content }], + }, }); await fastify.register(fastifyAxios, { - clients: { - homeassistant: { - baseURL: Config.homeassistant.api_url, - headers: { - Authorization: `Bearer ${Config.homeassistant.api_token}`, - }, - }, - tidal: { - baseURL: `${Config.tidal.host}:${Config.tidal.port}`, - }, - }, + clients: { + homeassistant: { + baseURL: Config.homeassistant.api_url, + headers: { + Authorization: `Bearer ${Config.homeassistant.api_token}`, + }, + }, + tidal: { + baseURL: `${Config.tidal.host}:${Config.tidal.port}`, + }, + }, }); const haClient = new HomeAssistantClient(fastify.axios.homeassistant); @@ -75,16 +79,20 @@ const haService = new HomeAssistantService(haClient); const tidalClient = new TidalClient(fastify.axios.tidal); const tidalService = new TidalService(tidalClient); -async function verifyAPIKey( - request: FastifyRequest, - reply: FastifyReply, -): Promise { - const apiKey = request.headers["x-api-key"]; +const sseService = new SseService(); - if (!apiKey || apiKey !== Config.api_key) { - logInfo("POST Request with wrong API key received"); - return reply.code(401).send({ error: "Invalid API key" }); - } +const hpService = new HomepageService(haService, tidalService, sseService); + +async function verifyAPIKey( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + 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); @@ -92,28 +100,30 @@ 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 }); fastify.get( - "/ping", - { - schema: { - description: "Health check endpoint", - tags: ["default"], - response: { - 200: z.literal("pong"), - }, - }, - }, - async (_request, _reply) => { - return "pong" as const; - }, + "/ping", + { + schema: { + description: "Health check endpoint", + tags: ["default"], + response: { + 200: z.literal("pong"), + }, + }, + }, + async (_request, _reply) => { + return "pong" as const; + }, ); await fastify.ready(); fastify.listen({ port: port, host: "0.0.0.0" }, (err, address) => { - if (err) { - console.error(err); - process.exit(1); - } - console.log(`Server listening at ${address}`); + if (err) { + console.error(err); + process.exit(1); + } + console.log(`Server listening at ${address}`); }); diff --git a/src/sse/routes.ts b/src/sse/routes.ts new file mode 100644 index 0000000..e30ad15 --- /dev/null +++ b/src/sse/routes.ts @@ -0,0 +1,45 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { SseService } from "@dpu/shared"; + +export async function sseRoutes( + fastify: FastifyInstance, + { + sseService, + verifyAPIKey, + }: { + sseService: SseService; + verifyAPIKey: ( + request: FastifyRequest, + reply: FastifyReply, + ) => Promise; + }, +) { + fastify.get( + "/events", + { + schema: { + description: "Register for SSE", + tags: ["sse"], + }, + }, + async (request, reply) => { + reply.raw.setHeader("Content-Type", "text/event-stream"); + reply.raw.setHeader("Cache-Control", "no-cache"); + reply.raw.setHeader("Connection", "keep-alive"); + + const clientId = Date.now(); + + const sendEvent = (data: unknown) => { + reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + sseService.addClient({ id: clientId, send: sendEvent }); + + sendEvent({ type: "connected", message: "SSE connected" }); + + request.raw.on("close", () => { + sseService.removeClient(clientId); + }); + }, + ); +} diff --git a/src/tidal/service.ts b/src/tidal/service.ts index d577a0e..a1127d3 100644 --- a/src/tidal/service.ts +++ b/src/tidal/service.ts @@ -11,15 +11,17 @@ export class TidalService extends BaseService { const req = await this.getSong(); if (req.successful) { const song = req.result as TidalGetCurrent; - const status = song.status === "playing" ? "▶️" : "⏸️"; - return this.getSuccessfulResult( - `listening to ${song.title} by ${song.artists}. ${status} ${song.current}/${song.duration}. link: ${song.url}`, - ); + return this.getSuccessfulResult(this.formatSong(song)); } else { 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> { try { const response = await this.getClient().get("current");