feat: add ioBroker REST API integration

- New iobroker_sources + iobroker_states tables
- Source manager with discover, test, CRUD
- State poller (10s interval, getBulk endpoint)
- REST API routes under /api/iobroker
- Frontend page with search + pagination
- Sidebar link, app route

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 05:13:23 +02:00
co-authored by Claude Opus 4.6
parent e6b366862c
commit e76082df92
14 changed files with 863 additions and 0 deletions
+2
View File
@@ -6,6 +6,7 @@ import displayApiRouter from './routes/display-api';
import mqttBrokersRouter from './routes/mqtt-brokers';
import mqttTopicsRouter from './routes/mqtt-topics';
import pgSourcesRouter from './routes/pg-sources';
import iobrokerRouter from './routes/iobroker';
import datapointsRouter from './routes/datapoints';
import displaysRouter from './routes/displays';
import layoutsRouter from './routes/layouts';
@@ -30,6 +31,7 @@ export function createApp(): express.Application {
app.use('/api/mqtt/brokers/:brokerId/topics', authMiddleware, mqttTopicsRouter);
app.use('/api/mqtt/brokers', authMiddleware, mqttBrokersRouter);
app.use('/api/pg/sources', authMiddleware, pgSourcesRouter);
app.use('/api/iobroker', authMiddleware, iobrokerRouter);
app.use('/api/datapoints', authMiddleware, datapointsRouter);
app.use('/api/displays', authMiddleware, displaysRouter);
app.use('/api/layouts', authMiddleware, layoutsRouter);
@@ -0,0 +1,132 @@
import { Router } from 'express';
import {
getAllSources,
getSourceById,
createSource,
updateSource,
deleteSource,
testSource,
discoverStates,
getStatesForSource,
updateState,
} from '../../modules/iobroker/source-manager';
import {
createDatapointFromIoBroker,
getDatapointByIoBrokerStateId,
} from '../../modules/datapoints/datapoint-manager';
import { startPolling } from '../../modules/iobroker/state-poller';
import { getDb } from '../../db/index';
const router = Router();
// GET /api/iobroker/sources
router.get('/sources', (_req, res): void => {
try {
res.json(getAllSources());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/iobroker/sources
router.post('/sources', (req, res): void => {
try {
const { name, host } = req.body;
if (!name || !host) {
res.status(400).json({ error: 'name and host are required' });
return;
}
res.status(201).json(createSource(req.body));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/iobroker/sources/:id
router.put('/sources/:id', (req, res): void => {
try {
const source = updateSource(parseInt(req.params.id), req.body);
if (!source) { res.status(404).json({ error: 'Source not found' }); return; }
res.json(source);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/iobroker/sources/:id
router.delete('/sources/:id', (req, res): void => {
try {
const deleted = deleteSource(parseInt(req.params.id));
if (!deleted) { res.status(404).json({ error: 'Source not found' }); return; }
res.status(204).send();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/iobroker/sources/:id/test
router.post('/sources/:id/test', (req, res): void => {
const id = parseInt(req.params.id);
const source = getSourceById(id);
if (!source) { res.status(404).json({ error: 'Source not found' }); return; }
testSource(source.host, source.port)
.then((ok) => res.json({ success: ok }))
.catch((err) => res.status(500).json({ error: (err as Error).message }));
});
// POST /api/iobroker/sources/:id/discover
router.post('/sources/:id/discover', (req, res): void => {
const id = parseInt(req.params.id);
res.json({ message: 'Discovery started', sourceId: id });
discoverStates(id).then((count) => {
console.log(`ioBroker discovery for source ${id}: found ${count} states`);
}).catch((err) => {
console.error(`ioBroker discovery error for source ${id}:`, (err as Error).message);
});
});
// GET /api/iobroker/sources/:id/states?search=&selected=1&limit=50&offset=0
router.get('/sources/:id/states', (req, res): void => {
try {
const sourceId = parseInt(req.params.id);
const search = req.query.search as string | undefined;
const selectedOnly = req.query.selected === '1';
const limit = parseInt(req.query.limit as string) || 50;
const offset = parseInt(req.query.offset as string) || 0;
res.json(getStatesForSource(sourceId, { search, selectedOnly, limit, offset }));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/iobroker/states/:id
router.put('/states/:id', (req, res): void => {
try {
const stateRowId = parseInt(req.params.id);
const stateRow = getDb()
.prepare('SELECT * FROM iobroker_states WHERE id = ?')
.get(stateRowId) as { id: number; source_id: number; state_id: string; selected: number } | undefined;
if (!stateRow) { res.status(404).json({ error: 'State not found' }); return; }
const updated = updateState(stateRowId, req.body);
// If selecting a state, auto-create a datapoint and ensure poller is running
if (req.body.selected === 1 || req.body.selected === true) {
const existing = getDatapointByIoBrokerStateId(stateRow.state_id);
if (!existing) {
createDatapointFromIoBroker(stateRow.source_id, stateRow.state_id, stateRow.state_id);
}
startPolling(stateRow.source_id);
}
res.json(updated);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;
+2
View File
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { migrate001 } from './migrations/001-initial';
import { migrate002 } from './migrations/002-iobroker';
let dbInstance: Database.Database | null = null;
@@ -11,6 +12,7 @@ export function initDb(path: string): Database.Database {
// Run migrations
migrate001(db);
migrate002(db);
dbInstance = db;
return db;
@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
export function migrate002(db: Database.Database): void {
// Create ioBroker sources table
db.exec(`
CREATE TABLE IF NOT EXISTS iobroker_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 8087,
enabled INTEGER DEFAULT 1,
status TEXT DEFAULT 'disconnected',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS iobroker_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL REFERENCES iobroker_sources(id) ON DELETE CASCADE,
state_id TEXT NOT NULL,
selected INTEGER DEFAULT 0,
last_value TEXT,
last_ts INTEGER,
discovered_at TEXT DEFAULT (datetime('now'))
);
`);
// Update datapoints CHECK constraint to allow 'iobroker'
// SQLite does not support ALTER CHECK, so recreate the table if needed
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='datapoints'")
.get() as { sql: string } | undefined;
if (row && !row.sql.includes('iobroker')) {
db.exec(`
CREATE TABLE datapoints_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
label TEXT NOT NULL,
unit TEXT,
value TEXT,
status TEXT DEFAULT 'unknown',
source_type TEXT NOT NULL CHECK(source_type IN ('mqtt','postgresql','iobroker')),
source_id INTEGER NOT NULL,
topic_id INTEGER,
query_id INTEGER,
transform TEXT,
enabled INTEGER DEFAULT 1,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (topic_id) REFERENCES mqtt_topics(id) ON DELETE SET NULL,
FOREIGN KEY (query_id) REFERENCES pg_queries(id) ON DELETE SET NULL
);
INSERT INTO datapoints_new SELECT * FROM datapoints;
DROP TABLE datapoints;
ALTER TABLE datapoints_new RENAME TO datapoints;
`);
}
}
+4
View File
@@ -4,6 +4,7 @@ import { createApp } from './api/index';
import { connectAllEnabledBrokers } from './modules/mqtt/broker-manager';
import { connectAllEnabledSources } from './modules/postgres/source-manager';
import { startAllQueryTimers } from './modules/postgres/query-scheduler';
import { startAllPollers } from './modules/iobroker/state-poller';
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
const DB_PATH = process.env.DB_PATH ?? './display.db';
@@ -32,6 +33,9 @@ async function main(): Promise<void> {
await connectAllEnabledSources();
startAllQueryTimers();
// Start ioBroker state pollers for all enabled sources
startAllPollers();
// Create and start Express app
const app = createApp();
@@ -137,3 +137,24 @@ export function getDatapointByQueryId(queryId: number): Datapoint | undefined {
.prepare('SELECT * FROM datapoints WHERE query_id = ?')
.get(queryId) as Datapoint | undefined;
}
export function createDatapointFromIoBroker(
sourceId: number,
stateId: string,
stateName: string
): Datapoint {
const db = getDb();
const result = db.prepare(
`INSERT INTO datapoints (name, label, source_type, source_id, enabled)
VALUES (?, ?, 'iobroker', ?, 1)`
).run(stateId, stateName, sourceId);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM datapoints WHERE id = ?').get(id) as Datapoint;
}
export function getDatapointByIoBrokerStateId(stateId: string): Datapoint | undefined {
const db = getDb();
return db
.prepare("SELECT * FROM datapoints WHERE source_type = 'iobroker' AND name = ?")
.get(stateId) as Datapoint | undefined;
}
@@ -0,0 +1,187 @@
import { getDb } from '../../db/index';
export interface IoBrokerSource {
id: number;
name: string;
host: string;
port: number;
enabled: number;
status: string;
created_at: string;
}
export interface CreateSourceData {
name: string;
host: string;
port?: number;
enabled?: number;
}
export type UpdateSourceData = Partial<CreateSourceData>;
export interface IoBrokerState {
id: number;
source_id: number;
state_id: string;
selected: number;
last_value: string | null;
last_ts: number | null;
discovered_at: string;
}
export function getAllSources(): IoBrokerSource[] {
const db = getDb();
return db.prepare('SELECT * FROM iobroker_sources ORDER BY id ASC').all() as IoBrokerSource[];
}
export function getSourceById(id: number): IoBrokerSource | undefined {
const db = getDb();
return db.prepare('SELECT * FROM iobroker_sources WHERE id = ?').get(id) as IoBrokerSource | undefined;
}
export function createSource(data: CreateSourceData): IoBrokerSource {
const db = getDb();
const result = db.prepare(
`INSERT INTO iobroker_sources (name, host, port, enabled)
VALUES (?, ?, ?, ?)`
).run(
data.name,
data.host,
data.port ?? 8087,
data.enabled ?? 1,
);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM iobroker_sources WHERE id = ?').get(id) as IoBrokerSource;
}
export function updateSource(id: number, data: UpdateSourceData): IoBrokerSource | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.host !== undefined) { fields.push('host = ?'); values.push(data.host); }
if (data.port !== undefined) { fields.push('port = ?'); values.push(data.port); }
if (data.enabled !== undefined) { fields.push('enabled = ?'); values.push(data.enabled); }
if (fields.length === 0) return getSourceById(id);
values.push(id);
db.prepare(`UPDATE iobroker_sources SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return getSourceById(id);
}
export function deleteSource(id: number): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM iobroker_sources WHERE id = ?').run(id);
return (result.changes as number) > 0;
}
function setStatus(id: number, status: string): void {
const db = getDb();
db.prepare('UPDATE iobroker_sources SET status = ? WHERE id = ?').run(status, id);
}
export async function testSource(host: string, port: number): Promise<boolean> {
try {
const url = `http://${host}:${port}/get/system.adapter.admin.0.alive`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return res.ok;
} catch {
return false;
}
}
export function getStatesForSource(
sourceId: number,
opts?: { search?: string; selectedOnly?: boolean; limit?: number; offset?: number }
): { states: IoBrokerState[]; total: number } {
const db = getDb();
const conditions = ['source_id = ?'];
const params: unknown[] = [sourceId];
if (opts?.search) {
conditions.push('state_id LIKE ?');
params.push(`%${opts.search}%`);
}
if (opts?.selectedOnly) {
conditions.push('selected = 1');
}
const where = conditions.join(' AND ');
const total = (db.prepare(`SELECT count(*) as c FROM iobroker_states WHERE ${where}`).get(...params) as { c: number }).c;
const limit = opts?.limit ?? 50;
const offset = opts?.offset ?? 0;
params.push(limit, offset);
const states = db
.prepare(`SELECT * FROM iobroker_states WHERE ${where} ORDER BY selected DESC, state_id ASC LIMIT ? OFFSET ?`)
.all(...params) as IoBrokerState[];
return { states, total };
}
export function updateState(id: number, data: { selected?: number; last_value?: string; last_ts?: number }): IoBrokerState | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.selected !== undefined) { fields.push('selected = ?'); values.push(data.selected); }
if (data.last_value !== undefined) { fields.push('last_value = ?'); values.push(data.last_value); }
if (data.last_ts !== undefined) { fields.push('last_ts = ?'); values.push(data.last_ts); }
if (fields.length === 0) {
return db.prepare('SELECT * FROM iobroker_states WHERE id = ?').get(id) as IoBrokerState | undefined;
}
values.push(id);
db.prepare(`UPDATE iobroker_states SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare('SELECT * FROM iobroker_states WHERE id = ?').get(id) as IoBrokerState | undefined;
}
export async function discoverStates(sourceId: number): Promise<number> {
const source = getSourceById(sourceId);
if (!source) throw new Error(`ioBroker source ${sourceId} not found`);
const db = getDb();
try {
setStatus(sourceId, 'discovering');
const url = `http://${source.host}:${source.port}/objects?type=state&pattern=*`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) {
setStatus(sourceId, 'error');
throw new Error(`ioBroker API returned ${res.status}`);
}
const data = await res.json() as Record<string, unknown>;
const stateIds = Object.keys(data).slice(0, 5000);
const insertStmt = db.prepare(
`INSERT OR IGNORE INTO iobroker_states (source_id, state_id) VALUES (?, ?)`
);
const insertMany = db.transaction((ids: string[]) => {
for (const sid of ids) {
insertStmt.run(sourceId, sid);
}
});
insertMany(stateIds);
setStatus(sourceId, 'connected');
return stateIds.length;
} catch (err) {
setStatus(sourceId, 'error');
throw err;
}
}
@@ -0,0 +1,86 @@
import { getDb } from '../../db/index';
import { getAllSources, getSourceById, type IoBrokerState } from './source-manager';
import {
getDatapointByIoBrokerStateId,
updateDatapointValue,
} from '../datapoints/datapoint-manager';
const pollerTimers = new Map<number, ReturnType<typeof setInterval>>();
const POLL_INTERVAL_MS = 10_000;
async function pollSource(sourceId: number): Promise<void> {
const source = getSourceById(sourceId);
if (!source || !source.enabled) return;
const db = getDb();
const selectedStates = db
.prepare('SELECT * FROM iobroker_states WHERE source_id = ? AND selected = 1')
.all(sourceId) as IoBrokerState[];
if (selectedStates.length === 0) return;
const stateIds = selectedStates.map((s) => s.state_id).join(',');
const url = `http://${source.host}:${source.port}/getBulk/${encodeURIComponent(stateIds)}`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) return;
const results = await res.json() as Array<{ id: string; val: unknown; ts?: number }>;
for (const item of results) {
if (item.val === null || item.val === undefined) continue;
const rawValue = String(item.val);
// Update last_value in iobroker_states
db.prepare(
'UPDATE iobroker_states SET last_value = ?, last_ts = ? WHERE source_id = ? AND state_id = ?'
).run(rawValue, item.ts ?? null, sourceId, item.id);
// Update datapoint value if one exists
const dp = getDatapointByIoBrokerStateId(item.id);
if (dp) {
updateDatapointValue(dp.id, rawValue);
}
}
} catch (err) {
// Silently ignore polling errors — source may be temporarily unreachable
console.warn(`ioBroker poll error for source ${sourceId}:`, (err as Error).message);
}
}
export function startPolling(sourceId: number): void {
if (pollerTimers.has(sourceId)) return; // already running
const timer = setInterval(() => {
pollSource(sourceId).catch((err) =>
console.error(`ioBroker poller ${sourceId}:`, (err as Error).message)
);
}, POLL_INTERVAL_MS);
pollerTimers.set(sourceId, timer);
console.log(`ioBroker poller started for source ${sourceId}`);
}
export function stopPolling(sourceId: number): void {
const timer = pollerTimers.get(sourceId);
if (timer) {
clearInterval(timer);
pollerTimers.delete(sourceId);
console.log(`ioBroker poller stopped for source ${sourceId}`);
}
}
export function startAllPollers(): void {
const sources = getAllSources();
for (const source of sources) {
if (source.enabled) {
startPolling(source.id);
}
}
}