feat: Kasa REST API and status endpoint

This commit is contained in:
fullsizemalt 2026-01-06 00:10:04 -08:00
parent 5ef80be739
commit 3847d2cf26
3 changed files with 133 additions and 7 deletions

View file

@ -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 });
},
});
}

View file

@ -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,

View file

@ -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
};
}
}
}