Compare commits
9 Commits
bd87929593
...
07719bbc1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07719bbc1f | ||
|
|
7b6a056141 | ||
|
|
582c82d66f | ||
|
|
6e1c5e4e67 | ||
|
|
0043b75c95 | ||
|
|
4db61998bd | ||
|
|
7fd3e4b9d3 | ||
|
|
6b5b7a037d | ||
|
|
fd7a80f525 |
44
package-lock.json
generated
44
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
43
src/homepage/routes.ts
Normal 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
151
src/homepage/service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/index.ts
141
src/index.ts
@@ -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
44
src/sse/routes.ts
Normal 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`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user