feat: initial veridian-edge agent scaffold

This commit is contained in:
fullsizemalt 2026-01-01 23:14:50 -08:00
commit 1ce13b76f4
12 changed files with 876 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
dist/
*.db
config.json
.bun
*.log

96
README.md Normal file
View file

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

21
config.example.json Normal file
View file

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

18
package.json Normal file
View file

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

106
scripts/install.sh Normal file
View file

@ -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" <<EOF
[Unit]
Description=Veridian SensorPush Edge Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$CURRENT_USER
WorkingDirectory=$INSTALL_DIR
ExecStart=$BUN_PATH run src/index.ts
Restart=always
RestartSec=10
WatchdogSec=120
StandardOutput=journal
StandardError=journal
Environment=HOME=/home/$CURRENT_USER
[Install]
WantedBy=multi-user.target
EOF
# Enable and start service
echo "🚀 Enabling and starting service..."
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
echo ""
echo "================================"
echo "✅ Installation complete!"
echo ""
echo "Next steps:"
echo " 1. Edit config: sudo nano $INSTALL_DIR/config.json"
echo " 2. Start service: sudo systemctl start $SERVICE_NAME"
echo " 3. Check status: sudo systemctl status $SERVICE_NAME"
echo " 4. View logs: journalctl -u $SERVICE_NAME -f"
echo ""
echo "Health check: curl http://localhost:3030/health"
echo "================================"

113
src/buffer.ts Normal file
View file

@ -0,0 +1,113 @@
/**
* SQLite Buffer Manager
*
* Stores sensor readings locally when backend is unreachable.
* Flushes to backend when connection is restored.
*/
import Database from 'better-sqlite3';
import { join } from 'path';
import type { Reading } from './veridian';
const DB_PATH = join(process.env.HOME || '~', '.local/share/veridian-edge/buffer.db');
export class BufferManager {
private db: Database.Database;
private maxRows: number;
constructor(maxRows: number = 10000) {
this.maxRows = maxRows;
this.db = new Database(DB_PATH);
this.init();
}
private init(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
roomId TEXT NOT NULL,
temperature REAL NOT NULL,
humidity REAL NOT NULL,
dewpoint REAL,
vpd REAL,
timestamp TEXT NOT NULL,
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_readings_created
ON readings(createdAt)
`);
}
/**
* Add readings to buffer
*/
add(readings: Reading[]): void {
const insert = this.db.prepare(`
INSERT INTO readings (roomId, temperature, humidity, dewpoint, vpd, timestamp)
VALUES (@roomId, @temperature, @humidity, @dewpoint, @vpd, @timestamp)
`);
const insertMany = this.db.transaction((items: Reading[]) => {
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();
}
}

74
src/config.ts Normal file
View file

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

49
src/health.ts Normal file
View file

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

119
src/index.ts Normal file
View file

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

196
src/sensorpush.ts Normal file
View file

@ -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<void> {
// 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<SensorReading[]> {
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<string, { id: string; name: string }>;
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<string, Array<{
observed: string;
temperature: number;
humidity: number;
dewpoint?: number;
vpd?: number;
}>>;
};
// 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<void> {
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);
}
}
}

55
src/veridian.ts Normal file
View file

@ -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<void> {
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<boolean> {
try {
const res = await fetch(`${this.baseUrl}/health`);
return res.ok;
} catch {
return false;
}
}
}

23
tsconfig.json Normal file
View file

@ -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"
]
}