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