Initial systant implementation in Bun/TypeScript
A lightweight system monitoring agent that: - Collects metrics via configurable shell commands - Publishes to MQTT with Home Assistant auto-discovery - Supports entity types: sensor, binary_sensor, light, switch, button - Responds to commands over MQTT for controllable entities Architecture: - src/config.ts: TOML config loading and validation - src/mqtt.ts: MQTT client with HA discovery - src/entities.ts: Entity state polling and command handling - index.ts: CLI entry point (run, check, once commands) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+130
@@ -0,0 +1,130 @@
|
||||
import { parse } from "smol-toml";
|
||||
|
||||
export interface SystantConfig {
|
||||
hostname: string;
|
||||
defaultInterval: number;
|
||||
}
|
||||
|
||||
export interface MqttConfig {
|
||||
broker: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
clientId?: string;
|
||||
topicPrefix: string;
|
||||
}
|
||||
|
||||
export type EntityType = "sensor" | "binary_sensor" | "light" | "switch" | "button";
|
||||
|
||||
export interface EntityConfig {
|
||||
type: EntityType;
|
||||
state_command?: string; // for stateful entities (not button)
|
||||
on_command?: string; // for light/switch
|
||||
off_command?: string; // for light/switch
|
||||
press_command?: string; // for button
|
||||
interval?: number; // override default interval
|
||||
unit?: string; // for sensor
|
||||
device_class?: string; // for sensor (timestamp, etc.) or binary_sensor
|
||||
icon?: string;
|
||||
name?: string;
|
||||
availability?: boolean; // default true; set false to keep last value when offline
|
||||
}
|
||||
|
||||
export interface HomeAssistantConfig {
|
||||
discovery: boolean;
|
||||
discoveryPrefix: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
systant: SystantConfig;
|
||||
mqtt: MqttConfig;
|
||||
entities: Record<string, EntityConfig>;
|
||||
homeassistant: HomeAssistantConfig;
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
systant: {
|
||||
defaultInterval: 30,
|
||||
hostname: process.env.HOSTNAME || require("os").hostname(),
|
||||
},
|
||||
mqtt: {
|
||||
broker: "mqtt://localhost:1883",
|
||||
topicPrefix: "systant",
|
||||
},
|
||||
homeassistant: {
|
||||
discovery: true,
|
||||
discoveryPrefix: "homeassistant",
|
||||
},
|
||||
};
|
||||
|
||||
export async function loadConfig(path: string): Promise<Config> {
|
||||
const file = Bun.file(path);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`Config file not found: ${path}`);
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const parsed = parse(content) as Record<string, unknown>;
|
||||
|
||||
return buildConfig(parsed);
|
||||
}
|
||||
|
||||
function buildConfig(parsed: Record<string, unknown>): Config {
|
||||
const config: Config = {
|
||||
systant: { ...defaults.systant },
|
||||
mqtt: { ...defaults.mqtt },
|
||||
entities: {},
|
||||
homeassistant: { ...defaults.homeassistant },
|
||||
};
|
||||
|
||||
// MQTT settings
|
||||
if (parsed.mqtt && typeof parsed.mqtt === "object") {
|
||||
Object.assign(config.mqtt, parsed.mqtt);
|
||||
}
|
||||
|
||||
// Systant settings
|
||||
if (parsed.systant && typeof parsed.systant === "object") {
|
||||
Object.assign(config.systant, parsed.systant);
|
||||
}
|
||||
|
||||
// Entities
|
||||
if (parsed.entities && typeof parsed.entities === "object") {
|
||||
const entities = parsed.entities as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(entities)) {
|
||||
if (key === "interval") continue; // skip the default interval
|
||||
if (value && typeof value === "object" && "type" in value) {
|
||||
const e = value as Record<string, unknown>;
|
||||
const entityType = String(e.type) as EntityType;
|
||||
|
||||
// Buttons need press_command, others need state_command
|
||||
const hasRequiredCommand = entityType === "button"
|
||||
? "press_command" in e
|
||||
: "state_command" in e;
|
||||
|
||||
if (!hasRequiredCommand) continue;
|
||||
|
||||
config.entities[key] = {
|
||||
type: entityType,
|
||||
state_command: typeof e.state_command === "string" ? e.state_command : undefined,
|
||||
on_command: typeof e.on_command === "string" ? e.on_command : undefined,
|
||||
off_command: typeof e.off_command === "string" ? e.off_command : undefined,
|
||||
press_command: typeof e.press_command === "string" ? e.press_command : undefined,
|
||||
interval: typeof e.interval === "number" ? e.interval : undefined,
|
||||
unit: typeof e.unit === "string" ? e.unit : undefined,
|
||||
device_class: typeof e.device_class === "string" ? e.device_class : undefined,
|
||||
icon: typeof e.icon === "string" ? e.icon : undefined,
|
||||
name: typeof e.name === "string" ? e.name : undefined,
|
||||
availability: typeof e.availability === "boolean" ? e.availability : true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Home Assistant
|
||||
if (parsed.homeassistant && typeof parsed.homeassistant === "object") {
|
||||
Object.assign(config.homeassistant, parsed.homeassistant);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import type { Config, EntityConfig } from "./config";
|
||||
import type { MqttConnection } from "./mqtt";
|
||||
|
||||
export interface EntityManager {
|
||||
start(): Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function createEntityManager(config: Config, mqtt: MqttConnection): EntityManager {
|
||||
const timers: Timer[] = [];
|
||||
|
||||
async function setupStatefulEntity(id: string, entity: EntityConfig): Promise<void> {
|
||||
const interval = entity.interval ?? config.systant.defaultInterval;
|
||||
const isControllable = entity.type === "light" || entity.type === "switch";
|
||||
|
||||
if (!entity.state_command) return;
|
||||
|
||||
// State polling
|
||||
const pollState = async () => {
|
||||
try {
|
||||
const output = await Bun.$`sh -c ${entity.state_command}`.text();
|
||||
const value = output.trim();
|
||||
console.debug(`[${id}] state: ${value}`);
|
||||
await mqtt.publish(`${id}/state`, value, true); // retain state
|
||||
} catch (err) {
|
||||
console.error(`[${id}] state poll failed:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial state poll
|
||||
await pollState();
|
||||
|
||||
// Schedule periodic polling
|
||||
const timer = setInterval(pollState, interval * 1000);
|
||||
timers.push(timer);
|
||||
|
||||
// Command subscription for controllable entities
|
||||
if (isControllable && (entity.on_command || entity.off_command)) {
|
||||
await mqtt.subscribe(`${id}/set`, async (_topic, payload) => {
|
||||
const command = payload.toString().toUpperCase();
|
||||
console.log(`[${id}] received command: ${command}`);
|
||||
|
||||
try {
|
||||
if (command === "ON" && entity.on_command) {
|
||||
await Bun.$`sh -c ${entity.on_command}`;
|
||||
console.log(`[${id}] executed on_command`);
|
||||
} else if (command === "OFF" && entity.off_command) {
|
||||
await Bun.$`sh -c ${entity.off_command}`;
|
||||
console.log(`[${id}] executed off_command`);
|
||||
} else {
|
||||
console.warn(`[${id}] unknown command or no handler: ${command}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-poll state after command execution
|
||||
await new Promise((r) => setTimeout(r, 500)); // brief delay for state to settle
|
||||
await pollState();
|
||||
} catch (err) {
|
||||
console.error(`[${id}] command failed:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const typeLabel = isControllable ? `${entity.type} (controllable)` : entity.type;
|
||||
console.log(` ${id}: ${typeLabel}, poll every ${interval}s`);
|
||||
}
|
||||
|
||||
async function setupButton(id: string, entity: EntityConfig): Promise<void> {
|
||||
if (!entity.press_command) return;
|
||||
|
||||
await mqtt.subscribe(`${id}/press`, async (_topic, _payload) => {
|
||||
console.log(`[${id}] button pressed`);
|
||||
|
||||
try {
|
||||
await Bun.$`sh -c ${entity.press_command}`;
|
||||
console.log(`[${id}] executed press_command`);
|
||||
} catch (err) {
|
||||
console.error(`[${id}] press_command failed:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(` ${id}: button`);
|
||||
}
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
const entityCount = Object.keys(config.entities).length;
|
||||
if (entityCount === 0) {
|
||||
console.log("No entities configured");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Starting ${entityCount} entity manager(s):`);
|
||||
for (const [id, entity] of Object.entries(config.entities)) {
|
||||
if (entity.type === "button") {
|
||||
await setupButton(id, entity);
|
||||
} else {
|
||||
await setupStatefulEntity(id, entity);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
for (const timer of timers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
timers.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import mqtt, { type MqttClient, type IClientOptions } from "mqtt";
|
||||
import type { Config, EntityConfig } from "./config";
|
||||
|
||||
export interface MqttConnection {
|
||||
client: MqttClient;
|
||||
publish(topic: string, payload: string | object, retain?: boolean): Promise<void>;
|
||||
subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function connect(config: Config, hostname: string): Promise<MqttConnection> {
|
||||
const options: IClientOptions = {
|
||||
clientId: config.mqtt.clientId || `systant-${hostname}`,
|
||||
username: config.mqtt.username,
|
||||
password: config.mqtt.password,
|
||||
will: {
|
||||
topic: `${config.mqtt.topicPrefix}/${hostname}/status`,
|
||||
payload: Buffer.from("offline"),
|
||||
qos: 1,
|
||||
retain: true,
|
||||
},
|
||||
};
|
||||
|
||||
const client = mqtt.connect(config.mqtt.broker, options);
|
||||
const handlers = new Map<string, (topic: string, payload: Buffer) => void>();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("connect", () => {
|
||||
console.log(`Connected to MQTT broker: ${config.mqtt.broker}`);
|
||||
resolve();
|
||||
});
|
||||
client.on("error", reject);
|
||||
});
|
||||
|
||||
client.on("message", (topic, payload) => {
|
||||
for (const [pattern, handler] of handlers) {
|
||||
if (topicMatches(pattern, topic)) {
|
||||
handler(topic, payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Publish online status
|
||||
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "online", true);
|
||||
|
||||
// Publish HA discovery if enabled
|
||||
if (config.homeassistant.discovery) {
|
||||
await publishDiscovery(client, config, hostname);
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
|
||||
async publish(topic: string, payload: string | object, retain = false): Promise<void> {
|
||||
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
|
||||
const data = typeof payload === "object" ? JSON.stringify(payload) : payload;
|
||||
await publishAsync(client, fullTopic, data, retain);
|
||||
},
|
||||
|
||||
async subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void> {
|
||||
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
|
||||
handlers.set(fullTopic, handler);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.subscribe(fullTopic, { qos: 1 }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
console.log(`Subscribed to: ${fullTopic}`);
|
||||
},
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "offline", true);
|
||||
await new Promise<void>((resolve) => client.end(false, {}, () => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function publishAsync(client: MqttClient, topic: string, payload: string, retain: boolean): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.publish(topic, payload, { qos: 1, retain }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function topicMatches(pattern: string, topic: string): boolean {
|
||||
if (pattern === topic) return true;
|
||||
if (pattern.endsWith("#")) {
|
||||
return topic.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function publishDiscovery(client: MqttClient, config: Config, hostname: string): Promise<void> {
|
||||
const prefix = config.homeassistant.discoveryPrefix;
|
||||
const topicPrefix = config.mqtt.topicPrefix;
|
||||
|
||||
const entityCount = Object.keys(config.entities).length;
|
||||
if (entityCount === 0) {
|
||||
console.log("No entities configured, skipping HA discovery");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [id, entity] of Object.entries(config.entities)) {
|
||||
const payload = buildDiscoveryPayload(id, entity, hostname, topicPrefix);
|
||||
const discoveryTopic = `${prefix}/${entity.type}/${hostname}_${id}/config`;
|
||||
await publishAsync(client, discoveryTopic, JSON.stringify(payload), true);
|
||||
}
|
||||
|
||||
console.log(`Published Home Assistant discovery for ${entityCount} entity/entities`);
|
||||
}
|
||||
|
||||
function buildDiscoveryPayload(
|
||||
id: string,
|
||||
entity: EntityConfig,
|
||||
hostname: string,
|
||||
topicPrefix: string
|
||||
): Record<string, unknown> {
|
||||
const displayName = entity.name || id.replace(/_/g, " ");
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: displayName,
|
||||
unique_id: `systant_${hostname}_${id}`,
|
||||
device: {
|
||||
identifiers: [`systant_${hostname}`],
|
||||
name: `${hostname}`,
|
||||
manufacturer: "Systant",
|
||||
},
|
||||
};
|
||||
|
||||
// Stateful entities have a state_topic (buttons don't)
|
||||
if (entity.type !== "button") {
|
||||
payload.state_topic = `${topicPrefix}/${hostname}/${id}/state`;
|
||||
}
|
||||
|
||||
// Add availability tracking unless explicitly disabled
|
||||
if (entity.availability !== false) {
|
||||
payload.availability_topic = `${topicPrefix}/${hostname}/status`;
|
||||
payload.payload_available = "online";
|
||||
payload.payload_not_available = "offline";
|
||||
}
|
||||
|
||||
// Common optional fields
|
||||
if (entity.icon) payload.icon = entity.icon;
|
||||
|
||||
// Type-specific fields
|
||||
switch (entity.type) {
|
||||
case "sensor":
|
||||
if (entity.unit) payload.unit_of_measurement = entity.unit;
|
||||
if (entity.device_class) payload.device_class = entity.device_class;
|
||||
break;
|
||||
|
||||
case "binary_sensor":
|
||||
payload.payload_on = "ON";
|
||||
payload.payload_off = "OFF";
|
||||
if (entity.device_class) payload.device_class = entity.device_class;
|
||||
break;
|
||||
|
||||
case "light":
|
||||
case "switch":
|
||||
payload.command_topic = `${topicPrefix}/${hostname}/${id}/set`;
|
||||
payload.payload_on = "ON";
|
||||
payload.payload_off = "OFF";
|
||||
payload.state_on = "ON";
|
||||
payload.state_off = "OFF";
|
||||
break;
|
||||
|
||||
case "button":
|
||||
payload.command_topic = `${topicPrefix}/${hostname}/${id}/press`;
|
||||
payload.payload_press = "PRESS";
|
||||
break;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
Reference in New Issue
Block a user