From 3847d2cf262cea116d00069a671ae5aaa71f2150 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 6 Jan 2026 00:10:04 -0800 Subject: [PATCH] feat: Kasa REST API and status endpoint --- src/health.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/index.ts | 5 +-- src/kasa.ts | 46 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/src/health.ts b/src/health.ts index 18563f5..fee95ce 100644 --- a/src/health.ts +++ b/src/health.ts @@ -1,9 +1,11 @@ /** * Health Check HTTP Server * - * Exposes /health and /metrics endpoints for monitoring. + * Exposes /health, /metrics, and /kasa endpoints for monitoring and control. */ +import { KasaController } from './kasa'; + interface HealthStatus { status: 'ok' | 'error'; lastSync: string | null; @@ -12,17 +14,36 @@ interface HealthStatus { type HealthCallback = () => HealthStatus; +// Shared Kasa controller instance +let kasaController: KasaController | null = null; + +export function setKasaController(controller: KasaController): void { + kasaController = controller; +} + export function startHealthServer(port: number, getHealth: HealthCallback): void { Bun.serve({ port, - hostname: '127.0.0.1', // Only bind to localhost for security - fetch(req) { + hostname: '0.0.0.0', // Bind to all interfaces for admin access + async fetch(req) { const url = new URL(req.url); + // CORS headers for frontend access + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }; + + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + if (url.pathname === '/health') { const health = getHealth(); return Response.json(health, { status: health.status === 'ok' ? 200 : 503, + headers: corsHeaders, }); } @@ -39,11 +60,69 @@ export function startHealthServer(port: number, getHealth: HealthCallback): void ].join('\n'); return new Response(metrics, { - headers: { 'Content-Type': 'text/plain' }, + headers: { 'Content-Type': 'text/plain', ...corsHeaders }, }); } - return new Response('Not Found', { status: 404 }); + // Kasa Status + if (url.pathname === '/kasa/status') { + if (!kasaController) { + return Response.json({ error: 'Kasa controller not initialized' }, { + status: 503, + headers: corsHeaders + }); + } + + try { + const status = await kasaController.getStatus(); + return Response.json(status, { headers: corsHeaders }); + } catch (error: any) { + return Response.json({ error: error.message, isOn: null }, { headers: corsHeaders }); + } + } + + // Kasa Control (POST) + if (url.pathname === '/kasa/power' && req.method === 'POST') { + if (!kasaController) { + return Response.json({ error: 'Kasa controller not initialized' }, { + status: 503, + headers: corsHeaders + }); + } + + try { + const body = await req.json() as { state: boolean }; + const success = await kasaController.setState(body.state); + return Response.json({ success, state: body.state }, { headers: corsHeaders }); + } catch (error: any) { + return Response.json({ error: error.message, success: false }, { + status: 500, + headers: corsHeaders + }); + } + } + + // Kasa Discover + if (url.pathname === '/kasa/discover' && req.method === 'POST') { + if (!kasaController) { + return Response.json({ error: 'Kasa controller not initialized' }, { + status: 503, + headers: corsHeaders + }); + } + + try { + const ip = await kasaController.discover(); + return Response.json({ found: !!ip, ip }, { headers: corsHeaders }); + } catch (error: any) { + return Response.json({ error: error.message, found: false }, { + status: 500, + headers: corsHeaders + }); + } + } + + return new Response('Not Found', { status: 404, headers: corsHeaders }); }, }); } diff --git a/src/index.ts b/src/index.ts index 6d5d16e..46a9706 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { SensorPushClient } from './sensorpush'; import { VeridianClient } from './veridian'; import { BufferManager } from './buffer'; -import { startHealthServer } from './health'; +import { startHealthServer, setKasaController } from './health'; import { loadConfig, type EdgeConfig } from './config'; import { AlertEngine, type Alert } from './alerts'; import { HeartbeatSender } from './heartbeat'; @@ -81,7 +81,8 @@ async function main() { await sensorPush.authenticate(); console.log('✅ SensorPush authenticated'); - // Start health server + // Start health server with Kasa controller access + setKasaController(kasa); startHealthServer(3030, () => ({ status: 'ok', lastSync: lastSync?.toISOString() ?? null, diff --git a/src/kasa.ts b/src/kasa.ts index 0abe888..47bd23c 100644 --- a/src/kasa.ts +++ b/src/kasa.ts @@ -78,4 +78,50 @@ export class KasaController { return false; } } + + /** + * Get current status of the plug + */ + async getStatus(): Promise<{ + isOn: boolean | null; + alias: string; + ip: string | null; + model: string; + connected: boolean; + }> { + if (!this.deviceIp) { + await this.discover(); + } + + if (!this.deviceIp) { + return { + isOn: null, + alias: 'Unknown', + ip: null, + model: 'Unknown', + connected: false + }; + } + + try { + const device = await this.client.getDevice({ host: this.deviceIp }); + const sysInfo = await device.getSysInfo(); + return { + isOn: sysInfo.relay_state === 1, + alias: sysInfo.alias || 'Kasa Plug', + ip: this.deviceIp, + model: sysInfo.model || 'Unknown', + connected: true + }; + } catch (e) { + console.error('🔌 Kasa: Failed to get status', e); + return { + isOn: null, + alias: 'Error', + ip: this.deviceIp, + model: 'Unknown', + connected: false + }; + } + } }