initial
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
API_KEY=apiKey
|
||||
|
||||
TIDAL_HOST=http://localhost
|
||||
TIDAL_PORT=47836
|
||||
|
||||
HA_API_URL=http://homeassistant.com/api/states/
|
||||
HA_API_TOKEN=Nina hätte hier jetzt ihr Token ausversehen stehen hihi
|
||||
HA_ROOMTEMP_SENSOR_IDS=entityId(,separated)
|
||||
HA_STANDING_WEBHOOK=webhookId
|
||||
HA_DESK_SENSOR_BINARY=entityId
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Dist
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.zed/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
1516
package-lock.json
generated
Normal file
1516
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dpu/api",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node index.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Darius",
|
||||
"license": "",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git",
|
||||
"dotenv": "^17.2.2",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify-axios": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
22
src/config.ts
Normal file
22
src/config.ts
Normal 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;
|
||||
45
src/homeassistant/client.ts
Normal file
45
src/homeassistant/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/homeassistant/service.ts
Normal file
105
src/homeassistant/service.ts
Normal 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
145
src/index.ts
Normal 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
31
src/tidal/client.ts
Normal 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
83
src/tidal/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
test/api.http
Normal file
48
test/api.http
Normal file
@@ -0,0 +1,48 @@
|
||||
### Simple GET Request
|
||||
GET http://localhost:8080/ping
|
||||
|
||||
###
|
||||
|
||||
#################### HA #######################
|
||||
|
||||
### Simple GET Desk Position
|
||||
GET http://localhost:8080/homeassistant/desk/position
|
||||
|
||||
###
|
||||
|
||||
### Simple GET Stand Automation
|
||||
GET http://localhost:8080/homeassistant/desk/stand
|
||||
|
||||
###
|
||||
|
||||
### Simple POST Stand Automation
|
||||
POST http://localhost:8080/homeassistant/desk/stand
|
||||
|
||||
###
|
||||
|
||||
### Simple GET Temps
|
||||
GET http://localhost:8080/homeassistant/temperature
|
||||
|
||||
###
|
||||
|
||||
#################### TIDAL #######################
|
||||
|
||||
### Simple GET Song
|
||||
GET http://localhost:8080/tidal/song
|
||||
|
||||
###
|
||||
|
||||
### Simple GET Song Formatted
|
||||
GET http://localhost:8080/tidal/songFormatted
|
||||
|
||||
###
|
||||
|
||||
### Simple GET Volume
|
||||
GET http://localhost:8080/tidal/volume
|
||||
|
||||
###
|
||||
|
||||
### Simple SET Volume
|
||||
POST http://localhost:8080/tidal/volume
|
||||
|
||||
###
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"],
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user