From 1ce13b76f4fc472967db17b3f01605781db4cee4 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:14:50 -0800 Subject: [PATCH] feat: initial veridian-edge agent scaffold --- .gitignore | 6 ++ README.md | 96 ++++++++++++++++++++++ config.example.json | 21 +++++ package.json | 18 ++++ scripts/install.sh | 106 ++++++++++++++++++++++++ src/buffer.ts | 113 +++++++++++++++++++++++++ src/config.ts | 74 +++++++++++++++++ src/health.ts | 49 +++++++++++ src/index.ts | 119 +++++++++++++++++++++++++++ src/sensorpush.ts | 196 ++++++++++++++++++++++++++++++++++++++++++++ src/veridian.ts | 55 +++++++++++++ tsconfig.json | 23 ++++++ 12 files changed, 876 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.example.json create mode 100644 package.json create mode 100644 scripts/install.sh create mode 100644 src/buffer.ts create mode 100644 src/config.ts create mode 100644 src/health.ts create mode 100644 src/index.ts create mode 100644 src/sensorpush.ts create mode 100644 src/veridian.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30937be --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.db +config.json +.bun +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..b17fa64 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# Veridian Edge Agent + +SensorPush integration agent for the Veridian Cultivation Platform. Runs on Raspberry Pi or Ubuntu, polls SensorPush Cloud API, and syncs environmental readings to the Veridian backend. + +## Features + +- šŸŒ”ļø **SensorPush Integration** – OAuth 2.0 authentication with automatic token refresh +- šŸ’¾ **Offline Resilience** – SQLite buffer for network outages +- šŸ„ **Health Monitoring** – HTTP endpoints for health checks and Prometheus metrics +- šŸ”„ **Auto-Recovery** – Systemd service with watchdog + +## Quick Start + +### Prerequisites + +- Raspberry Pi 4 (2GB+ RAM) or Ubuntu 22.04+ +- SensorPush G1 WiFi Gateway +- Bun 1.x runtime + +### Installation + +```bash +# Clone repository +git clone https://git.runfoo.run/malty/veridian-edge.git +cd veridian-edge + +# Run installer +sudo ./scripts/install.sh +``` + +### Configuration + +Edit `/opt/veridian-edge/config.json`: + +```json +{ + "facilityId": "your-facility-uuid", + "backendUrl": "https://api.veridian.runfoo.run", + "backendApiKey": "your-api-key", + "sensorpush": { + "email": "sensors@facility.com", + "password": "your-password" + }, + "sensorMappings": [ + { + "sensorId": "123456.7890", + "roomId": "room-uuid", + "name": "Flower Room 1" + } + ] +} +``` + +### Service Management + +```bash +# Start service +sudo systemctl start veridian-edge + +# Check status +sudo systemctl status veridian-edge + +# View logs +journalctl -u veridian-edge -f + +# Health check +curl http://localhost:3030/health +``` + +## Development + +```bash +# Install dependencies +bun install + +# Run in development mode +bun run dev +``` + +## Architecture + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ SensorPush │ ──▶ │ Veridian Edge │ ──▶ │ Veridian API │ +│ Cloud API │ │ Agent │ │ Backend │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ SQLite Buffer │ + │ (offline cache) │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +## License + +Proprietary - Veridian Cultivation Platform diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..7e9cbcb --- /dev/null +++ b/config.example.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://veridian.runfoo.run/schemas/edge-config.json", + "version": "1.0", + "facilityId": "YOUR_FACILITY_UUID", + "backendUrl": "https://api.veridian.runfoo.run", + "backendApiKey": "YOUR_API_KEY", + "sensorpush": { + "email": "sensors@facility.com", + "password": "YOUR_PASSWORD" + }, + "sensorMappings": [ + { + "sensorId": "123456.7890", + "roomId": "ROOM_UUID", + "name": "Flower Room 1" + } + ], + "pollingIntervalSec": 60, + "bufferMaxRows": 10000, + "logLevel": "info" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1bbecce --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "veridian-edge", + "version": "1.0.0", + "description": "SensorPush Edge Agent for Veridian Cultivation Platform", + "main": "src/index.ts", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun --watch run src/index.ts", + "test": "bun test" + }, + "dependencies": { + "better-sqlite3": "^11.0.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/better-sqlite3": "^7.6.11" + } +} \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..27db2ba --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# +# Veridian Edge Agent Installer +# For Raspberry Pi / Ubuntu +# + +set -e + +INSTALL_DIR="/opt/veridian-edge" +SERVICE_NAME="veridian-edge" +CURRENT_USER="${SUDO_USER:-$USER}" + +echo "🌱 Veridian Edge Agent Installer" +echo "================================" +echo "" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "āš ļø Please run with sudo: sudo ./install.sh" + exit 1 +fi + +# Check for Bun +if ! command -v bun &> /dev/null; then + echo "šŸ“¦ Installing Bun..." + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="/home/$CURRENT_USER/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" +fi + +echo "āœ… Bun installed: $(bun --version)" + +# Create install directory +echo "šŸ“ Creating $INSTALL_DIR..." +mkdir -p "$INSTALL_DIR" +chown "$CURRENT_USER:$CURRENT_USER" "$INSTALL_DIR" + +# Copy files (assume running from repo root) +echo "šŸ“‹ Copying files..." +if [ -f "package.json" ]; then + cp -r ./* "$INSTALL_DIR/" +else + echo "āŒ Error: Run this script from the veridian-edge repository root" + exit 1 +fi + +# Install dependencies +echo "šŸ“¦ Installing dependencies..." +cd "$INSTALL_DIR" +sudo -u "$CURRENT_USER" bun install + +# Create config if not exists +if [ ! -f "$INSTALL_DIR/config.json" ]; then + echo "šŸ“ Creating config.json from template..." + cp "$INSTALL_DIR/config.example.json" "$INSTALL_DIR/config.json" + echo "" + echo "āš ļø IMPORTANT: Edit $INSTALL_DIR/config.json with your credentials!" + echo "" +fi + +# Create data directory +mkdir -p "/home/$CURRENT_USER/.local/share/veridian-edge" +chown "$CURRENT_USER:$CURRENT_USER" "/home/$CURRENT_USER/.local/share/veridian-edge" + +# Create systemd service +echo "šŸ”§ Creating systemd service..." +BUN_PATH="/home/$CURRENT_USER/.bun/bin/bun" +cat > "/etc/systemd/system/$SERVICE_NAME.service" < { + for (const r of items) { + insert.run(r); + } + }); + + insertMany(readings); + this.prune(); + } + + /** + * Get all buffered readings + */ + getAll(): Reading[] { + const rows = this.db.prepare(` + SELECT roomId, temperature, humidity, dewpoint, vpd, timestamp + FROM readings + ORDER BY createdAt ASC + `).all() as Reading[]; + + return rows; + } + + /** + * Clear all buffered readings + */ + clear(): void { + this.db.exec('DELETE FROM readings'); + } + + /** + * Get count of buffered readings + */ + count(): number { + const row = this.db.prepare('SELECT COUNT(*) as count FROM readings').get() as { count: number }; + return row.count; + } + + /** + * Prune old readings if over limit + */ + private prune(): void { + const count = this.count(); + if (count > this.maxRows) { + const toDelete = count - this.maxRows; + this.db.prepare(` + DELETE FROM readings + WHERE id IN ( + SELECT id FROM readings ORDER BY createdAt ASC LIMIT ? + ) + `).run(toDelete); + } + } + + /** + * Close database connection + */ + close(): void { + this.db.close(); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0d876f2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,74 @@ +/** + * Configuration loader for Veridian Edge Agent + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +export interface SensorMapping { + sensorId: string; + roomId: string; + name: string; +} + +export interface EdgeConfig { + version: string; + facilityId: string; + backendUrl: string; + backendApiKey: string; + sensorpush: { + email: string; + password: string; + gateway?: string; + }; + sensorMappings: SensorMapping[]; + pollingIntervalSec: number; + bufferMaxRows: number; + logLevel: 'debug' | 'info' | 'warn' | 'error'; +} + +const CONFIG_PATHS = [ + './config.json', + '/opt/veridian-edge/config.json', + join(process.env.HOME || '~', '.config/veridian-edge/config.json'), +]; + +export function loadConfig(): EdgeConfig { + // Check environment variable first + const envPath = process.env.VERIDIAN_CONFIG; + if (envPath && existsSync(envPath)) { + return parseConfig(envPath); + } + + // Check default paths + for (const path of CONFIG_PATHS) { + if (existsSync(path)) { + return parseConfig(path); + } + } + + throw new Error( + `Config file not found. Tried: ${CONFIG_PATHS.join(', ')}\n` + + 'Set VERIDIAN_CONFIG env var or copy config.example.json to one of the paths above.' + ); +} + +function parseConfig(path: string): EdgeConfig { + console.log(`šŸ“ Loading config from: ${path}`); + const content = readFileSync(path, 'utf-8'); + const config = JSON.parse(content) as EdgeConfig; + + // Validate required fields + if (!config.facilityId) throw new Error('Config missing: facilityId'); + if (!config.backendUrl) throw new Error('Config missing: backendUrl'); + if (!config.sensorpush?.email) throw new Error('Config missing: sensorpush.email'); + if (!config.sensorpush?.password) throw new Error('Config missing: sensorpush.password'); + + // Defaults + config.pollingIntervalSec = config.pollingIntervalSec || 60; + config.bufferMaxRows = config.bufferMaxRows || 10000; + config.logLevel = config.logLevel || 'info'; + config.sensorMappings = config.sensorMappings || []; + + return config; +} diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..18563f5 --- /dev/null +++ b/src/health.ts @@ -0,0 +1,49 @@ +/** + * Health Check HTTP Server + * + * Exposes /health and /metrics endpoints for monitoring. + */ + +interface HealthStatus { + status: 'ok' | 'error'; + lastSync: string | null; + bufferedCount: number; +} + +type HealthCallback = () => HealthStatus; + +export function startHealthServer(port: number, getHealth: HealthCallback): void { + Bun.serve({ + port, + hostname: '127.0.0.1', // Only bind to localhost for security + fetch(req) { + const url = new URL(req.url); + + if (url.pathname === '/health') { + const health = getHealth(); + return Response.json(health, { + status: health.status === 'ok' ? 200 : 503, + }); + } + + if (url.pathname === '/metrics') { + const health = getHealth(); + const metrics = [ + `# HELP veridian_edge_buffer_size Number of readings in local buffer`, + `# TYPE veridian_edge_buffer_size gauge`, + `veridian_edge_buffer_size ${health.bufferedCount}`, + ``, + `# HELP veridian_edge_status Agent status (1=ok, 0=error)`, + `# TYPE veridian_edge_status gauge`, + `veridian_edge_status ${health.status === 'ok' ? 1 : 0}`, + ].join('\n'); + + return new Response(metrics, { + headers: { 'Content-Type': 'text/plain' }, + }); + } + + return new Response('Not Found', { status: 404 }); + }, + }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a113b8b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,119 @@ +/** + * Veridian Edge Agent - SensorPush Integration + * + * Main entry point for the edge device. + * Polls SensorPush Cloud API and syncs readings to Veridian backend. + */ + +import { SensorPushClient } from './sensorpush'; +import { VeridianClient } from './veridian'; +import { BufferManager } from './buffer'; +import { startHealthServer } from './health'; +import { loadConfig, type EdgeConfig } from './config'; + +// Global state +let config: EdgeConfig; +let sensorPush: SensorPushClient; +let veridian: VeridianClient; +let buffer: BufferManager; +let lastSync: Date | null = null; +let isRunning = false; + +async function main() { + console.log('🌱 Veridian Edge Agent starting...'); + + // Load configuration + config = loadConfig(); + console.log(`šŸ“ Facility: ${config.facilityId}`); + console.log(`šŸ”— Backend: ${config.backendUrl}`); + + // Initialize clients + sensorPush = new SensorPushClient(config.sensorpush); + veridian = new VeridianClient(config.backendUrl, config.backendApiKey); + buffer = new BufferManager(config.bufferMaxRows); + + // Authenticate with SensorPush + await sensorPush.authenticate(); + console.log('āœ… SensorPush authenticated'); + + // Start health server + startHealthServer(3030, () => ({ + status: 'ok', + lastSync: lastSync?.toISOString() ?? null, + bufferedCount: buffer.count(), + })); + console.log('šŸ„ Health server running on :3030'); + + // Start polling loop + isRunning = true; + pollLoop(); + + // Graceful shutdown + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +async function pollLoop() { + while (isRunning) { + try { + await pollAndSync(); + } catch (error) { + console.error('āŒ Poll error:', error); + } + + // Wait for next poll (respect rate limit) + await Bun.sleep(config.pollingIntervalSec * 1000); + } +} + +async function pollAndSync() { + // 1. Fetch readings from SensorPush + const readings = await sensorPush.getSamples(); + console.log(`šŸ“Š Fetched ${readings.length} readings from SensorPush`); + + if (readings.length === 0) return; + + // 2. Map sensor IDs to room IDs + const mappedReadings = readings + .map(r => { + const mapping = config.sensorMappings.find(m => m.sensorId === r.sensorId); + if (!mapping) return null; + return { + roomId: mapping.roomId, + temperature: r.temperature, + humidity: r.humidity, + dewpoint: r.dewpoint, + vpd: r.vpd, + timestamp: r.observed, + }; + }) + .filter(Boolean); + + // 3. Try to sync to backend + try { + // First flush any buffered readings + const buffered = buffer.getAll(); + if (buffered.length > 0) { + await veridian.postReadings([...buffered, ...mappedReadings]); + buffer.clear(); + console.log(`šŸ“¤ Synced ${buffered.length + mappedReadings.length} readings (including buffered)`); + } else { + await veridian.postReadings(mappedReadings); + console.log(`šŸ“¤ Synced ${mappedReadings.length} readings`); + } + lastSync = new Date(); + } catch (error) { + // Backend unreachable - buffer locally + console.warn('āš ļø Backend unreachable, buffering readings...'); + buffer.add(mappedReadings); + } +} + +function shutdown() { + console.log('\nšŸ›‘ Shutting down...'); + isRunning = false; + buffer.close(); + process.exit(0); +} + +main().catch(console.error); diff --git a/src/sensorpush.ts b/src/sensorpush.ts new file mode 100644 index 0000000..7e10e89 --- /dev/null +++ b/src/sensorpush.ts @@ -0,0 +1,196 @@ +/** + * SensorPush Cloud API Client + * + * Implements OAuth 2.0 authentication and sensor data fetching. + * Respects rate limit: max 1 request per minute. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; + +const API_BASE = 'https://api.sensorpush.com/api/v1'; +const TOKEN_FILE = join(process.env.HOME || '~', '.config/sensorpush/tokens.json'); + +interface TokenData { + accessToken: string; + authorization: string; + expiresAt: number; // Unix timestamp +} + +interface SensorReading { + sensorId: string; + temperature: number; // Fahrenheit + humidity: number; // % + dewpoint: number; + vpd: number; + observed: string; // ISO timestamp +} + +export class SensorPushClient { + private email: string; + private password: string; + private tokens: TokenData | null = null; + + constructor(config: { email: string; password: string }) { + this.email = config.email; + this.password = config.password; + this.loadTokens(); + } + + /** + * Authenticate with SensorPush Cloud API + */ + async authenticate(): Promise { + // Check if we have valid tokens + if (this.tokens && this.tokens.expiresAt > Date.now() + 3600000) { + console.log('šŸ”‘ Using cached SensorPush token'); + return; + } + + console.log('šŸ” Authenticating with SensorPush...'); + + // Step 1: Get authorization code + const authRes = await fetch(`${API_BASE}/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: this.email, + password: this.password, + }), + }); + + if (!authRes.ok) { + throw new Error(`SensorPush auth failed: ${authRes.status} ${await authRes.text()}`); + } + + const authData = await authRes.json() as { authorization: string }; + + // Step 2: Exchange for access token + const tokenRes = await fetch(`${API_BASE}/oauth/accesstoken`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + authorization: authData.authorization, + }), + }); + + if (!tokenRes.ok) { + throw new Error(`SensorPush token exchange failed: ${tokenRes.status}`); + } + + const tokenData = await tokenRes.json() as { accesstoken: string }; + + // Store tokens (access token valid for 12 hours) + this.tokens = { + accessToken: tokenData.accesstoken, + authorization: authData.authorization, + expiresAt: Date.now() + 12 * 60 * 60 * 1000, // 12 hours + }; + + this.saveTokens(); + } + + /** + * Get recent sensor samples + */ + async getSamples(minutes: number = 5): Promise { + await this.ensureAuthenticated(); + + // First get list of sensors + const sensorsRes = await fetch(`${API_BASE}/devices/sensors`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': this.tokens!.accessToken, + }, + body: JSON.stringify({}), + }); + + if (!sensorsRes.ok) { + throw new Error(`Failed to get sensors: ${sensorsRes.status}`); + } + + const sensorsData = await sensorsRes.json() as Record; + const sensorIds = Object.keys(sensorsData); + + if (sensorIds.length === 0) { + return []; + } + + // Get samples for all sensors + const startTime = new Date(Date.now() - minutes * 60 * 1000).toISOString(); + + const samplesRes = await fetch(`${API_BASE}/samples`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': this.tokens!.accessToken, + }, + body: JSON.stringify({ + sensors: sensorIds, + startTime, + limit: 100, + }), + }); + + if (!samplesRes.ok) { + throw new Error(`Failed to get samples: ${samplesRes.status}`); + } + + const samplesData = await samplesRes.json() as { + sensors: Record>; + }; + + // Flatten and transform + const readings: SensorReading[] = []; + for (const [sensorId, samples] of Object.entries(samplesData.sensors || {})) { + for (const sample of samples) { + readings.push({ + sensorId, + temperature: sample.temperature, + humidity: sample.humidity, + dewpoint: sample.dewpoint ?? 0, + vpd: sample.vpd ?? 0, + observed: sample.observed, + }); + } + } + + return readings; + } + + private async ensureAuthenticated(): Promise { + if (!this.tokens || this.tokens.expiresAt < Date.now() + 3600000) { + await this.authenticate(); + } + } + + private loadTokens(): void { + try { + if (existsSync(TOKEN_FILE)) { + const data = readFileSync(TOKEN_FILE, 'utf-8'); + this.tokens = JSON.parse(data); + } + } catch { + // Ignore errors + } + } + + private saveTokens(): void { + try { + const dir = dirname(TOKEN_FILE); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(TOKEN_FILE, JSON.stringify(this.tokens), { mode: 0o600 }); + } catch (err) { + console.warn('Failed to save tokens:', err); + } + } +} diff --git a/src/veridian.ts b/src/veridian.ts new file mode 100644 index 0000000..8699745 --- /dev/null +++ b/src/veridian.ts @@ -0,0 +1,55 @@ +/** + * Veridian Backend API Client + */ + +export interface Reading { + roomId: string; + temperature: number; + humidity: number; + dewpoint?: number; + vpd?: number; + timestamp: string; +} + +export class VeridianClient { + private baseUrl: string; + private apiKey: string; + + constructor(baseUrl: string, apiKey: string) { + this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash + this.apiKey = apiKey; + } + + /** + * Post sensor readings to Veridian backend + */ + async postReadings(readings: Reading[]): Promise { + if (readings.length === 0) return; + + const res = await fetch(`${this.baseUrl}/environment/ingest`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ readings }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Veridian API error: ${res.status} ${text}`); + } + } + + /** + * Health check + */ + async ping(): Promise { + try { + const res = await fetch(`${this.baseUrl}/health`); + return res.ok; + } catch { + return false; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f7703b4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "types": [ + "bun-types" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file