feat: initial veridian-edge agent scaffold
This commit is contained in:
commit
1ce13b76f4
12 changed files with 876 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
config.json
|
||||||
|
.bun
|
||||||
|
*.log
|
||||||
96
README.md
Normal file
96
README.md
Normal 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
21
config.example.json
Normal 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
18
package.json
Normal 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
106
scripts/install.sh
Normal 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
113
src/buffer.ts
Normal 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
74
src/config.ts
Normal 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
49
src/health.ts
Normal 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
119
src/index.ts
Normal 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
196
src/sensorpush.ts
Normal 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
55
src/veridian.ts
Normal 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
23
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue