This commit is contained in:
Darius
2025-11-18 20:47:00 +01:00
commit 88ae2ac260
12 changed files with 2076 additions and 0 deletions

22
src/config.ts Normal file
View File

@@ -0,0 +1,22 @@
import dotenv from "dotenv";
dotenv.config();
export const Config = {
api_key: process.env.API_KEY,
tidal: {
host: process.env.TIDAL_HOST || "",
port: process.env.TIDAL_PORT || "",
},
homeassistant: {
api_url: process.env.HA_API_URL || "",
api_token: process.env.HA_API_TOKEN || "",
id_desk_sensor_binary: process.env.HA_DESK_SENSOR_BINARY || "",
id_room_sensors: process.env.HA_ROOMTEMP_SENSOR_IDS?.split(",") || [],
id_webhook_stand: process.env.HA_STANDING_WEBHOOK || "",
},
} as const;

View File

@@ -0,0 +1,45 @@
import type { HomeAssistantEntity } from "@dpu/shared";
import { printNetworkError } from "@dpu/shared/dist/utility";
import type { AxiosInstance } from "axios";
export class HomeAssistantClient {
private axiosInstance: AxiosInstance;
constructor(axiosInstance: AxiosInstance) {
this.axiosInstance = axiosInstance;
}
async getEntityStates(entityIds: string[]): Promise<HomeAssistantEntity[]> {
try {
const promises = entityIds.map((id) => this.getEntityState(id));
return await Promise.all(promises);
} catch (error) {
printNetworkError(error);
throw error;
}
}
async getEntityState(entityId: string): Promise<HomeAssistantEntity> {
try {
const response = await this.axiosInstance.get<HomeAssistantEntity>(
`states/${entityId}`,
);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
async triggerWebhook(webhookId: string): Promise<unknown> {
try {
const response = await this.axiosInstance.post<HomeAssistantEntity>(
`webhook/${webhookId}`,
);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
}

View File

@@ -0,0 +1,105 @@
import type {
HomeAssistantDeskPositionResult,
HomeAssistantEntity,
} from "@dpu/shared";
import { logWarning } from "@dpu/shared/dist/logger.js";
import { calculateSecondsBetween } from "@dpu/shared/dist/timehelper.js";
import { Config } from "../config.js";
import type { HomeAssistantClient } from "./client.js";
export class HomeAssistantService {
private client: HomeAssistantClient;
constructor(client: HomeAssistantClient) {
this.client = client;
}
async startStandingAutomation(): Promise<unknown | null> {
try {
const position = await this.getDeskPosition();
if (position === null) {
throw Error("error accessing desk api");
}
if (position.as_boolean) {
throw Error("desk is already in standing position");
}
if (position.last_changed.seconds < 120) {
throw Error("desk has moved too recently");
}
return await this.client.triggerWebhook(
Config.homeassistant.id_webhook_stand,
);
} catch (error) {
logWarning("Error starting stand automation:", error);
return null;
}
}
async getDeskPosition(): Promise<HomeAssistantDeskPositionResult | null> {
try {
const raw = await this.client.getEntityState(
Config.homeassistant.id_desk_sensor_binary,
);
const position = Number(raw.state);
return {
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(),
),
};
} catch (error) {
logWarning("Error getting desk position:", error);
return null;
}
}
async getTemperatureText(): Promise<string | null> {
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;
return average.toFixed(2);
} catch (error) {
logWarning(`Error getting temperature as text`, error);
return null;
}
}
async getTemperatures(): Promise<HomeAssistantEntity[]> {
try {
return await this.client.getEntityStates(
Config.homeassistant.id_room_sensors,
);
} catch (error) {
logWarning("Error getting temperatures:", error);
return [];
}
}
async getTemperature(entityId: string): Promise<HomeAssistantEntity | null> {
try {
return await this.client.getEntityState(entityId);
} catch (error) {
logWarning(`Error getting temperature for ${entityId}:`, error);
return null;
}
}
}

145
src/index.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { FastifyReply, FastifyRequest } from "fastify";
import fastify from "fastify";
import fastifyAxios from "fastify-axios";
import { Config } from "./config";
import { HomeAssistantClient } from "./homeassistant/client";
import { HomeAssistantService } from "./homeassistant/service";
import { TidalClient } from "./tidal/client";
import { TidalService } from "./tidal/service";
const server = fastify();
await server.register(fastifyAxios, {
clients: {
homeassistant: {
baseURL: Config.homeassistant.api_url,
headers: {
Authorization: `Bearer ${Config.homeassistant.api_token}`,
},
},
tidal: {
baseURL: `${Config.tidal.host}:${Config.tidal.port}`,
},
},
});
function verifyAPIKey(request: FastifyRequest, reply: FastifyReply): void {
const apiKey = request.headers["x-api-key"];
if (!apiKey || apiKey !== Config.api_key) {
reply.code(401).send({ error: "Invalid API key" });
}
}
const haClient = new HomeAssistantClient(server.axios.homeassistant);
const haService = new HomeAssistantService(haClient);
const tidalClient = new TidalClient(server.axios.tidal);
const tidalService = new TidalService(tidalClient);
// HOME ASSISTANT
server.get("/homeassistant/desk/position", async (_request, reply) => {
const result = await haService.getDeskPosition();
if (!result) {
reply.code(500);
return { error: "Failed to get desk position" };
}
return {
position: result.as_text(),
is_standing: result.as_boolean,
last_changed: result.last_changed.toReadable(true),
};
});
server.post(
"/homeassistant/desk/stand",
{ preHandler: verifyAPIKey },
async (_request, reply) => {
const result = await haService.startStandingAutomation();
if (!result) {
reply.code(500);
return { error: "Failed to get desk position" };
}
return { result };
},
);
server.get("/homeassistant/temperature", async (_request, reply) => {
const result = await haService.getTemperatureText();
if (!result) {
reply.code(500);
return { error: "Failed to get desk position" };
}
return {
temperature: result,
};
});
// TIDAL
server.get("/tidal/song", async (_request, reply) => {
const result = await tidalService.getSong();
if (!result) {
reply.code(500);
return { error: "Failed to get song" };
}
return { result };
});
server.get("/tidal/songFormatted", async (_request, reply) => {
const result = await tidalService.getSongFormatted();
if (!result) {
reply.code(500);
return { error: "Failed to get song" };
}
return { result };
});
server.get("/tidal/volume", async (_request, reply) => {
const result = await tidalService.getVolume();
if (!result) {
reply.code(500);
return { error: "Failed to get volume" };
}
return { result };
});
server.post(
"/tidal/volume",
{ preHandler: verifyAPIKey },
async (request, reply) => {
const volume = request.body as string;
const result = await tidalService.setVolume(volume);
if (!result) {
reply.code(500);
return { error: "Failed to set volume" };
}
return { result };
},
);
// Default
server.get("/ping", async (_request, _reply) => {
return "pong\n";
});
server.listen({ port: 8080 }, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`Server listening at ${address}`);
});

31
src/tidal/client.ts Normal file
View File

@@ -0,0 +1,31 @@
import { printNetworkError } from "@dpu/shared/dist/utility";
import type { AxiosInstance } from "axios";
export class TidalClient {
private axiosInstance: AxiosInstance;
constructor(axiosInstance: AxiosInstance) {
this.axiosInstance = axiosInstance;
}
async get<T>(endpoint: string): Promise<T> {
try {
const response = await this.axiosInstance.get<T>(`/${endpoint}`);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
async post<T>(endpoint: string, data: unknown): Promise<T> {
try {
const response = await this.axiosInstance.post<T>(`/${endpoint}`, data);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
}

83
src/tidal/service.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { TidalSong, TidalVolume } from "@dpu/shared";
import { logWarning } from "@dpu/shared/dist/logger.js";
import type { TidalClient } from "./client.js";
export class TidalService {
private client: TidalClient;
constructor(client: TidalClient) {
this.client = client;
}
async getSongFormatted(): Promise<string> {
const song = await this.getSong();
if (song) {
const status = song.status === "playing" ? "▶️" : "⏸️";
return `listening to ${song.title} by ${song.artists}. ${status} ${song.current}/${song.duration}. link: ${song.url}`;
} else {
return `no song found`;
}
}
async getSong(): Promise<TidalSong | null> {
try {
const response = this.client.get<TidalSong>("current");
return response;
} catch {
logWarning("error getting song from tidal");
return null;
}
}
async getVolume(): Promise<TidalVolume | null> {
try {
const response = await this.client.get<TidalVolume>("volume");
return response;
} catch {
logWarning("error getting volume from tidal");
return null;
}
}
clamp(value: number): number {
return Math.min(Math.max(value, 0), 100);
}
async setVolume(argument: string): Promise<TidalVolume | null> {
const value = parseInt(argument, 10);
// relative
const adjustMatch = argument.match(/^([+-])(\d+)$/);
if (adjustMatch) {
const volume = await this.getVolume();
if (volume) {
const wantedVolume = volume.volume + value;
const clampWantedVolume = this.clamp(wantedVolume);
return await this.setVolumeToTidal(clampWantedVolume);
}
}
// absolute
const setMatch = argument.match(/^(\d+)$/);
if (setMatch) {
const clampValue = this.clamp(value);
return await this.setVolumeToTidal(clampValue);
}
return null;
}
async setVolumeToTidal(volume: number): Promise<TidalVolume | null> {
try {
const response = await this.client.post<TidalVolume>("volume", {
volume,
});
return response;
} catch {
logWarning("error setting volume from tidal");
return null;
}
}
}