Compare commits

...

17 Commits

Author SHA1 Message Date
sebseb7
27f52b0c3d u 2025-12-26 02:00:42 +01:00
sebseb7
86bea2fa6d u 2025-12-26 01:57:15 +01:00
sebseb7
e9a66cd1f4 u 2025-12-26 01:44:21 +01:00
sebseb7
758684c598 u 2025-12-26 01:41:49 +01:00
sebseb7
ad7a0d1768 u 2025-12-26 01:26:32 +01:00
sebseb7
1dfa59ae13 u 2025-12-26 01:23:33 +01:00
sebseb7
3d43a42b12 u 2025-12-26 01:05:43 +01:00
sebseb7
d586d12e68 u 2025-12-26 00:46:19 +01:00
sebseb7
94a435c6f6 u 2025-12-26 00:32:04 +01:00
sebseb7
93e3baa1c5 u 2025-12-25 18:42:15 +01:00
sebseb7
4f52064b3d u 2025-12-25 18:34:49 +01:00
sebseb7
822045b06d u 2025-12-25 06:22:05 +01:00
sebseb7
dcdfb27684 u 2025-12-25 06:13:23 +01:00
sebseb7
10556cb698 u 2025-12-25 06:03:46 +01:00
sebseb7
22701a2614 u 2025-12-25 05:56:44 +01:00
sebseb7
a09da9a835 u 2025-12-25 05:48:56 +01:00
sebseb7
c794dbaab8 u 2025-12-25 05:43:36 +01:00
24 changed files with 2787 additions and 2016 deletions

139
README.md
View File

@@ -1,139 +0,0 @@
# TischlerCtrl - Sensor Data Collection System
A Node.js server that collects sensor data from multiple agents via WebSocket, stores it in SQLite with automatic data summarization and retention policies.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Central Server (Node.js) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ WebSocket │ │ SQLite DB │ │ Aggregation & │ │
│ │ Server │──│ sensor_data │ │ Cleanup Jobs │ │
│ │ :8080 │ │ sensor_10m │ │ (10m, 1h) │ │
│ └─────────────┘ │ sensor_1h │ └──────────────────┘ │
└────────┬─────────┴──────────────────────────────────────────┘
┌────┴────┬──────────────┐
│ │ │
┌───▼───┐ ┌───▼───┐ ┌─────▼─────┐
│ AC │ │ Tapo │ │ CLI │
│Infinity│ │ Agent │ │ Agent │
│ Agent │ │(Rust) │ │ (bash) │
└───────┘ └───────┘ └───────────┘
```
## Quick Start
### 1. Start the Server
```bash
cd server
cp .env.example .env
npm install
npm start
```
### 2. Generate API Keys
```bash
cd server
node src/cli/generate-key.js "ac-infinity-agent" "ac:"
node src/cli/generate-key.js "tapo-agent" "tapo:"
node src/cli/generate-key.js "custom" "custom:"
```
### 3. Configure and Start AC Infinity Agent
```bash
cd agents/ac-infinity
cp .env.example .env
# Edit .env with your AC Infinity credentials and API key
npm install
npm start
```
### 4. Build and Deploy Tapo Agent (Rust)
```bash
cd agents/tapo
cp config.toml.example config.toml
# Edit config.toml with your Tapo devices and API key
# Build for local machine
cargo build --release
# Or cross-compile for Raspberry Pi (requires cross)
# cargo install cross
# cross build --release --target armv7-unknown-linux-gnueabihf
# Run
./target/release/tapo-agent
# Or: RUST_LOG=info ./target/release/tapo-agent
```
### 5. Use CLI Agent
```bash
# Install websocat (one-time)
cargo install websocat
# Or: sudo apt install websocat
# Send data
export SENSOR_API_KEY="your-custom-api-key"
export SENSOR_SERVER="ws://localhost:8080"
./agents/cli/sensor-send mydevice temperature 24.5
```
## Data Retention Policy
| Resolution | Retention | Source |
|------------|-----------|--------|
| Raw (1 min) | 7 days | `sensor_data` |
| 10 minutes | 30 days | `sensor_data_10m` |
| 1 hour | Forever | `sensor_data_1h` |
Data is averaged when aggregating to higher resolutions.
## WebSocket Protocol
### Authentication
```json
{"type": "auth", "apiKey": "your-api-key"}
{"type": "auth", "success": true, "devicePrefix": "ac:"}
```
### Send Data
```json
{"type": "data", "readings": [
{"device": "ctrl1", "channel": "temperature", "value": 24.5},
{"device": "ctrl1", "channel": "humidity", "value": 65.0}
]}
{"type": "ack", "count": 2}
```
## Project Structure
```
tischlerctrl/
├── server/ # Central data collection server
│ ├── src/
│ │ ├── index.js # Entry point
│ │ ├── config.js # Configuration
│ │ ├── db/ # Database schema & queries
│ │ ├── websocket/ # WebSocket server
│ │ ├── jobs/ # Aggregation & cleanup jobs
│ │ └── cli/ # CLI tools (generate-key)
│ └── data/ # SQLite database files
├── agents/
│ ├── ac-infinity/ # Node.js AC Infinity agent
│ ├── tapo/ # Rust Tapo smart plug agent
│ └── cli/ # Bash CLI tool
└── README.md
```
## License
MIT

View File

@@ -276,8 +276,15 @@ export class ACInfinityClient {
// Constrain level 0-10
const safeLevel = Math.max(0, Math.min(10, Math.round(level)));
// Mode 1 = ON (Manual), 0 = OFF
const mode = safeLevel === 0 ? 0 : 1;
// AtType Constants from reverse engineering
const AtType = {
OFF: 1,
ON: 2,
AUTO: 3
};
// Mode 2 = ON (Manual), 1 = OFF
const mode = safeLevel === 0 ? AtType.OFF : AtType.ON;
// Merge with existing settings
// We need to send back mostly specific keys.
@@ -293,7 +300,23 @@ export class ACInfinityClient {
// Add mode/speak
params.append('mode', mode.toString());
params.append('speak', safeLevel.toString());
// NOTE: In Mode 1 (OFF), 'speak' sets the Minimum Speed (usually 0).
// In Mode 2 (ON), 'speak' sets the Maximum/Target Speed.
const speakValue = mode === AtType.OFF ? 0 : safeLevel;
params.append('speak', speakValue.toString());
// CRITICAL FIX: Explicitly set atType to match the mode!
// atType: 1 = OFF, 2 = ON, 3 = AUTO
params.append('atType', mode.toString());
// Ensure onSpead (Max Speed) matches target if in ON mode
if (mode === AtType.ON) {
params.append('onSpead', safeLevel.toString());
} else {
// In OFF mode, ensure onSpead is at least present (maybe 10 or 0? Leaving existing or default)
if (!params.has('onSpead')) params.append('onSpead', '10');
}
// Copy other relevant fields from settings if they exist to maintain state
// Common fields seen in other implementations:
@@ -331,7 +354,7 @@ export class ACInfinityClient {
throw new ACInfinityClientError(`Set mode failed: ${JSON.stringify(data)}`);
}
console.log(`[AC] Set device ${devId} port ${port} to level ${safeLevel} (mode ${mode})`);
console.log(`[AC] Set device ${devId} port ${port} to level ${safeLevel} (mode ${mode}: ${mode === 1 ? 'OFF' : 'ON'})`);
return true;
} catch (error) {
console.error('[AC] Error setting device port:', error);

View File

@@ -1,335 +0,0 @@
# Sensor Data Collection System
A Node.js server that collects sensor data from multiple agents via WebSocket, stores it in SQLite with automatic data summarization and retention policies.
## Architecture Overview
```mermaid
graph TB
subgraph "Central Server (Node.js)"
WS[WebSocket Server :8080]
DB[(SQLite Database)]
AGG[Aggregation Job]
WS --> DB
AGG --> DB
end
subgraph "AC Infinity Agent (Node.js)"
AC[AC Infinity Client]
AC -->|polls every 60s| ACAPI[AC Infinity Cloud API]
AC -->|WebSocket| WS
end
subgraph "Tapo Agent (Rust)"
TAPO[Tapo Client]
TAPO -->|polls every 60s| PLUG[Tapo P100/P110]
TAPO -->|WebSocket| WS
end
subgraph "Custom CLI Agent"
CLI[Shell Script]
CLI -->|WebSocket| WS
end
```
---
## User Review Required
> [!IMPORTANT]
> **Tapo Agent Language Choice**: I recommend **Rust** for the Tapo agent because:
> - Compiles to a single ~2MB static binary
> - Uses ~5-10MB RAM at runtime
> - Excellent [tapo crate](https://crates.io/crates/tapo) already exists
> - Easy cross-compilation for Raspberry Pi
>
> Alternatively, I could write it in **Go** (would need to implement protocol from scratch) or as a **Node.js** agent (but you mentioned wanting it lightweight).
> [!IMPORTANT]
> **AC Infinity Credentials**: The AC Infinity API requires email/password authentication to their cloud service. These will need to be stored in configuration.
---
## Project Structure
```
tischlerctrl/
├── server/
│ ├── package.json
│ ├── src/
│ │ ├── index.js # Entry point
│ │ ├── config.js # Configuration loader
│ │ ├── db/
│ │ │ ├── schema.js # SQLite schema + migrations
│ │ │ └── queries.js # Database operations
│ │ ├── websocket/
│ │ │ ├── server.js # WebSocket server
│ │ │ └── handlers.js # Message handlers
│ │ └── jobs/
│ │ ├── aggregator.js # Data summarization job
│ │ └── cleanup.js # Data retention cleanup
│ └── data/
│ └── sensors.db # SQLite database file
├── agents/
│ ├── ac-infinity/
│ │ ├── package.json
│ │ └── src/
│ │ ├── index.js # Entry point
│ │ ├── config.js # Configuration
│ │ ├── ac-client.js # AC Infinity API client
│ │ └── ws-client.js # WebSocket client with reconnect
│ │
│ ├── tapo/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # Rust Tapo agent
│ │
│ └── cli/
│ └── sensor-send # Shell script CLI tool
├── .env.example # Example environment variables
└── README.md
```
---
## Proposed Changes
### Server - Database Schema
#### [NEW] [schema.js](file:///home/seb/src/tischlerctrl/server/src/db/schema.js)
SQLite tables:
```sql
-- API keys for agent authentication
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL, -- e.g., "ac-infinity-agent"
device_prefix TEXT NOT NULL, -- e.g., "ac:" or "tapo:"
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME
);
-- Raw sensor data (1-minute resolution, kept for 1 week)
CREATE TABLE sensor_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL,
device TEXT NOT NULL, -- e.g., "ac:controller-69-grow"
channel TEXT NOT NULL, -- e.g., "temperature", "humidity", "power"
value REAL NOT NULL,
INDEX idx_sensor_data_time (timestamp),
INDEX idx_sensor_data_device (device, channel)
);
-- 10-minute aggregated data (kept for 1 month)
CREATE TABLE sensor_data_10m (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL, -- Rounded to 10-min boundary
device TEXT NOT NULL,
channel TEXT NOT NULL,
value REAL NOT NULL, -- Averaged value
sample_count INTEGER NOT NULL,
UNIQUE(timestamp, device, channel)
);
-- 1-hour aggregated data (kept forever)
CREATE TABLE sensor_data_1h (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL, -- Rounded to 1-hour boundary
device TEXT NOT NULL,
channel TEXT NOT NULL,
value REAL NOT NULL, -- Averaged value
sample_count INTEGER NOT NULL,
UNIQUE(timestamp, device, channel)
);
```
---
### Server - WebSocket Protocol
#### [NEW] [server.js](file:///home/seb/src/tischlerctrl/server/src/websocket/server.js)
**Authentication Flow:**
1. Client connects to `ws://server:8080`
2. Client sends: `{ "type": "auth", "apiKey": "xxx" }`
3. Server validates API key, responds: `{ "type": "auth", "success": true, "devicePrefix": "ac:" }`
4. Client is now authenticated and can send data
**Data Ingestion Message:**
```json
{
"type": "data",
"readings": [
{ "device": "controller-69-grow", "channel": "temperature", "value": 24.5 },
{ "device": "controller-69-grow", "channel": "humidity", "value": 65.2 }
]
}
```
Server prepends `devicePrefix` to device names and adds timestamp.
**Keepalive:**
- Server sends `ping` every 30 seconds
- Client responds with `pong`
- Connection closed after 90 seconds of no response
---
### Server - Aggregation Jobs
#### [NEW] [aggregator.js](file:///home/seb/src/tischlerctrl/server/src/jobs/aggregator.js)
Runs every 10 minutes:
1. **10-minute aggregation**:
- Select data from `sensor_data` older than 10 minutes
- Group by device, channel, and 10-minute bucket
- Calculate average, insert into `sensor_data_10m`
2. **1-hour aggregation**:
- Select data from `sensor_data_10m` older than 1 hour
- Group by device, channel, and 1-hour bucket
- Calculate weighted average, insert into `sensor_data_1h`
#### [NEW] [cleanup.js](file:///home/seb/src/tischlerctrl/server/src/jobs/cleanup.js)
Runs every hour:
- Delete from `sensor_data` where timestamp < NOW - 7 days
- Delete from `sensor_data_10m` where timestamp < NOW - 30 days
---
### AC Infinity Agent
#### [NEW] [ac-client.js](file:///home/seb/src/tischlerctrl/agents/ac-infinity/src/ac-client.js)
Port of the TypeScript AC Infinity client to JavaScript ES modules:
- `login(email, password)` Returns userId token
- `getDevicesListAll()` Returns all controllers with sensor readings
- Polling interval: 60 seconds
- Extracts: temperature, humidity, VPD (if available) per controller
**Data extraction from API response:**
```javascript
// Each device in response has:
// - devId, devName
// - devSettings.temperature (°C * 100)
// - devSettings.humidity (% * 100)
// We normalize and send to server
```
#### [NEW] [ws-client.js](file:///home/seb/src/tischlerctrl/agents/ac-infinity/src/ws-client.js)
WebSocket client with:
- Auto-reconnect with exponential backoff (1s 2s 4s ... 60s max)
- Authentication on connect
- Heartbeat response
- Message queue during disconnection
---
### Tapo Agent (Rust)
#### [NEW] [main.rs](file:///home/seb/src/tischlerctrl/agents/tapo/src/main.rs)
Uses [tapo crate](https://crates.io/crates/tapo) for P100/P110 communication.
**Features:**
- Configuration via environment variables or TOML file
- WebSocket client with tungstenite crate
- Auto-reconnect with backoff
- Polls devices every 60 seconds
**Data collected:**
| Device | Channel | Description |
|--------|---------|-------------|
| P100 | `state` | 0 = off, 1 = on |
| P110 | `state` | 0 = off, 1 = on |
| P110 | `power` | Current power in watts |
| P110 | `energy_today` | Energy used today in Wh |
**Build for Raspberry Pi:**
```bash
# Cross-compile for ARM
cross build --release --target armv7-unknown-linux-gnueabihf
# Binary: ~2MB, runs with ~8MB RAM
```
---
### Custom CLI Agent
#### [NEW] [sensor-send](file:///home/seb/src/tischlerctrl/agents/cli/sensor-send)
A shell script using `websocat` (lightweight WebSocket CLI tool):
```bash
#!/bin/bash
# Usage: sensor-send --device=mydevice --channel=temp --value=23.5
API_KEY="${SENSOR_API_KEY:-}"
SERVER="${SENSOR_SERVER:-ws://localhost:8080}"
sensor-send mydevice temperature 23.5
```
Requires: `websocat` (single binary, ~3MB, available via cargo or apt)
---
## Configuration Examples
### Server `.env`
```bash
PORT=8080
DB_PATH=./data/sensors.db
# Generate API keys via CLI: node src/cli/generate-key.js "ac-infinity" "ac:"
```
### AC Infinity Agent `.env`
```bash
SERVER_URL=ws://192.168.1.100:8080
API_KEY=your-api-key-here
AC_EMAIL=your@email.com
AC_PASSWORD=your-password
POLL_INTERVAL_MS=60000
```
### Tapo Agent `config.toml`
```toml
server_url = "ws://192.168.1.100:8080"
api_key = "your-api-key-here"
poll_interval_secs = 60
[[devices]]
ip = "192.168.1.50"
name = "grow-light-plug"
type = "P110" # or "P100"
tapo_email = "your@email.com"
tapo_password = "your-tapo-password"
```
---
## Verification Plan
### Automated Tests
1. **Server unit tests**: Database operations, aggregation logic
2. **Integration test**: Start server, connect mock agent, verify data flow
3. **Run commands**:
```bash
cd server && npm test
cd agents/ac-infinity && npm test
```
### Manual Verification
1. Start server, verify WebSocket accepts connections
2. Send test data via CLI agent, verify it appears in database
3. Wait 10+ minutes, verify aggregation runs and data appears in `sensor_data_10m`
4. Connect AC Infinity agent with real credentials, verify sensor readings
5. Deploy Tapo agent to Raspberry Pi, verify plug data collection

View File

@@ -1,120 +0,0 @@
# Setting up Nginx as a Reverse Proxy
This guide explains how to configure Nginx to act as a reverse proxy for the TischlerCtrl server. This allows you to host the application on standard HTTP/HTTPS ports (80/443) and adds a layer of security.
## Prerequisites
- A Linux server (Debian/Ubuntu/Raspberry Pi OS).
- Root or sudo access.
- TischlerCtrl server running on localhost (default port: `8080`).
## 1. Install Nginx
If Nginx is not already installed:
```bash
sudo apt update
sudo apt install nginx
```
## 2. Create Configuration File
Create a new configuration file for the site in `/etc/nginx/sites-available/`. We'll name it `tischlerctrl`.
```bash
sudo nano /etc/nginx/sites-available/tischlerctrl
```
Paste the following configuration using your actual domain name or IP address:
```nginx
server {
listen 80;
server_name your-domain.com; # Replace with your domain or IP address
# Access logs
access_log /var/log/nginx/tischlerctrl.access.log;
error_log /var/log/nginx/tischlerctrl.error.log;
location /agentapi/ {
proxy_pass http://localhost:8080/; # Trailing slash strips /agentapi/
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Forwarding real client IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Key Configuration Explained
- **proxy_pass**: Forwards requests to your Node.js application running on port 8080.
- **WebSocket Support**: These lines are **critical** for TischlerCtrl as it relies on WebSockets for real-time sensor data:
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
```
## 3. Enable the Site
Create a symbolic link to the `sites-enabled` directory to activate the configuration:
```bash
sudo ln -s /etc/nginx/sites-available/tischlerctrl /etc/nginx/sites-enabled/
```
## 4. Test and Reload Nginx
Test the configuration for syntax errors:
```bash
sudo nginx -t
```
If the test is successful (returns `syntax is ok`), reload Nginx:
```bash
sudo systemctl reload nginx
```
## 5. SSL Configuration (Recommended)
To secure your connection with HTTPS (especially important for authentication), use Certbot to automatically configure a free specific Let's Encrypt SSL certificate.
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com
```
Certbot will automatically modify your Nginx configuration to force HTTPS redirection and manage the SSL certificates.
## 6. Update Client Configurations
Since you are serving the API under `/agentapi/`, you must update your agents' configuration to point to the new URL path.
### WebSocket URL Format
- **Old (Direct):** `ws://server-ip:8080`
- **New (Proxy):** `ws://your-domain.com/agentapi/` (or `wss://` if using SSL)
### Example for Tapo Agent (`config.toml`)
```toml
server_url = "ws://your-domain.com/agentapi/"
# Or with SSL:
# server_url = "wss://your-domain.com/agentapi/"
```
### Example for Environment Variables
For agents using `.env` files:
```bash
SENSOR_SERVER="ws://your-domain.com/agentapi/"
```

View File

@@ -1,258 +0,0 @@
Sensor Data Collection System
A Node.js server that collects sensor data from multiple agents via WebSocket, stores it in SQLite with automatic data summarization and retention policies.
Architecture Overview
Custom CLI Agent
Tapo Agent (Rust)
AC Infinity Agent (Node.js)
Central Server (Node.js)
polls every 60s
WebSocket
polls every 60s
WebSocket
WebSocket
WebSocket Server :8080
SQLite Database
Aggregation Job
AC Infinity Client
AC Infinity Cloud API
Tapo Client
Tapo P100/P110
Shell Script
User Review Required
IMPORTANT
Tapo Agent Language Choice: I recommend Rust for the Tapo agent because:
Compiles to a single ~2MB static binary
Uses ~5-10MB RAM at runtime
Excellent tapo crate already exists
Easy cross-compilation for Raspberry Pi
Alternatively, I could write it in Go (would need to implement protocol from scratch) or as a Node.js agent (but you mentioned wanting it lightweight).
IMPORTANT
AC Infinity Credentials: The AC Infinity API requires email/password authentication to their cloud service. These will need to be stored in configuration.
Project Structure
tischlerctrl/
├── server/
│ ├── package.json
│ ├── src/
│ │ ├── index.js # Entry point
│ │ ├── config.js # Configuration loader
│ │ ├── db/
│ │ │ ├── schema.js # SQLite schema + migrations
│ │ │ └── queries.js # Database operations
│ │ ├── websocket/
│ │ │ ├── server.js # WebSocket server
│ │ │ └── handlers.js # Message handlers
│ │ └── jobs/
│ │ ├── aggregator.js # Data summarization job
│ │ └── cleanup.js # Data retention cleanup
│ └── data/
│ └── sensors.db # SQLite database file
├── agents/
│ ├── ac-infinity/
│ │ ├── package.json
│ │ └── src/
│ │ ├── index.js # Entry point
│ │ ├── config.js # Configuration
│ │ ├── ac-client.js # AC Infinity API client
│ │ └── ws-client.js # WebSocket client with reconnect
│ │
│ ├── tapo/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # Rust Tapo agent
│ │
│ └── cli/
│ └── sensor-send # Shell script CLI tool
├── .env.example # Example environment variables
└── README.md
Proposed Changes
Server - Database Schema
[NEW]
schema.js
SQLite tables:
-- API keys for agent authentication
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL, -- e.g., "ac-infinity-agent"
device_prefix TEXT NOT NULL, -- e.g., "ac:" or "tapo:"
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME
);
-- Raw sensor data (1-minute resolution, kept for 1 week)
CREATE TABLE sensor_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL,
device TEXT NOT NULL, -- e.g., "ac:controller-69-grow"
channel TEXT NOT NULL, -- e.g., "temperature", "humidity", "power"
value REAL NOT NULL,
INDEX idx_sensor_data_time (timestamp),
INDEX idx_sensor_data_device (device, channel)
);
-- 10-minute aggregated data (kept for 1 month)
CREATE TABLE sensor_data_10m (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL, -- Rounded to 10-min boundary
device TEXT NOT NULL,
channel TEXT NOT NULL,
value REAL NOT NULL, -- Averaged value
sample_count INTEGER NOT NULL,
UNIQUE(timestamp, device, channel)
);
-- 1-hour aggregated data (kept forever)
CREATE TABLE sensor_data_1h (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL, -- Rounded to 1-hour boundary
device TEXT NOT NULL,
channel TEXT NOT NULL,
value REAL NOT NULL, -- Averaged value
sample_count INTEGER NOT NULL,
UNIQUE(timestamp, device, channel)
);
Server - WebSocket Protocol
[NEW]
server.js
Authentication Flow:
Client connects to ws://server:8080
Client sends: { "type": "auth", "apiKey": "xxx" }
Server validates API key, responds: { "type": "auth", "success": true, "devicePrefix": "ac:" }
Client is now authenticated and can send data
Data Ingestion Message:
{
"type": "data",
"readings": [
{ "device": "controller-69-grow", "channel": "temperature", "value": 24.5 },
{ "device": "controller-69-grow", "channel": "humidity", "value": 65.2 }
]
}
Server prepends devicePrefix to device names and adds timestamp.
Keepalive:
Server sends ping every 30 seconds
Client responds with pong
Connection closed after 90 seconds of no response
Server - Aggregation Jobs
[NEW]
aggregator.js
Runs every 10 minutes:
10-minute aggregation:
Select data from sensor_data older than 10 minutes
Group by device, channel, and 10-minute bucket
Calculate average, insert into sensor_data_10m
1-hour aggregation:
Select data from sensor_data_10m older than 1 hour
Group by device, channel, and 1-hour bucket
Calculate weighted average, insert into sensor_data_1h
[NEW]
cleanup.js
Runs every hour:
Delete from sensor_data where timestamp < NOW - 7 days
Delete from sensor_data_10m where timestamp < NOW - 30 days
AC Infinity Agent
[NEW]
ac-client.js
Port of the TypeScript AC Infinity client to JavaScript ES modules:
login(email, password) → Returns userId token
getDevicesListAll() → Returns all controllers with sensor readings
Polling interval: 60 seconds
Extracts: temperature, humidity, VPD (if available) per controller
Data extraction from API response:
// Each device in response has:
// - devId, devName
// - devSettings.temperature (°C * 100)
// - devSettings.humidity (% * 100)
// We normalize and send to server
[NEW]
ws-client.js
WebSocket client with:
Auto-reconnect with exponential backoff (1s → 2s → 4s → ... → 60s max)
Authentication on connect
Heartbeat response
Message queue during disconnection
Tapo Agent (Rust)
[NEW]
main.rs
Uses tapo crate for P100/P110 communication.
Features:
Configuration via environment variables or TOML file
WebSocket client with tungstenite crate
Auto-reconnect with backoff
Polls devices every 60 seconds
Data collected:
Device Channel Description
P100 state 0 = off, 1 = on
P110 state 0 = off, 1 = on
P110 power Current power in watts
P110 energy_today Energy used today in Wh
Build for Raspberry Pi:
# Cross-compile for ARM
cross build --release --target armv7-unknown-linux-gnueabihf
# Binary: ~2MB, runs with ~8MB RAM
Custom CLI Agent
[NEW]
sensor-send
A shell script using websocat (lightweight WebSocket CLI tool):
#!/bin/bash
# Usage: sensor-send --device=mydevice --channel=temp --value=23.5
API_KEY="${SENSOR_API_KEY:-}"
SERVER="${SENSOR_SERVER:-ws://localhost:8080}"
sensor-send mydevice temperature 23.5
Requires: websocat (single binary, ~3MB, available via cargo or apt)
Configuration Examples
Server .env
PORT=8080
DB_PATH=./data/sensors.db
# Generate API keys via CLI: node src/cli/generate-key.js "ac-infinity" "ac:"
AC Infinity Agent .env
SERVER_URL=ws://192.168.1.100:8080
API_KEY=your-api-key-here
AC_EMAIL=your@email.com
AC_PASSWORD=your-password
POLL_INTERVAL_MS=60000
Tapo Agent config.toml
server_url = "ws://192.168.1.100:8080"
api_key = "your-api-key-here"
poll_interval_secs = 60
[[devices]]
ip = "192.168.1.50"
name = "grow-light-plug"
type = "P110" # or "P100"
tapo_email = "your@email.com"
tapo_password = "your-tapo-password"
Verification Plan
Automated Tests
Server unit tests: Database operations, aggregation logic
Integration test: Start server, connect mock agent, verify data flow
Run commands:
cd server && npm test
cd agents/ac-infinity && npm test
Manual Verification
Start server, verify WebSocket accepts connections
Send test data via CLI agent, verify it appears in database
Wait 10+ minutes, verify aggregation runs and data appears in sensor_data_10m
Connect AC Infinity agent with real credentials, verify sensor readings
Deploy Tapo agent to Raspberry Pi, verify plug data collection

14
uiserver/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Database path (default: ../server/data/sensors.db)
DB_PATH=
# JWT secret for authentication (CHANGE IN PRODUCTION!)
JWT_SECRET=your-secret-key-here
# WebSocket port for agent connections (default: 3962)
WS_PORT=3962
# Webpack dev server port (default: 3905)
DEV_SERVER_PORT=3905
# Rule runner interval in milliseconds (default: 10000 = 10s)
RULE_RUNNER_INTERVAL=10000

28
uiserver/api/auth.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* Auth API - Login endpoint
*/
module.exports = function setupAuthApi(app, { db, bcrypt, jwt, JWT_SECRET }) {
// POST /api/login
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
try {
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
const user = stmt.get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({
id: user.id,
username: user.username,
role: user.role
}, JWT_SECRET, { expiresIn: '24h' });
res.json({ token, role: user.role, username: user.username });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
};

26
uiserver/api/devices.js Normal file
View File

@@ -0,0 +1,26 @@
/**
* Devices API - List unique device/channel pairs
*/
module.exports = function setupDevicesApi(app, { db, getOutputChannels }) {
// GET /api/devices - Returns list of unique device/channel pairs (sensors + outputs)
app.get('/api/devices', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
// Get sensor channels
const sensorStmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel");
const sensorRows = sensorStmt.all();
// Add output channels with 'output' as device
const outputChannels = getOutputChannels();
const outputRows = outputChannels.map(ch => ({
device: 'output',
channel: ch.channel
}));
res.json([...sensorRows, ...outputRows]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
};

45
uiserver/api/index.js Normal file
View File

@@ -0,0 +1,45 @@
/**
* API Routes Index - Sets up all API endpoints
*/
const setupAuthApi = require('./auth');
const setupViewsApi = require('./views');
const setupRulesApi = require('./rules');
const setupOutputsApi = require('./outputs');
const setupOutputConfigApi = require('./output-config');
const setupDevicesApi = require('./devices');
const setupReadingsApi = require('./readings');
module.exports = function setupAllApis(app, context) {
const { db, bcrypt, jwt, JWT_SECRET, getOutputChannels, getOutputBindings, runRules, activeRuleIds } = context;
// Auth middleware helpers
const checkAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(' ')[1];
jwt.verify(token, JWT_SECRET, (err, user) => {
if (user) req.user = user;
next();
});
} else {
next();
}
};
const requireAdmin = (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
// Setup all API routes
setupAuthApi(app, { db, bcrypt, jwt, JWT_SECRET });
setupViewsApi(app, { db, checkAuth, requireAdmin });
setupRulesApi(app, { db, checkAuth, requireAdmin, runRules, activeRuleIds });
setupOutputConfigApi(app, { db, checkAuth, requireAdmin });
setupOutputsApi(app, { db, getOutputChannels, getOutputBindings });
setupDevicesApi(app, { db, getOutputChannels });
setupReadingsApi(app, { db });
};

View File

@@ -0,0 +1,162 @@
/**
* Output Config API - CRUD for output channel configurations
*/
module.exports = function setupOutputConfigApi(app, { db, checkAuth, requireAdmin }) {
// Apply checkAuth middleware to output config routes
app.use('/api/output-configs', checkAuth);
// GET /api/output-configs - List all output configs
app.get('/api/output-configs', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const stmt = db.prepare('SELECT * FROM output_configs ORDER BY position ASC');
const rows = stmt.all();
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/output-configs - Create new output config (admin only)
app.post('/api/output-configs', requireAdmin, (req, res) => {
const { channel, description, value_type, min_value, max_value, device, device_channel } = req.body;
if (!channel || !value_type) {
return res.status(400).json({ error: 'Missing required fields: channel, value_type' });
}
try {
// Get max position
const maxPos = db.prepare('SELECT MAX(position) as max FROM output_configs').get();
const position = (maxPos.max ?? -1) + 1;
const stmt = db.prepare(`
INSERT INTO output_configs (channel, description, value_type, min_value, max_value, device, device_channel, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
channel,
description || '',
value_type,
min_value ?? 0,
max_value ?? 1,
device || null,
device_channel || null,
position
);
global.insertChangelog(req.user?.username || 'admin', `Created output config "${channel}"`);
res.json({
id: info.lastInsertRowid,
channel,
description,
value_type,
min_value: min_value ?? 0,
max_value: max_value ?? 1,
device,
device_channel,
position
});
} catch (err) {
if (err.message.includes('UNIQUE constraint')) {
return res.status(400).json({ error: 'Channel name already exists' });
}
res.status(500).json({ error: err.message });
}
});
// PUT /api/output-configs/:id - Update output config (admin only)
app.put('/api/output-configs/:id', requireAdmin, (req, res) => {
const { channel, description, value_type, min_value, max_value, device, device_channel } = req.body;
try {
const oldConfig = db.prepare('SELECT * FROM output_configs WHERE id = ?').get(req.params.id);
if (!oldConfig) {
return res.status(404).json({ error: 'Output config not found' });
}
const stmt = db.prepare(`
UPDATE output_configs
SET channel = ?, description = ?, value_type = ?, min_value = ?, max_value = ?, device = ?, device_channel = ?
WHERE id = ?
`);
const info = stmt.run(
channel ?? oldConfig.channel,
description ?? oldConfig.description,
value_type ?? oldConfig.value_type,
min_value ?? oldConfig.min_value,
max_value ?? oldConfig.max_value,
device ?? oldConfig.device,
device_channel ?? oldConfig.device_channel,
req.params.id
);
if (info.changes > 0) {
const changes = [];
if (oldConfig.channel !== channel) changes.push(`channel: ${oldConfig.channel}${channel}`);
if (oldConfig.device !== device) changes.push(`device: ${oldConfig.device || 'none'}${device || 'none'}`);
if (oldConfig.device_channel !== device_channel) changes.push(`device_channel: ${oldConfig.device_channel || 'none'}${device_channel || 'none'}`);
const changeText = changes.length > 0
? `Updated output config "${channel}": ${changes.join(', ')}`
: `Updated output config "${channel}"`;
global.insertChangelog(req.user?.username || 'admin', changeText);
res.json({ success: true, id: req.params.id });
} else {
res.status(404).json({ error: 'Output config not found' });
}
} catch (err) {
if (err.message.includes('UNIQUE constraint')) {
return res.status(400).json({ error: 'Channel name already exists' });
}
res.status(500).json({ error: err.message });
}
});
// DELETE /api/output-configs/:id - Delete output config (admin only)
app.delete('/api/output-configs/:id', requireAdmin, (req, res) => {
try {
const config = db.prepare('SELECT channel FROM output_configs WHERE id = ?').get(req.params.id);
if (!config) {
return res.status(404).json({ error: 'Output config not found' });
}
const stmt = db.prepare('DELETE FROM output_configs WHERE id = ?');
const info = stmt.run(req.params.id);
if (info.changes > 0) {
global.insertChangelog(req.user?.username || 'admin', `Deleted output config "${config.channel}"`);
res.json({ success: true });
} else {
res.status(404).json({ error: 'Output config not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/output-configs/reorder - Reorder output configs (admin only)
app.post('/api/output-configs/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) {
return res.status(400).json({ error: 'Invalid format' });
}
const updateStmt = db.prepare('UPDATE output_configs SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
};

75
uiserver/api/outputs.js Normal file
View File

@@ -0,0 +1,75 @@
/**
* Outputs API - Output channel definitions and values
*/
module.exports = function setupOutputsApi(app, { db, getOutputChannels, getOutputBindings }) {
// GET /api/outputs - List output channel definitions
app.get('/api/outputs', (req, res) => {
res.json(getOutputChannels());
});
// GET /api/outputs/values - Get current output values
app.get('/api/outputs/values', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const result = {};
const stmt = db.prepare(`
SELECT channel, value FROM output_events
WHERE id IN (
SELECT MAX(id) FROM output_events GROUP BY channel
)
`);
const rows = stmt.all();
rows.forEach(row => {
result[row.channel] = row.value;
});
// Fill in defaults for missing channels
const outputChannels = getOutputChannels();
outputChannels.forEach(ch => {
if (result[ch.channel] === undefined) {
result[ch.channel] = 0;
}
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/outputs/commands - Get desired states for bound devices
// Agents poll this to get commands. Returns { "device:channel": { state: 0|1 } }
app.get('/api/outputs/commands', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
// Get current output values
const stmt = db.prepare(`
SELECT channel, value FROM output_events
WHERE id IN (
SELECT MAX(id) FROM output_events GROUP BY channel
)
`);
const rows = stmt.all();
const outputValues = {};
rows.forEach(row => {
outputValues[row.channel] = row.value;
});
// Map to device commands
const bindings = getOutputBindings();
const commands = {};
for (const [outputChannel, binding] of Object.entries(bindings)) {
const value = outputValues[outputChannel] ?? 0;
const deviceKey = `${binding.device}:${binding.channel}`;
commands[deviceKey] = {
state: value > 0 ? 1 : 0,
source: outputChannel
};
}
res.json(commands);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
};

124
uiserver/api/readings.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* Readings API - Sensor and output data for charts
*/
module.exports = function setupReadingsApi(app, { db }) {
// GET /api/readings
// Query params: since, until, selection (comma-separated device:channel pairs)
app.get('/api/readings', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const { since, until } = req.query;
const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const endTime = until || new Date().toISOString();
const requestedSensorChannels = []; // [{device, channel}]
const requestedOutputChannels = []; // [channel]
if (req.query.selection) {
const selections = req.query.selection.split(',');
selections.forEach(s => {
const lastColonIndex = s.lastIndexOf(':');
if (lastColonIndex !== -1) {
const d = s.substring(0, lastColonIndex);
const c = s.substring(lastColonIndex + 1);
if (d === 'output') {
requestedOutputChannels.push(c);
} else {
requestedSensorChannels.push({ device: d, channel: c });
}
}
});
}
const result = {};
// 1. Fetch sensor data
if (requestedSensorChannels.length > 0) {
let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? ';
const params = [startTime, endTime];
const placeholders = [];
requestedSensorChannels.forEach(ch => {
placeholders.push('(device = ? AND channel = ?)');
params.push(ch.device, ch.channel);
});
if (placeholders.length > 0) {
sql += `AND (${placeholders.join(' OR ')}) `;
}
sql += 'ORDER BY timestamp ASC';
const rows = db.prepare(sql).all(...params);
// Backfill for sensors
const backfillStmt = db.prepare(`
SELECT * FROM sensor_events
WHERE device = ? AND channel = ?
AND timestamp <= ?
AND (until >= ? OR until IS NULL)
ORDER BY timestamp DESC LIMIT 1
`);
const backfillRows = [];
requestedSensorChannels.forEach(ch => {
const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime);
if (prev) backfillRows.push(prev);
});
[...backfillRows, ...rows].forEach(row => {
const key = `${row.device}:${row.channel}`;
if (!result[key]) result[key] = [];
const pt = [row.timestamp, row.value];
if (row.until) pt.push(row.until);
result[key].push(pt);
});
}
// 2. Fetch output data
if (requestedOutputChannels.length > 0) {
let sql = 'SELECT * FROM output_events WHERE timestamp > ? AND timestamp <= ? ';
const params = [startTime, endTime];
const placeholders = requestedOutputChannels.map(() => 'channel = ?');
sql += `AND (${placeholders.join(' OR ')}) `;
params.push(...requestedOutputChannels);
sql += 'ORDER BY timestamp ASC';
const rows = db.prepare(sql).all(...params);
// Backfill for outputs
const backfillStmt = db.prepare(`
SELECT * FROM output_events
WHERE channel = ?
AND timestamp <= ?
AND (until >= ? OR until IS NULL)
ORDER BY timestamp DESC LIMIT 1
`);
const backfillRows = [];
requestedOutputChannels.forEach(ch => {
const prev = backfillStmt.get(ch, startTime, startTime);
if (prev) {
backfillRows.push(prev);
} else {
// No data at all - add default 0 value at startTime
backfillRows.push({ channel: ch, timestamp: startTime, value: 0, until: null });
}
});
[...backfillRows, ...rows].forEach(row => {
const key = `output:${row.channel}`;
if (!result[key]) result[key] = [];
const pt = [row.timestamp, row.value];
if (row.until) pt.push(row.until);
result[key].push(pt);
});
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
};

166
uiserver/api/rules.js Normal file
View File

@@ -0,0 +1,166 @@
/**
* Rules API - CRUD for automation rules
*/
module.exports = function setupRulesApi(app, { db, checkAuth, requireAdmin, runRules, activeRuleIds }) {
// Apply checkAuth middleware to rules routes
app.use('/api/rules', checkAuth);
// GET /api/rules/status - Get currently active rule IDs
app.get('/api/rules/status', (req, res) => {
res.json({ activeIds: Array.from(activeRuleIds) });
});
// GET /api/rules - List all rules
app.get('/api/rules', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC');
const rows = stmt.all();
const rules = rows.map(row => ({
...row,
conditions: JSON.parse(row.conditions || '{}'),
action: JSON.parse(row.action || '{}')
}));
res.json(rules);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules - Create rule (admin only)
app.post('/api/rules', requireAdmin, (req, res) => {
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
if (!name || !conditions || !action) {
return res.status(400).json({ error: 'Missing required fields: name, conditions, action' });
}
try {
const stmt = db.prepare(`
INSERT INTO rules (name, type, enabled, conditions, action, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
name,
type,
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.user?.id || null
);
runRules(); // Trigger rules immediately
global.insertChangelog(req.user?.username || 'admin', `Created rule "${name}"`);
res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/rules/:id - Update rule (admin only)
app.put('/api/rules/:id', requireAdmin, (req, res) => {
const { name, type, enabled, conditions, action } = req.body;
try {
// Get old rule for comparison
const oldRule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id);
if (!oldRule) {
return res.status(404).json({ error: 'Rule not found' });
}
const stmt = db.prepare(`
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
WHERE id = ?
`);
const info = stmt.run(
name,
type || 'static',
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.params.id
);
if (info.changes > 0) {
runRules(); // Trigger rules immediately
// Build detailed changelog
const changes = [];
if (oldRule.name !== name) {
changes.push(`name: "${oldRule.name}" → "${name}"`);
}
if (!!oldRule.enabled !== !!enabled) {
changes.push(`enabled: ${oldRule.enabled ? 'on' : 'off'}${enabled ? 'on' : 'off'}`);
}
const oldConditions = oldRule.conditions || '{}';
const newConditions = JSON.stringify(conditions);
if (oldConditions !== newConditions) {
changes.push('conditions changed');
}
const oldAction = oldRule.action || '{}';
const newAction = JSON.stringify(action);
if (oldAction !== newAction) {
try {
const oldA = JSON.parse(oldAction);
const newA = action;
if (oldA.channel !== newA.channel) {
changes.push(`action channel: ${oldA.channel}${newA.channel}`);
}
if (JSON.stringify(oldA.value) !== JSON.stringify(newA.value)) {
changes.push(`action value: ${JSON.stringify(oldA.value)}${JSON.stringify(newA.value)}`);
}
} catch (e) {
changes.push('action changed');
}
}
const changeText = changes.length > 0
? `Updated rule "${name}": ${changes.join(', ')}`
: `Updated rule "${name}" (no changes)`;
global.insertChangelog(req.user?.username || 'admin', changeText);
res.json({ id: req.params.id, name, type, enabled, conditions, action });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/rules/:id - Delete rule (admin only)
app.delete('/api/rules/:id', requireAdmin, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM rules WHERE id = ?');
const ruleName = db.prepare('SELECT name FROM rules WHERE id = ?').get(req.params.id)?.name || 'Unknown Rule';
const info = stmt.run(req.params.id);
if (info.changes > 0) {
runRules(); // Trigger rules immediately
global.insertChangelog(req.user?.username || 'admin', `Deleted rule "${ruleName}" (ID: ${req.params.id})`);
res.json({ success: true });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules/reorder - Reorder rules (admin only)
app.post('/api/rules/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
runRules(); // Trigger rules immediately
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
};

186
uiserver/api/views.js Normal file
View File

@@ -0,0 +1,186 @@
/**
* Views API - CRUD for dashboard views
*/
module.exports = function setupViewsApi(app, { db, checkAuth, requireAdmin }) {
// Apply checkAuth middleware to views routes
app.use('/api/views', checkAuth);
// POST /api/views - Create view (admin only)
app.post('/api/views', requireAdmin, (req, res) => {
const { name, config } = req.body;
try {
const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)');
const info = stmt.run(name, JSON.stringify(config), req.user.id);
global.insertChangelog(req.user.username, `Created view "${name}"`);
res.json({ id: info.lastInsertRowid, name, config });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/views - List all views (public)
app.get('/api/views', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
const rows = stmt.all();
const views = rows.map(row => {
try {
return { ...row, config: JSON.parse(row.config) };
} catch (e) {
return row;
}
});
res.json(views);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/views/:id - Get single view
app.get('/api/views/:id', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM views WHERE id = ?');
const view = stmt.get(req.params.id);
if (view) {
view.config = JSON.parse(view.config);
res.json(view);
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/views/:id - Delete view (admin only)
app.delete('/api/views/:id', requireAdmin, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM views WHERE id = ?');
const viewName = db.prepare('SELECT name FROM views WHERE id = ?').get(req.params.id)?.name || 'Unknown View';
const info = stmt.run(req.params.id);
if (info.changes > 0) {
global.insertChangelog(req.user.username, `Deleted view "${viewName}" (ID: ${req.params.id})`);
res.json({ success: true });
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/views/:id - Update view (admin only)
app.put('/api/views/:id', requireAdmin, (req, res) => {
const { name, config } = req.body;
try {
// Get old view for comparison
const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id);
if (!oldView) {
return res.status(404).json({ error: 'View not found' });
}
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
const info = stmt.run(name, JSON.stringify(config), req.params.id);
if (info.changes > 0) {
// Build detailed changelog
const changes = [];
// Check name change
if (oldView.name !== name) {
changes.push(`renamed: "${oldView.name}" → "${name}"`);
}
// Parse configs for comparison
let oldConfig = {};
try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { }
const newConfig = config || {};
// Compare channels
const oldChannels = (oldConfig.channels || []).map(ch =>
typeof ch === 'string' ? ch : ch.channel
);
const newChannels = (newConfig.channels || []).map(ch =>
typeof ch === 'string' ? ch : ch.channel
);
const added = newChannels.filter(ch => !oldChannels.includes(ch));
const removed = oldChannels.filter(ch => !newChannels.includes(ch));
if (added.length > 0) {
changes.push(`added channels: ${added.join(', ')}`);
}
if (removed.length > 0) {
changes.push(`removed channels: ${removed.join(', ')}`);
}
// Check for color/fill changes
const oldChannelConfigs = {};
(oldConfig.channels || []).forEach(ch => {
if (typeof ch === 'object') {
oldChannelConfigs[ch.channel] = ch;
}
});
const newChannelConfigs = {};
(newConfig.channels || []).forEach(ch => {
if (typeof ch === 'object') {
newChannelConfigs[ch.channel] = ch;
}
});
const colorChanges = [];
for (const ch of newChannels) {
const oldCh = oldChannelConfigs[ch] || {};
const newCh = newChannelConfigs[ch] || {};
if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) {
colorChanges.push(ch.split(':').pop());
}
}
if (colorChanges.length > 0) {
changes.push(`colors changed for: ${colorChanges.join(', ')}`);
}
// Check order change
if (added.length === 0 && removed.length === 0 &&
JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) {
changes.push('channel order changed');
}
const changeText = changes.length > 0
? `Updated view "${name}": ${changes.join('; ')}`
: `Updated view "${name}" (no significant changes)`;
global.insertChangelog(req.user.username, changeText);
res.json({ id: req.params.id, name, config });
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/views/reorder - Reorder views (admin only)
app.post('/api/views/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
console.log('[API] Reorder request:', order);
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
console.log('[API] Updating view', item.id, 'to position', item.position);
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
console.log('[API] Reorder successful');
res.json({ success: true });
} catch (err) {
console.error('[API] Reorder error:', err);
res.status(500).json({ error: err.message });
}
});
};

17
uiserver/debug_db.js Normal file
View File

@@ -0,0 +1,17 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.resolve(__dirname, '../server/data/sensors.db');
const db = new Database(dbPath, { readonly: true });
console.log('--- RULES ---');
const rules = db.prepare('SELECT * FROM rules').all();
console.log(JSON.stringify(rules, null, 2));
console.log('\n--- OUTPUT CHANNELS ---');
const outputs = db.prepare("SELECT * FROM output_events WHERE channel = 'CircFanLevel' ORDER BY timestamp DESC LIMIT 10").all();
console.table(outputs);
console.log('\n--- SENSOR DATA (ac:tent:temperature) ---');
const sensors = db.prepare("SELECT * FROM sensor_events WHERE device = 'ac' AND channel = 'tent:temperature' ORDER BY timestamp DESC LIMIT 5").all();
console.table(sensors);

View File

@@ -10,15 +10,15 @@
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.18.0",
"@mui/material": "^5.14.0",
"@mui/x-charts": "^6.0.0-alpha.0",
"@mui/icons-material": "^6.0.0",
"@mui/material": "^6.0.0",
"@mui/x-charts": "^8.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.6.0",
"dotenv": "^16.3.1",
"jsonwebtoken": "^9.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.11.0"
},
"devDependencies": {
@@ -1815,44 +1815,6 @@
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1917,32 +1879,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/@mui/base": {
"version": "5.0.0-dev.20240529-082515-213b5e33ab",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-dev.20240529-082515-213b5e33ab.tgz",
"integrity": "sha512-3ic6fc6BHstgM+MGqJEVx3zt9g5THxVXm3VVFUfdeplPqAWWgW2QoKfZDLT10s+pi+MAkpgEBP0kgRidf81Rsw==",
"deprecated": "This package has been replaced by @base-ui/react",
"node_modules/@mui/core-downloads-tracker": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz",
"integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.5.0.tgz",
"integrity": "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.6",
"@floating-ui/react-dom": "^2.0.8",
"@mui/types": "^7.2.14-dev.20240529-082515-213b5e33ab",
"@mui/utils": "^6.0.0-dev.20240529-082515-213b5e33ab",
"@popperjs/core": "^2.11.8",
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
"@babel/runtime": "^7.26.0"
},
"engines": {
"node": ">=12.0.0"
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
"@mui/material": "^6.5.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -1950,18 +1915,64 @@
}
}
},
"node_modules/@mui/base/node_modules/@mui/utils": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz",
"integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==",
"node_modules/@mui/material": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz",
"integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/core-downloads-tracker": "^6.5.0",
"@mui/system": "^6.5.0",
"@mui/types": "~7.2.24",
"@types/prop-types": "^15.7.14",
"@mui/utils": "^6.4.9",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1",
"react-is": "^19.0.0"
"react-is": "^19.0.0",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^6.5.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@mui/material-pigment-css": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/private-theming": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz",
"integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/utils": "^6.4.9",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
@@ -1980,128 +1991,21 @@
}
}
},
"node_modules/@mui/core-downloads-tracker": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz",
"integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz",
"integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^5.0.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz",
"integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/core-downloads-tracker": "^5.18.0",
"@mui/system": "^5.18.0",
"@mui/types": "~7.2.15",
"@mui/utils": "^5.17.1",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.10",
"clsx": "^2.1.0",
"csstype": "^3.1.3",
"prop-types": "^15.8.1",
"react-is": "^19.0.0",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/private-theming": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
"integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/utils": "^5.17.1",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/styled-engine": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz",
"integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz",
"integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@babel/runtime": "^7.26.0",
"@emotion/cache": "^11.13.5",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
@@ -2122,22 +2026,22 @@
}
},
"node_modules/@mui/system": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz",
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz",
"integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/private-theming": "^5.17.1",
"@mui/styled-engine": "^5.18.0",
"@mui/types": "~7.2.15",
"@mui/utils": "^5.17.1",
"clsx": "^2.1.0",
"@babel/runtime": "^7.26.0",
"@mui/private-theming": "^6.4.9",
"@mui/styled-engine": "^6.5.0",
"@mui/types": "~7.2.24",
"@mui/utils": "^6.4.9",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
@@ -2176,20 +2080,20 @@
}
},
"node_modules/@mui/utils": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz",
"integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/types": "~7.2.15",
"@types/prop-types": "^15.7.12",
"@babel/runtime": "^7.26.0",
"@mui/types": "~7.2.24",
"@types/prop-types": "^15.7.14",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.0.0"
},
"engines": {
"node": ">=12.0.0"
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
@@ -2206,20 +2110,21 @@
}
},
"node_modules/@mui/x-charts": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-6.19.8.tgz",
"integrity": "sha512-cjwsCJrUPDlMytJHBV+g3gDoSRURiphjclZs8sRnkZ+h4QbHn24K5QkK4bxEj7aCkO2HVJmDE0aqYEg4BnWCOA==",
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.23.0.tgz",
"integrity": "sha512-eYUC3ja1+0Wk7STAbEqwbRXxH6a+KFD/P+KNgyqVK1C10faRxTm/TJ/3GOh6haDaZ0AlhVGXkt7Wex5jZWVIsw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2",
"@mui/base": "^5.0.0-beta.22",
"@react-spring/rafz": "^9.7.3",
"@react-spring/web": "^9.7.3",
"clsx": "^2.0.0",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"prop-types": "^15.8.1"
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5",
"@mui/x-charts-vendor": "8.23.0",
"@mui/x-internal-gestures": "0.4.0",
"@mui/x-internals": "8.23.0",
"bezier-easing": "^2.1.0",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
@@ -2227,10 +2132,10 @@
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.4.1",
"@mui/system": "^5.4.1",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
@@ -2241,6 +2146,162 @@
}
}
},
"node_modules/@mui/x-charts-vendor": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.23.0.tgz",
"integrity": "sha512-AmGNPEFX8bTgmCuljxEcFaa2JQkUxRJKvHJYfCvy76Hexu4O1aQC15wznlKiL1nrFo3otQHw0bnozpz0PHIxWg==",
"license": "MIT AND ISC",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@types/d3-array": "^3.2.2",
"@types/d3-color": "^3.1.3",
"@types/d3-format": "^3.0.4",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-path": "^3.1.1",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.7",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/d3-timer": "^3.0.2",
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-format": "^3.1.0",
"d3-interpolate": "^3.0.1",
"d3-path": "^3.1.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"d3-timer": "^3.0.1",
"flatqueue": "^3.0.0",
"internmap": "^2.0.3"
}
},
"node_modules/@mui/x-charts/node_modules/@mui/types": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz",
"integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-charts/node_modules/@mui/utils": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz",
"integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.9",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-internal-gestures": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.4.0.tgz",
"integrity": "sha512-i0W6v9LoiNY8Yf1goOmaygtz/ncPJGBedhpDfvNg/i8BvzPwJcBaeW4rqPucJfVag9KQ8MSssBBrvYeEnrQmhw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
}
},
"node_modules/@mui/x-internals": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz",
"integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-internals/node_modules/@mui/types": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz",
"integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-internals/node_modules/@mui/utils": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz",
"integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.9",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -2251,78 +2312,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@react-spring/animated": {
"version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
"integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==",
"license": "MIT",
"dependencies": {
"@react-spring/shared": "~9.7.5",
"@react-spring/types": "~9.7.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/core": {
"version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz",
"integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==",
"license": "MIT",
"dependencies": {
"@react-spring/animated": "~9.7.5",
"@react-spring/shared": "~9.7.5",
"@react-spring/types": "~9.7.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-spring/donate"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/rafz": {
"version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz",
"integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==",
"license": "MIT"
},
"node_modules/@react-spring/shared": {
"version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz",
"integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==",
"license": "MIT",
"dependencies": {
"@react-spring/rafz": "~9.7.5",
"@react-spring/types": "~9.7.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@react-spring/types": {
"version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz",
"integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
"license": "MIT"
},
"node_modules/@react-spring/web": {
"version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz",
"integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==",
"license": "MIT",
"dependencies": {
"@react-spring/animated": "~9.7.5",
"@react-spring/core": "~9.7.5",
"@react-spring/shared": "~9.7.5",
"@react-spring/types": "~9.7.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2365,6 +2354,75 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -3110,6 +3168,12 @@
"prebuild-install": "^7.1.1"
}
},
"node_modules/bezier-easing": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3858,6 +3922,15 @@
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4579,6 +4652,12 @@
"flat": "cli.js"
}
},
"node_modules/flatqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz",
"integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==",
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -6609,28 +6688,24 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.2.3"
}
},
"node_modules/react-is": {
@@ -6845,6 +6920,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6952,13 +7033,10 @@
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/schema-utils": {
"version": "4.3.3",
@@ -7799,6 +7877,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -10,15 +10,15 @@
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.18.0",
"@mui/material": "^5.14.0",
"@mui/x-charts": "^6.0.0-alpha.0",
"@mui/icons-material": "^6.0.0",
"@mui/material": "^6.0.0",
"@mui/x-charts": "^8.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.6.0",
"dotenv": "^16.3.1",
"jsonwebtoken": "^9.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.11.0"
},
"devDependencies": {

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TischlerCtrl UI</title>
<title>CTRL Freak</title>
<style>
body {
margin: 0;

View File

@@ -1,18 +1,16 @@
import React, { Component } from 'react';
import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
import { AppBar, Toolbar, Typography, Button, Box, IconButton, CssBaseline } from '@mui/material';
import { AppBar, Toolbar, Typography, Button, Box, CssBaseline } from '@mui/material';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import SettingsIcon from '@mui/icons-material/Settings';
import ShowChartIcon from '@mui/icons-material/ShowChart';
import DashboardIcon from '@mui/icons-material/Dashboard';
import RuleIcon from '@mui/icons-material/Rule';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import Settings from './components/Settings';
import Chart from './components/Chart';
import Login from './components/Login';
import ViewManager from './components/ViewManager';
import ViewDisplay from './components/ViewDisplay';
import RuleEditor from './components/RuleEditor';
import OutputConfigEditor from './components/OutputConfigEditor';
const darkTheme = createTheme({
palette: {
@@ -34,23 +32,12 @@ export default class App extends Component {
constructor(props) {
super(props);
this.state = {
selectedChannels: [],
user: null, // { username, role, token }
loading: true
};
}
componentDidMount() {
// Load selection from local storage
const saved = localStorage.getItem('selectedChannels');
if (saved) {
try {
this.setState({ selectedChannels: JSON.parse(saved) });
} catch (e) {
console.error("Failed to parse saved channels");
}
}
// Check for existing token
const token = localStorage.getItem('authToken');
const username = localStorage.getItem('authUser');
@@ -63,11 +50,6 @@ export default class App extends Component {
this.setState({ loading: false });
}
handleSelectionChange = (newSelection) => {
this.setState({ selectedChannels: newSelection });
localStorage.setItem('selectedChannels', JSON.stringify(newSelection));
};
handleLogin = (userData) => {
this.setState({ user: userData });
localStorage.setItem('authToken', userData.token);
@@ -83,7 +65,7 @@ export default class App extends Component {
};
render() {
const { selectedChannels, user, loading } = this.state;
const { user } = this.state;
// While checking auth, we could show loader, but it's sync here mostly.
@@ -95,15 +77,16 @@ export default class App extends Component {
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
TischlerCtrl
CTRL Freak
</Typography>
<Button color="inherit" component={Link} to="/" startIcon={<DashboardIcon />}>Views</Button>
<Button color="inherit" component={Link} to="/live" startIcon={<ShowChartIcon />}>Live</Button>
{user && user.role === 'admin' && (
<>
<Button color="inherit" component={Link} to="/rules" startIcon={<RuleIcon />}>Rules</Button>
<Button color="inherit" component={Link} to="/outputs" startIcon={<SettingsInputComponentIcon />}>Outputs</Button>
</>
)}
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
{user ? (
<Button color="inherit" onClick={this.handleLogout}>Logout ({user.username})</Button>
@@ -117,17 +100,7 @@ export default class App extends Component {
<Route path="/" element={<ViewManager user={user} />} />
<Route path="/views/:id" element={<ViewDisplay />} />
<Route path="/rules" element={<RuleEditor user={user} />} />
<Route path="/live" element={
<Chart
selectedChannels={selectedChannels}
/>
} />
<Route path="/settings" element={
<Settings
selectedChannels={selectedChannels}
onSelectionChange={this.handleSelectionChange}
/>
} />
<Route path="/outputs" element={<OutputConfigEditor user={user} />} />
<Route path="/login" element={<Login onLogin={this.handleLogin} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -1,17 +1,124 @@
import React, { Component } from 'react';
import { Box, Paper, Typography, CircularProgress, IconButton } from '@mui/material';
import { LineChart } from '@mui/x-charts/LineChart';
import { useDrawingArea, useYScale, useXScale } from '@mui/x-charts/hooks';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
// Custom component to render a horizontal band between two y-values
function ReferenceArea({ yMin, yMax, color = 'rgba(76, 175, 80, 0.15)', axisId = 'left' }) {
const { left, width } = useDrawingArea();
const yScale = useYScale(axisId);
if (!yScale) return null;
const y1 = yScale(yMax);
const y2 = yScale(yMin);
if (y1 === undefined || y2 === undefined) return null;
return (
<rect
x={left}
y={Math.min(y1, y2)}
width={width}
height={Math.abs(y2 - y1)}
fill={color}
/>
);
}
// Custom component to render vertical time bands every 6 hours aligned to midnight
function TimeReferenceAreas({ axisStart, axisEnd, colors }) {
const { top, height } = useDrawingArea();
const xScale = useXScale();
if (!xScale) return null;
// Calculate 6-hour bands aligned to midnight
const SIX_HOURS = 6 * 60 * 60 * 1000;
const bands = [];
// Find the first midnight before axisStart
const startDate = new Date(axisStart);
const midnight = new Date(startDate);
midnight.setHours(0, 0, 0, 0);
// Start from that midnight
let bandStart = midnight.getTime();
while (bandStart < axisEnd) {
const bandEnd = bandStart + SIX_HOURS;
// Only render if band overlaps with visible range
if (bandEnd > axisStart && bandStart < axisEnd) {
const visibleStart = Math.max(bandStart, axisStart);
const visibleEnd = Math.min(bandEnd, axisEnd);
const x1 = xScale(new Date(visibleStart));
const x2 = xScale(new Date(visibleEnd));
if (x1 !== undefined && x2 !== undefined) {
// Determine which 6-hour block (0-3) based on hour of day
const hour = new Date(bandStart).getHours();
const blockIndex = Math.floor(hour / 6); // 0, 1, 2, or 3
const color = colors[blockIndex % colors.length];
bands.push(
<rect
key={bandStart}
x={Math.min(x1, x2)}
y={top}
width={Math.abs(x2 - x1)}
height={height}
fill={color}
/>
);
}
}
bandStart = bandEnd;
}
return <>{bands}</>;
}
// Helper function to calculate Simple Moving Average
function calculateSMA(data, channelKey, period) {
if (period <= 1 || data.length === 0) return data;
return data.map((row, i) => {
const newRow = { ...row };
const values = [];
// Look back up to 'period' samples
for (let j = Math.max(0, i - period + 1); j <= i; j++) {
const val = data[j][channelKey];
if (val !== null && val !== undefined && !isNaN(val)) {
values.push(val);
}
}
// Calculate average if we have values
if (values.length > 0) {
newRow[channelKey] = values.reduce((a, b) => a + b, 0) / values.length;
}
return newRow;
});
}
export default class Chart extends Component {
constructor(props) {
super(props);
this.state = {
data: [],
loading: true
loading: true,
hiddenSeries: {}, // { seriesId: true/false }
lastValues: {}, // { channelId: lastValue } - for detecting changes
flashStates: {} // { channelId: 'up' | 'down' | null } - for flash animation
};
this.interval = null;
this.flashTimeouts = {}; // Store timeouts to clear flash states
}
componentDidMount() {
@@ -52,6 +159,8 @@ export default class Chart extends Component {
if (this.interval) {
clearInterval(this.interval);
}
// Clear any pending flash timeouts
Object.values(this.flashTimeouts).forEach(timeout => clearTimeout(timeout));
}
getEffectiveChannels(props) {
@@ -85,6 +194,16 @@ export default class Chart extends Component {
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`)
.then(res => res.json())
.then(dataObj => {
// Safety check: ensure dataObj is a valid object
if (!dataObj || typeof dataObj !== 'object') {
console.error('Invalid data received from API:', dataObj);
this.setState({ data: [], loading: false });
return;
}
// Recalculate effective channels inside callback (closure fix)
const channelList = this.getEffectiveChannels(this.props);
// 1. Parse raw rows into intervals per channel
const intervals = [];
const timestampsSet = new Set();
@@ -92,7 +211,10 @@ export default class Chart extends Component {
// dataObj format: { "device:channel": [ [timestamp, value, until], ... ] }
Object.entries(dataObj).forEach(([id, points]) => {
// Check if this ID is in our effective/requested list
if (!effectiveChannels.includes(id)) return;
if (!channelList || !channelList.includes(id)) return;
// Skip if points is not a valid array
if (!Array.isArray(points)) return;
// Ensure sorted by time
points.sort((a, b) => new Date(a[0]) - new Date(b[0]));
@@ -114,9 +236,14 @@ export default class Chart extends Component {
// Calculate effective end
let end = explicitEnd;
// If 'until' is null, extend to next point or now
// If 'until' is null, extend to next point or now (but never beyond current time)
const nowTime = Date.now();
if (!end) {
end = nextStart || endTimeVal;
end = nextStart || Math.min(endTimeVal, nowTime);
}
// Never extend data beyond the current time
if (end > nowTime) {
end = nowTime;
}
// Strict Cutoff: Current interval cannot extend past the start of the next interval
@@ -165,10 +292,74 @@ export default class Chart extends Component {
row[inv.id] = inv.val;
}
});
// Ensure all channel values are numbers or null (MUI-X requirement)
channelList.forEach(ch => {
if (row[ch] !== null && (typeof row[ch] !== 'number' || !Number.isFinite(row[ch]))) {
row[ch] = null;
}
});
return row;
});
this.setState({ data: denseData, loading: false });
// 4. Apply SMA for channels that have it configured
const { channelConfig } = this.props;
let processedData = denseData;
if (channelConfig) {
channelConfig.forEach(cfg => {
if (cfg.sma && cfg.sma > 1) {
processedData = calculateSMA(processedData, cfg.id, cfg.sma);
}
});
}
// 5. Detect value changes for flash animation
const effectiveChannels = this.getEffectiveChannels(this.props);
const newLastValues = {};
const newFlashStates = { ...this.state.flashStates };
// Get latest value for each channel (search from end of data)
if (processedData.length > 0) {
effectiveChannels.forEach(channelId => {
// Find most recent non-null value for this channel
let newVal = null;
for (let i = processedData.length - 1; i >= 0 && newVal === null; i--) {
const val = processedData[i][channelId];
if (val !== null && val !== undefined) {
newVal = val;
}
}
if (newVal !== null) {
newLastValues[channelId] = newVal;
const oldVal = this.state.lastValues[channelId];
// Only flash if we had a previous value and it changed
if (oldVal !== undefined && oldVal !== newVal) {
const direction = newVal > oldVal ? 'up' : 'down';
newFlashStates[channelId] = direction;
console.log(`[Flash] ${channelId}: ${oldVal}${newVal} (${direction})`);
// Clear flash after 1 second
if (this.flashTimeouts[channelId]) {
clearTimeout(this.flashTimeouts[channelId]);
}
this.flashTimeouts[channelId] = setTimeout(() => {
this.setState(prev => ({
flashStates: { ...prev.flashStates, [channelId]: null }
}));
}, 1000);
}
}
});
}
this.setState({
data: processedData,
loading: false,
lastValues: newLastValues,
flashStates: newFlashStates
});
})
.catch(err => {
console.error(err);
@@ -181,7 +372,7 @@ export default class Chart extends Component {
let axisMin = Infinity;
let axisMax = -Infinity;
const axisSeries = series.filter(s => s.yAxisKey === axisKey).map(s => s.dataKey);
const axisSeries = series.filter(s => s.yAxisId === axisKey).map(s => s.dataKey);
if (axisSeries.length === 0) return {}; // No data for this axis
@@ -219,15 +410,41 @@ export default class Chart extends Component {
return { min: axisMin, max: axisMax };
}
toggleSeries = (seriesId) => {
this.setState(prev => ({
hiddenSeries: {
...prev.hiddenSeries,
[seriesId]: !prev.hiddenSeries[seriesId]
}
}));
};
render() {
const { loading, data } = this.state;
const { loading, data, hiddenSeries, flashStates } = this.state;
const { channelConfig, windowEnd, range } = this.props;
const effectiveChannels = this.getEffectiveChannels(this.props);
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
const series = effectiveChannels.map(id => {
// Build legend config (all channels, for rendering custom legend)
const legendItems = effectiveChannels.map(id => {
let label = id;
let color = '#888';
if (channelConfig) {
const item = channelConfig.find(c => c.id === id);
if (item) {
if (item.alias) label = item.alias;
if (item.color) color = item.color;
}
}
return { id, label, color, hidden: !!hiddenSeries[id] };
});
// Filter out hidden series
const visibleChannels = effectiveChannels.filter(id => !hiddenSeries[id]);
const series = visibleChannels.map(id => {
// Find alias and axis if config exists
let label = id;
let yAxisKey = 'left';
@@ -250,7 +467,7 @@ export default class Chart extends Component {
label: label,
connectNulls: true,
showMark: false,
yAxisKey: yAxisKey,
yAxisId: yAxisKey,
};
if (color) sObj.color = color;
// Enable area fill if fillColor is set (with configurable opacity)
@@ -261,16 +478,16 @@ export default class Chart extends Component {
return sObj;
});
const hasRightAxis = series.some(s => s.yAxisKey === 'right');
const hasRightAxis = series.some(s => s.yAxisId === 'right');
const leftLimits = this.computeAxisLimits('left', effectiveChannels, series);
const rightLimits = this.computeAxisLimits('right', effectiveChannels, series);
const yAxes = [
{ id: 'left', scaleType: 'linear', ...leftLimits }
{ id: 'left', ...leftLimits }
];
if (hasRightAxis) {
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits });
yAxes.push({ id: 'right', position: 'right', ...rightLimits });
}
// Calculate X-Axis Limits
@@ -278,9 +495,76 @@ export default class Chart extends Component {
const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
const axisStart = axisEnd - rangeMs;
// Determine if all visible series are Temperature channels
const isTemperatureOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
const lcId = id.toLowerCase();
return lcId.includes('temp') || lcId.includes('temperature');
});
// Determine if all visible series are Humidity channels
const isHumidityOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
const lcId = id.toLowerCase();
return lcId.includes('humid') || lcId.includes('humidity') || lcId.includes('rh');
});
// Determine if all visible series are Light channels
const isLightOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
const lcId = id.toLowerCase();
return lcId.includes('light');
});
// Colors for 6-hour time bands (midnight, 6am, noon, 6pm)
const lightBandColors = [
'rgba(0, 0, 0, 0.1)', // 00:00-06:00 - black (night)
'rgba(135, 206, 250, 0.1)', // 06:00-12:00 - light blue (morning)
'rgba(255, 255, 180, 0.1)', // 12:00-18:00 - light yellow (afternoon)
'rgba(255, 200, 150, 0.1)', // 18:00-24:00 - light orange (evening)
];
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', p: 2, boxSizing: 'border-box' }}>
<Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{/* Custom Interactive Legend */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, justifyContent: 'center', mb: 1, py: 0.5 }}>
{legendItems.map(item => {
const flash = flashStates[item.id];
const flashColor = flash === 'up' ? 'rgba(76, 175, 80, 0.4)' : flash === 'down' ? 'rgba(244, 67, 54, 0.4)' : 'transparent';
return (
<Box
key={item.id}
onClick={() => this.toggleSeries(item.id)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
opacity: item.hidden ? 0.4 : 1,
textDecoration: item.hidden ? 'line-through' : 'none',
transition: 'opacity 0.2s, background-color 0.3s',
userSelect: 'none',
backgroundColor: flashColor,
borderRadius: 1,
px: 0.5,
'&:hover': { opacity: item.hidden ? 0.6 : 0.8 },
}}
>
<Box
sx={{
width: 14,
height: 14,
borderRadius: '50%',
bgcolor: item.color,
border: '2px solid',
borderColor: item.hidden ? 'grey.500' : item.color,
}}
/>
<Typography variant="body2" component="span">
{item.label}
</Typography>
</Box>
);
})}
</Box>
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
<LineChart
dataset={data}
@@ -293,14 +577,9 @@ export default class Chart extends Component {
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}]}
yAxis={yAxes}
rightAxis={hasRightAxis ? 'right' : null}
hideLegend
slotProps={{
legend: {
direction: 'row',
position: { vertical: 'top', horizontal: 'middle' },
padding: 0,
},
lineHighlight: { strokeWidth: 3 },
}}
sx={{
'& .MuiLineElement-root': {
@@ -310,7 +589,20 @@ export default class Chart extends Component {
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5,
},
}}
/>
>
{/* Green reference band for temperature charts (20-25°C) */}
{isTemperatureOnly && (
<ReferenceArea yMin={20} yMax={25} color="rgba(76, 175, 80, 0.2)" />
)}
{/* Green reference band for humidity charts (50-70%) */}
{isHumidityOnly && (
<ReferenceArea yMin={50} yMax={70} color="rgba(76, 175, 80, 0.2)" />
)}
{/* Time-based vertical bands for light charts (6-hour intervals) */}
{isLightOnly && (
<TimeReferenceAreas axisStart={axisStart} axisEnd={axisEnd} colors={lightBandColors} />
)}
</LineChart>
</Box>
</Paper>
</Box>

View File

@@ -0,0 +1,371 @@
import React, { Component } from 'react';
import {
Container, Typography, Paper, List, ListItem, ListItemText,
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, InputLabel, Select, MenuItem, Box, IconButton,
Chip, Switch, FormControlLabel
} from '@mui/material';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import LinkIcon from '@mui/icons-material/Link';
import LinkOffIcon from '@mui/icons-material/LinkOff';
class OutputConfigEditor extends Component {
constructor(props) {
super(props);
this.state = {
configs: [],
loading: true,
error: null,
// Dialog state
open: false,
editingId: null,
channel: '',
description: '',
value_type: 'boolean',
min_value: 0,
max_value: 1,
device: '',
device_channel: ''
};
}
componentDidMount() {
this.loadConfigs();
}
isAdmin() {
const { user } = this.props;
return user && user.role === 'admin';
}
loadConfigs = async () => {
try {
const res = await fetch('/api/output-configs');
const configs = await res.json();
this.setState({ configs, loading: false });
} catch (err) {
this.setState({ error: err.message, loading: false });
}
};
handleOpenCreate = () => {
this.setState({
open: true,
editingId: null,
channel: '',
description: '',
value_type: 'boolean',
min_value: 0,
max_value: 1,
device: '',
device_channel: ''
});
};
handleOpenEdit = (config, e) => {
e.stopPropagation();
this.setState({
open: true,
editingId: config.id,
channel: config.channel,
description: config.description || '',
value_type: config.value_type,
min_value: config.min_value,
max_value: config.max_value,
device: config.device || '',
device_channel: config.device_channel || ''
});
};
handleSave = async () => {
const { editingId, channel, description, value_type, min_value, max_value, device, device_channel } = this.state;
const { user } = this.props;
if (!channel) {
alert('Channel name is required');
return;
}
const url = editingId ? `/api/output-configs/${editingId}` : '/api/output-configs';
const method = editingId ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({
channel,
description,
value_type,
min_value: parseFloat(min_value),
max_value: parseFloat(max_value),
device: device || null,
device_channel: device_channel || null
})
});
if (res.ok) {
this.setState({ open: false });
this.loadConfigs();
} else {
const err = await res.json();
alert('Failed: ' + err.error);
}
} catch (err) {
alert('Failed: ' + err.message);
}
};
handleDelete = async (id, e) => {
e.stopPropagation();
if (!window.confirm('Delete this output config?')) return;
const { user } = this.props;
try {
await fetch(`/api/output-configs/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${user.token}` }
});
this.loadConfigs();
} catch (err) {
alert('Failed to delete: ' + err.message);
}
};
moveConfig = async (idx, dir) => {
const newConfigs = [...this.state.configs];
const target = idx + dir;
if (target < 0 || target >= newConfigs.length) return;
[newConfigs[idx], newConfigs[target]] = [newConfigs[target], newConfigs[idx]];
this.setState({ configs: newConfigs });
const order = newConfigs.map((c, i) => ({ id: c.id, position: i }));
const { user } = this.props;
try {
await fetch('/api/output-configs/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.token}`
},
body: JSON.stringify({ order })
});
} catch (err) {
console.error('Failed to save order', err);
}
};
render() {
const { configs, loading, error, open, editingId, channel, description, value_type, min_value, max_value, device, device_channel } = this.state;
const isAdmin = this.isAdmin();
if (loading) return <Container sx={{ mt: 4 }}><Typography>Loading...</Typography></Container>;
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
return (
<Container maxWidth="lg" sx={{ mt: 4 }}>
<Paper sx={{ p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h5">
<SettingsInputComponentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Output Configuration
</Typography>
{isAdmin && (
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
Add Output
</Button>
)}
</Paper>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Output Channels</Typography>
<List>
{configs.map((config, idx) => (
<ListItem
key={config.id}
sx={{
borderRadius: 1,
mb: 1,
border: '1px solid #504945',
bgcolor: config.device ? 'rgba(131, 165, 152, 0.1)' : 'transparent'
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{config.channel}
</Typography>
<Chip
size="small"
label={config.value_type}
color={config.value_type === 'boolean' ? 'default' : 'info'}
/>
{config.device ? (
<Chip
size="small"
icon={<LinkIcon />}
label={`${config.device}:${config.device_channel}`}
color="success"
variant="outlined"
/>
) : (
<Chip
size="small"
icon={<LinkOffIcon />}
label="unbound"
color="warning"
variant="outlined"
/>
)}
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{config.description || 'No description'}
</Typography>
{config.value_type === 'number' && (
<Typography variant="body2" color="text.secondary">
Range: {config.min_value} - {config.max_value}
</Typography>
)}
</Box>
}
/>
{isAdmin && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton size="small" onClick={() => this.moveConfig(idx, -1)} disabled={idx === 0}>
<ArrowUpwardIcon />
</IconButton>
<IconButton size="small" onClick={() => this.moveConfig(idx, 1)} disabled={idx === configs.length - 1}>
<ArrowDownwardIcon />
</IconButton>
<IconButton onClick={(e) => this.handleOpenEdit(config, e)}>
<EditIcon />
</IconButton>
<IconButton color="error" onClick={(e) => this.handleDelete(config.id, e)}>
<DeleteIcon />
</IconButton>
</Box>
)}
</ListItem>
))}
{configs.length === 0 && (
<Typography color="text.secondary" sx={{ p: 2 }}>
No output channels defined. {isAdmin ? 'Click "Add Output" to create one.' : ''}
</Typography>
)}
</List>
</Paper>
{/* Edit/Create Dialog */}
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="sm" fullWidth>
<DialogTitle>{editingId ? 'Edit Output Config' : 'Add Output Config'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Channel Name"
value={channel}
onChange={e => this.setState({ channel: e.target.value })}
fullWidth
placeholder="e.g., CircFanLevel"
/>
<TextField
label="Description"
value={description}
onChange={e => this.setState({ description: e.target.value })}
fullWidth
placeholder="e.g., Circulation Fan Level"
/>
<FormControl fullWidth>
<InputLabel>Value Type</InputLabel>
<Select
value={value_type}
label="Value Type"
onChange={e => {
const newType = e.target.value;
// Auto-select compatible device: number->ac, boolean->tapo
const newDevice = device ? (newType === 'number' ? 'ac' : 'tapo') : '';
this.setState({
value_type: newType,
min_value: 0,
max_value: newType === 'boolean' ? 1 : 10,
device: newDevice
});
}}
>
<MenuItem value="boolean">Boolean (on/off)</MenuItem>
<MenuItem value="number">Number (0-10 range)</MenuItem>
</Select>
</FormControl>
{value_type === 'number' && (
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Min Value"
type="number"
value={min_value}
onChange={e => this.setState({ min_value: e.target.value })}
sx={{ flex: 1 }}
/>
<TextField
label="Max Value"
type="number"
value={max_value}
onChange={e => this.setState({ max_value: e.target.value })}
sx={{ flex: 1 }}
/>
</Box>
)}
<Typography variant="subtitle2" sx={{ mt: 2 }}>Device Binding (Optional)</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<FormControl sx={{ flex: 1 }}>
<InputLabel>Device</InputLabel>
<Select
value={device}
label="Device"
onChange={e => this.setState({ device: e.target.value })}
>
<MenuItem value=""><em>Not bound</em></MenuItem>
{value_type === 'boolean' && <MenuItem value="tapo">tapo (Switch)</MenuItem>}
{value_type === 'number' && <MenuItem value="ac">ac (Level)</MenuItem>}
</Select>
</FormControl>
<TextField
label="Device Channel"
value={device_channel}
onChange={e => this.setState({ device_channel: e.target.value })}
sx={{ flex: 1 }}
placeholder={value_type === 'number' ? 'e.g., tent:fan' : 'e.g., r0, c'}
disabled={!device}
/>
</Box>
{device && (
<Typography variant="caption" color="text.secondary">
Binding type: {device === 'ac' ? 'Level (0-10)' : 'Switch (on/off)'}
</Typography>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
<Button variant="contained" onClick={this.handleSave}>Save</Button>
</DialogActions>
</Dialog>
</Container>
);
}
}
export default OutputConfigEditor;

View File

@@ -418,19 +418,86 @@ class RuleEditor extends Component {
sx={{ width: 140 }}
/>
</>
) : (
cond.type === 'sensor' ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Dynamic Target Toggle */}
<Tooltip title="Compare to Value or Another Sensor">
<Chip
label={cond.value?.type === 'dynamic' ? 'Sensor' : 'Value'}
size="small"
color={cond.value?.type === 'dynamic' ? 'secondary' : 'default'}
onClick={this.isAdmin() ? () => {
const isDynamic = cond.value?.type === 'dynamic';
this.updateCondition(condPath, {
value: isDynamic
? 0 // Switch to static
: { type: 'dynamic', channel: '', factor: 1, offset: 0 } // Switch to dynamic
});
} : undefined}
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default', minWidth: 60 }}
/>
</Tooltip>
{cond.value?.type === 'dynamic' ? (
<>
<Select
size="small"
value={cond.value.channel || ''}
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, channel: e.target.value } })}
disabled={!this.isAdmin()}
displayEmpty
sx={{ minWidth: 150 }}
>
<MenuItem value=""><em>Target Sensor</em></MenuItem>
{sensorChannels.map(ch => <MenuItem key={ch} value={ch}>{ch}</MenuItem>)}
</Select>
<Typography>*</Typography>
<TextField
size="small"
label="Factor"
type="number"
value={cond.value.factor}
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, factor: parseFloat(e.target.value) || 0 } })}
disabled={!this.isAdmin()}
sx={{ width: 70 }}
/>
<Typography>+</Typography>
<TextField
size="small"
label="Offset"
type="number"
value={cond.value.offset}
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, offset: parseFloat(e.target.value) || 0 } })}
disabled={!this.isAdmin()}
sx={{ width: 70 }}
/>
</>
) : (
<TextField
size="small"
type="number"
value={cond.value ?? ''}
onChange={e => this.updateCondition(condPath, { value: parseFloat(e.target.value) || 0 })}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
)}
</Box>
) : (
<TextField
size="small"
type={cond.type === 'time' ? 'time' : (cond.type === 'date' ? 'date' : 'number')}
value={cond.value ?? ''}
onChange={e => this.updateCondition(condPath, {
value: cond.type === 'sensor' || cond.type === 'output'
value: cond.type === 'output'
? parseFloat(e.target.value) || 0
: e.target.value
})}
disabled={!this.isAdmin()}
sx={{ width: 140 }}
/>
)
)}
{this.isAdmin() && (
@@ -468,6 +535,12 @@ class RuleEditor extends Component {
: `date ${operator} ${value}`;
break;
case 'sensor':
if (value && value.type === 'dynamic') {
formatted = `${channel} ${operator} (${value.channel} * ${value.factor} + ${value.offset})`;
} else {
formatted = `${channel || '?'} ${operator} ${value}`;
}
break;
case 'output':
formatted = `${channel || '?'} ${operator} ${value}`;
break;
@@ -547,7 +620,11 @@ class RuleEditor extends Component {
When: {this.formatConditionSummary(rule.conditions)}
</Typography>
<Typography variant="body2" color="text.secondary">
Then: Set {rule.action?.channel} = {rule.action?.value}
Then: Set {rule.action?.channel} = {
rule.action?.value?.type === 'calculated'
? `(${rule.action.value.sensorA} - ${rule.action.value.sensorB || '0'}) * ${rule.action.value.factor} + ${rule.action.value.offset}`
: rule.action?.value
}
</Typography>
</Box>
}
@@ -609,8 +686,29 @@ class RuleEditor extends Component {
<Divider sx={{ my: 2 }} />
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1" gutterBottom>Action (Then)</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Value Type Toggle */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">Value Type:</Typography>
<Chip
label={action.value?.type === 'calculated' ? 'Calculated' : 'Static'}
color={action.value?.type === 'calculated' ? 'secondary' : 'default'}
onClick={() => this.setState({
action: {
...action,
value: action.value?.type === 'calculated'
? 0 // Reset to static
: { type: 'calculated', sensorA: '', sensorB: '', factor: 1, offset: 0 }
}
})}
sx={{ cursor: 'pointer' }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography>Set</Typography>
<Select
size="small"
@@ -624,7 +722,77 @@ class RuleEditor extends Component {
</MenuItem>
))}
</Select>
<Typography>to</Typography>
<Typography>=</Typography>
{action.value?.type === 'calculated' ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1, border: '1px solid #444', borderRadius: 1 }}>
<Typography>(</Typography>
<Select
size="small"
value={action.value.sensorA || ''}
onChange={e => this.setState({
action: {
...action,
value: { ...action.value, sensorA: e.target.value }
}
})}
displayEmpty
sx={{ minWidth: 150 }}
>
<MenuItem value=""><em>Sensor A</em></MenuItem>
{this.state.devices.map(d => `${d.device}:${d.channel}`).map(ch => (
<MenuItem key={ch} value={ch}>{ch}</MenuItem>
))}
</Select>
<Typography>-</Typography>
<Select
size="small"
value={action.value.sensorB || ''}
onChange={e => this.setState({
action: {
...action,
value: { ...action.value, sensorB: e.target.value }
}
})}
displayEmpty
sx={{ minWidth: 150 }}
>
<MenuItem value=""><em>Sensor B (0)</em></MenuItem>
{this.state.devices.map(d => `${d.device}:${d.channel}`).map(ch => (
<MenuItem key={ch} value={ch}>{ch}</MenuItem>
))}
</Select>
<Typography>)</Typography>
<Typography>*</Typography>
<TextField
size="small"
type="number"
label="Factor"
value={action.value.factor}
onChange={e => this.setState({
action: {
...action,
value: { ...action.value, factor: parseFloat(e.target.value) || 0 }
}
})}
sx={{ width: 80 }}
/>
<Typography>+</Typography>
<TextField
size="small"
type="number"
label="Offset"
value={action.value.offset}
onChange={e => this.setState({
action: {
...action,
value: { ...action.value, offset: parseFloat(e.target.value) || 0 }
}
})}
sx={{ width: 80 }}
/>
</Box>
) : (
<TextField
size="small"
type="number"
@@ -636,6 +804,8 @@ class RuleEditor extends Component {
}}
sx={{ width: 100 }}
/>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>

View File

@@ -24,6 +24,14 @@ const RANGES = {
'3m': 90 * 24 * 60 * 60 * 1000,
};
const SMA_OPTIONS = [
{ value: 0, label: 'Off' },
{ value: 3, label: '3' },
{ value: 5, label: '5' },
{ value: 10, label: '10' },
{ value: 15, label: '15' },
];
const GRUVBOX_COLORS = [
'#cc241d', '#fb4934', // Red
'#98971a', '#b8bb26', // Green
@@ -43,6 +51,7 @@ class ViewManager extends Component {
this.state = {
views: [],
rules: [],
activeRuleIds: [],
outputValues: {},
open: false,
colorPickerOpen: false,
@@ -73,11 +82,13 @@ class ViewManager extends Component {
this.refreshViews();
this.loadRules();
this.loadOutputValues();
this.loadRuleStatus(); // Load immediately on mount
// Refresh rules and outputs every 30s
this.rulesInterval = setInterval(() => {
this.loadRules();
this.loadOutputValues();
}, 30000);
this.loadRuleStatus();
}, 5000);
if (this.isAdmin()) {
fetch('/api/devices')
.then(res => res.json())
@@ -128,6 +139,13 @@ class ViewManager extends Component {
.catch(console.error);
};
loadRuleStatus = () => {
fetch('/api/rules/status')
.then(res => res.json())
.then(data => this.setState({ activeRuleIds: data.activeIds || [] }))
.catch(console.error);
};
parseViewData(view) {
let channels = [];
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
@@ -160,62 +178,130 @@ class ViewManager extends Component {
// Emoji for rule based on action channel
getRuleEmoji = (rule) => {
const channel = rule.action?.channel || '';
const emojis = {
'CircFanLevel': '🌀',
'CO2Valve': '🫧',
'BigDehumid': '💧',
'TentExhaust': '💨'
};
return emojis[channel] || '⚡';
return '';
};
// Format conditions for display
formatRuleConditions = (condition) => {
if (!condition) return '(always)';
// Format conditions for display - returns React components with visual grouping
formatRuleConditions = (condition, depth = 0) => {
if (!condition) return <span style={{ color: '#888' }}>(always)</span>;
if (condition.operator === 'AND' || condition.operator === 'OR') {
const parts = (condition.conditions || []).map(c => this.formatRuleConditions(c)).filter(Boolean);
if (parts.length === 0) return '(always)';
const sep = condition.operator === 'AND' ? ' & ' : ' | ';
return parts.join(sep);
const parts = (condition.conditions || []).map((c, i) => this.formatRuleConditions(c, depth + 1)).filter(Boolean);
if (parts.length === 0) return <span style={{ color: '#888' }}>(always)</span>;
const isAnd = condition.operator === 'AND';
const borderColor = isAnd ? 'rgba(100, 150, 255, 0.5)' : 'rgba(255, 150, 100, 0.5)';
const bgColor = isAnd ? 'rgba(100, 150, 255, 0.08)' : 'rgba(255, 150, 100, 0.08)';
const label = isAnd ? 'ALL' : 'ANY';
const symbol = isAnd ? 'and' : 'or';
return (
<Box
component="span"
sx={{
display: 'inline-flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 0.5,
border: `1px solid ${borderColor}`,
borderRadius: 1,
bgcolor: bgColor,
px: 0.75,
py: 0.25,
fontSize: depth > 0 ? '0.9em' : '1em',
}}
>
<Typography
component="span"
sx={{
fontSize: '0.7em',
fontWeight: 'bold',
color: isAnd ? '#6496ff' : '#ff9664',
mr: 0.5,
}}
>
{label}:
</Typography>
{parts.map((part, i) => (
<React.Fragment key={i}>
{part}
{i < parts.length - 1 && (
<Typography
component="span"
sx={{
mx: 0.5,
fontWeight: 'bold',
color: isAnd ? '#6496ff' : '#ff9664',
}}
>
{symbol}
</Typography>
)}
</React.Fragment>
))}
</Box>
);
}
const { type, channel, operator, value } = condition;
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
const op = opSymbols[operator] || operator;
let text = '?';
switch (type) {
case 'time':
if (operator === 'between' && Array.isArray(value)) {
return `🕐 ${value[0]} - ${value[1]}`;
text = `🕐 ${value[0]} - ${value[1]}`;
} else {
text = `🕐 ${op} ${value}`;
}
return `🕐 ${op} ${value}`;
break;
case 'date':
if (operator === 'between' && Array.isArray(value)) {
return `📅 ${value[0]} to ${value[1]}`;
text = `📅 ${value[0]} to ${value[1]}`;
} else {
text = `📅 ${operator} ${value}`;
}
return `📅 ${operator} ${value}`;
break;
case 'sensor':
// Show device:channel for clarity (e.g. "ac:controller:humidity")
return `📡 ${channel} ${op} ${value}`;
case 'output':
return `⚙️ ${channel} ${op} ${value}`;
default:
return '?';
if (value && value.type === 'dynamic') {
text = `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
} else {
text = `📡 ${channel} ${op} ${value}`;
}
break;
case 'output':
text = `⚙️ ${channel} ${op} ${value}`;
break;
default:
text = '?';
}
return (
<Typography
component="span"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
px: 0.5,
py: 0.25,
borderRadius: 0.5,
whiteSpace: 'nowrap',
}}
>
{text}
</Typography>
);
};
// Format action for display
formatRuleAction = (action) => {
if (!action?.channel) return '?';
const channelNames = {
'CircFanLevel': '🌀 Circ Fan',
'CO2Valve': '🫧 CO2',
'BigDehumid': '💧 Big Dehumid',
'TentExhaust': '💨 Tent Exhaust Fan'
};
const name = channelNames[action.channel] || action.channel;
const name = action.channel;
if (action.value && action.value.type === 'calculated') {
return `${name} = (${action.value.sensorA} - ${action.value.sensorB || '0'}) * ${action.value.factor} + ${action.value.offset}`;
}
return `${name} = ${action.value}`;
};
@@ -397,6 +483,14 @@ class ViewManager extends Component {
this.setState({ viewConfig: newConfig });
};
updateChannel = (idx, updates) => {
const newConfig = this.state.viewConfig.map((ch, i) => {
if (i === idx) return { ...ch, ...updates };
return ch;
});
this.setState({ viewConfig: newConfig });
};
updateFillOpacity = (idx, value) => {
const newConfig = this.state.viewConfig.map((ch, i) => {
if (i === idx) {
@@ -424,6 +518,45 @@ class ViewManager extends Component {
}
};
handleAlignToPeriod = () => {
const { rangeLabel } = this.state;
const now = new Date();
let periodEnd;
switch (rangeLabel) {
case '1d':
// Midnight of tomorrow (so range - 24h = midnight today)
periodEnd = new Date(now);
periodEnd.setDate(periodEnd.getDate() + 1);
periodEnd.setHours(0, 0, 0, 0);
break;
case '1w':
// Midnight of next Monday (start of next week)
periodEnd = new Date(now);
const dayOfWeek = periodEnd.getDay(); // 0 = Sunday
const daysUntilNextMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
periodEnd.setDate(periodEnd.getDate() + daysUntilNextMonday);
periodEnd.setHours(0, 0, 0, 0);
break;
case '1m':
// First day of next month (midnight)
periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
break;
case '3m':
// First day of next quarter (midnight)
const nextQuarterMonth = (Math.floor(now.getMonth() / 3) + 1) * 3;
periodEnd = new Date(now.getFullYear(), nextQuarterMonth, 1, 0, 0, 0, 0);
break;
default:
// For 3h or unsupported, don't change
return;
}
// Set window end to the end of the period
// This makes the chart show [period_start, period_end]
// e.g., for 1d, shows 0:00 to 23:59:59 of today
this.setState({ windowEnd: periodEnd });
};
render() {
const {
views, open, editingId, viewName, availableDevices,
@@ -447,14 +580,71 @@ class ViewManager extends Component {
return (
<Container maxWidth="xl" sx={{ mt: 4 }}>
<Paper sx={{ position: 'sticky', top: 10, zIndex: 1000, p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between', border: '1px solid #504945' }}>
<Paper sx={{
position: 'sticky',
top: 10,
zIndex: 1000,
p: 2,
mb: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: 'rgba(20, 30, 50, 0.95)',
border: '2px solid #1976d2',
borderRadius: 2,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(25, 118, 210, 0.3)',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<ToggleButtonGroup value={rangeLabel} exclusive onChange={this.handleRangeChange} size="small">
<ToggleButtonGroup
value={rangeLabel}
exclusive
onChange={this.handleRangeChange}
size="small"
sx={{
'& .MuiToggleButton-root': {
transition: 'all 0.15s ease',
border: '1px solid rgba(255, 255, 255, 0.2)',
'&:hover': {
bgcolor: 'rgba(100, 180, 255, 0.3)',
border: '2px solid #64b5f6',
boxShadow: '0 0 15px rgba(100, 180, 255, 0.6), inset 0 0 8px rgba(100, 180, 255, 0.2)',
transform: 'scale(1.08)',
zIndex: 1,
color: '#fff',
},
'&.Mui-selected': {
bgcolor: '#1976d2',
color: 'white',
border: '2px solid #42a5f5',
'&:hover': {
bgcolor: '#1e88e5',
boxShadow: '0 0 20px rgba(100, 180, 255, 0.8)',
},
},
},
}}
>
{Object.keys(RANGES).map(r => <ToggleButton key={r} value={r}>{r}</ToggleButton>)}
</ToggleButtonGroup>
<Box>
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton>
<IconButton onClick={() => this.handleTimeNav(1)} disabled={!windowEnd}><ArrowForwardIcon /></IconButton>
{['1d', '1w', '1m', '3m'].includes(rangeLabel) && (
<Button
size="small"
variant="outlined"
onClick={this.handleAlignToPeriod}
sx={{
ml: 1,
minWidth: 'auto',
fontSize: '0.75rem',
px: 1,
}}
title={`Align to ${rangeLabel === '1d' ? 'today' : rangeLabel === '1w' ? 'this week' : rangeLabel === '1m' ? 'this month' : 'this quarter'}`}
>
📅 Align
</Button>
)}
</Box>
</Box>
<Typography variant="h6">{dateDisplay}</Typography>
@@ -491,7 +681,8 @@ class ViewManager extends Component {
yAxis: c.yAxis || 'left',
color: c.color,
fillColor: c.fillColor,
fillOpacity: c.fillOpacity
fillOpacity: c.fillOpacity,
sma: c.sma || 0
}))}
axisConfig={axes}
windowEnd={windowEnd}
@@ -508,14 +699,16 @@ class ViewManager extends Component {
<Paper sx={{ p: 2, mt: 4 }}>
<Typography variant="h5" sx={{ mb: 2 }}>🤖 Active Rules</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{this.state.rules.filter(r => r.enabled).map((rule, idx) => (
{this.state.rules.filter(r => r.enabled).map((rule, idx) => {
const isActive = this.state.activeRuleIds.includes(rule.id);
return (
<Box
key={rule.id}
sx={{
p: 1.5,
bgcolor: 'background.paper',
bgcolor: isActive ? 'rgba(76, 175, 80, 0.15)' : 'background.paper',
borderRadius: 1,
border: '1px solid #504945',
border: isActive ? '1px solid #4caf50' : '1px solid #504945',
display: 'flex',
alignItems: 'center',
gap: 2
@@ -536,16 +729,18 @@ class ViewManager extends Component {
#{idx + 1}
</Typography>
</Box>
))}
);
})}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, textAlign: 'center' }}>
📊 Current outputs: {Object.entries(this.state.outputValues).filter(([k, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(', ') || 'all off'}
</Typography>
</Paper>
)}
</Box>
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
{/* Scroll space at end of page */}
<Box sx={{ height: 200 }} />
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="lg" fullWidth>
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
<DialogContent>
<TextField
@@ -597,11 +792,37 @@ class ViewManager extends Component {
</IconButton>
</>
)}
<ListItemText
primary={ch.alias}
secondary={`${ch.device}:${ch.channel} (${ch.yAxis})`}
sx={{ ml: 1 }}
<Select
size="small"
value={ch.sma || 0}
onChange={e => this.updateChannel(idx, { sma: e.target.value })}
sx={{ width: 100, ml: 1 }}
title="Simple Moving Average"
>
<MenuItem value="" disabled><em>SMA</em></MenuItem>
{SMA_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
<TextField
size="small"
value={ch.alias}
onChange={e => this.updateChannel(idx, { alias: e.target.value })}
sx={{ ml: 1, flex: 1, minWidth: 100 }}
placeholder="Alias"
/>
<Typography variant="caption" sx={{ ml: 1, color: 'text.secondary', whiteSpace: 'nowrap' }}>
{ch.device}:{ch.channel}
</Typography>
<Select
size="small"
value={ch.yAxis || 'left'}
onChange={e => this.updateChannel(idx, { yAxis: e.target.value })}
sx={{ width: 85, ml: 1 }}
>
<MenuItem value="left">Left</MenuItem>
<MenuItem value="right">Right</MenuItem>
</Select>
<IconButton size="small" onClick={() => this.moveChannel(idx, -1)} disabled={idx === 0}><ArrowUpwardIcon /></IconButton>
<IconButton size="small" onClick={() => this.moveChannel(idx, 1)} disabled={idx === viewConfig.length - 1}><ArrowDownwardIcon /></IconButton>
<IconButton size="small" color="error" onClick={() => this.removeChannel(idx)}><DeleteIcon /></IconButton>

View File

@@ -12,24 +12,87 @@ config();
// Database connection for Dev Server API
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../server/data/sensors.db');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-me';
const WS_PORT = process.env.WS_PORT || 3962;
const WS_PORT = parseInt(process.env.WS_PORT || '3962', 10);
const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT || '3905', 10);
const RULE_RUNNER_INTERVAL = parseInt(process.env.RULE_RUNNER_INTERVAL || '10000', 10);
let db;
try {
db = new Database(dbPath);
console.log(`[UI Server] Connected to database at ${dbPath}`);
// Create changelog table
db.exec(`
CREATE TABLE IF NOT EXISTS changelog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
user TEXT,
text TEXT NOT NULL
)
`);
// Create output_configs table (unified channels + bindings)
// Note: binding_type derived from device (ac=level, tapo=switch)
db.exec(`
CREATE TABLE IF NOT EXISTS output_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT UNIQUE NOT NULL,
description TEXT,
value_type TEXT NOT NULL,
min_value REAL DEFAULT 0,
max_value REAL DEFAULT 1,
device TEXT,
device_channel TEXT,
position INTEGER DEFAULT 0
)
`);
// Helper to insert changelog entry
global.insertChangelog = (user, text) => {
try {
if (!db) return;
const stmt = db.prepare('INSERT INTO changelog (date, user, text) VALUES (?, ?, ?)');
stmt.run(new Date().toISOString(), user || 'system', text);
console.log(`[Changelog] ${user || 'system'}: ${text}`);
} catch (err) {
console.error('[Changelog] Error inserting entry:', err.message);
}
};
} catch (err) {
console.error(`[UI Server] Failed to connect to database at ${dbPath}:`, err.message);
}
// Output bindings: map virtual outputs to physical devices
// Format: outputChannel -> { device, channel, type }
const OUTPUT_BINDINGS = {
'BigDehumid': { device: 'tapo', channel: 'r0', type: 'switch' },
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' },
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' },
'CircFanLevel': { device: 'ac', channel: 'tent:fan', type: 'level' },
};
// Load output channels from database (replaces hardcoded OUTPUT_CHANNELS)
function getOutputChannels() {
if (!db) return [];
const rows = db.prepare('SELECT * FROM output_configs ORDER BY position ASC').all();
return rows.map(r => ({
channel: r.channel,
type: r.value_type,
min: r.min_value,
max: r.max_value,
description: r.description
}));
}
// Load output bindings from database (replaces hardcoded OUTPUT_BINDINGS)
// Binding type derived: ac=level, tapo=switch
function getOutputBindings() {
if (!db) return {};
const rows = db.prepare('SELECT * FROM output_configs WHERE device IS NOT NULL').all();
const bindings = {};
for (const r of rows) {
if (r.device && r.device_channel) {
bindings[r.channel] = {
device: r.device,
channel: r.device_channel,
type: r.device === 'ac' ? 'level' : 'switch'
};
}
}
return bindings;
}
// =============================================
// WebSocket Server for Agents (port 3962)
@@ -246,6 +309,10 @@ function handleAgentMessage(ws, message, clientState, clientId) {
try {
const validReadings = readings.filter(r => r.device && r.channel && (r.value !== undefined || r.data !== undefined));
const result = insertReadingsSmart(clientState.devicePrefix, validReadings);
// Trigger rules immediately on new data
if (runRules) runRules();
ws.send(JSON.stringify({ type: 'ack', count: result.inserted + result.updated }));
} catch (err) {
console.error('[WS] Error inserting readings:', err.message);
@@ -285,6 +352,7 @@ function syncOutputStates() {
if (!db) return;
try {
const bindings = getOutputBindings();
// Get current output values
const stmt = db.prepare(`
SELECT channel, value FROM output_events
@@ -295,12 +363,17 @@ function syncOutputStates() {
for (const row of rows) {
// Only sync non-zero values
if (row.value > 0) {
const binding = OUTPUT_BINDINGS[row.channel];
const binding = bindings[row.channel];
if (binding) {
let commandValue = row.value;
if (binding.type === 'switch') {
commandValue = row.value > 0 ? 1 : 0;
}
const success = sendCommandToDevicePrefix(`${binding.device}:`, {
device: binding.channel,
action: 'set_state',
value: 1
value: commandValue
});
if (!success) {
@@ -316,400 +389,13 @@ function syncOutputStates() {
// Start output state sync interval (every 60s)
setInterval(syncOutputStates, 60000);
// Also sync immediately on startup after a short delay
setTimeout(syncOutputStates, 5000);
// Start the WebSocket server
const agentWss = createAgentWebSocketServer();
// =============================================
// RULE ENGINE (Global Scope)
// =============================================
module.exports = {
entry: './src/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
clean: true,
},
mode: 'development',
devtool: 'source-map',
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
devServer: {
port: 3905,
historyApiFallback: true,
hot: true,
allowedHosts: 'all',
setupMiddlewares: (middlewares, devServer) => {
if (!devServer) {
throw new Error('webpack-dev-server is not defined');
}
// API Endpoints
const app = devServer.app;
const bodyParser = require('body-parser');
app.use(bodyParser.json());
// --- Auth API ---
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
try {
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
const user = stmt.get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({
id: user.id,
username: user.username,
role: user.role
}, JWT_SECRET, { expiresIn: '24h' });
res.json({ token, role: user.role, username: user.username });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Middleware to check auth (Optional for read, required for write)
const checkAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(' ')[1];
jwt.verify(token, JWT_SECRET, (err, user) => {
if (user) req.user = user;
next();
});
} else {
next();
}
};
const requireAdmin = (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
app.use('/api/views', checkAuth);
// --- Views API ---
app.post('/api/views', requireAdmin, (req, res) => {
const { name, config } = req.body;
try {
const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)');
const info = stmt.run(name, JSON.stringify(config), req.user.id);
res.json({ id: info.lastInsertRowid, name, config });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Publicly list views
app.get('/api/views', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
const rows = stmt.all();
const views = rows.map(row => {
try {
return { ...row, config: JSON.parse(row.config) };
} catch (e) {
return row;
}
});
res.json(views);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/views/:id', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM views WHERE id = ?');
const view = stmt.get(req.params.id);
if (view) {
view.config = JSON.parse(view.config);
res.json(view);
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Delete View
app.delete('/api/views/:id', requireAdmin, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM views WHERE id = ?');
const info = stmt.run(req.params.id);
if (info.changes > 0) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Update View
app.put('/api/views/:id', requireAdmin, (req, res) => {
const { name, config } = req.body;
try {
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
const info = stmt.run(name, JSON.stringify(config), req.params.id);
if (info.changes > 0) {
res.json({ id: req.params.id, name, config });
} else {
res.status(404).json({ error: 'View not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Reorder Views
app.post('/api/views/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
console.log('[API] Reorder request:', order);
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
console.log('[API] Updating view', item.id, 'to position', item.position);
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
console.log('[API] Reorder successful');
res.json({ success: true });
} catch (err) {
console.error('[API] Reorder error:', err);
res.status(500).json({ error: err.message });
}
});
// =============================================
// RULES API
// =============================================
// Apply checkAuth middleware to rules API routes
app.use('/api/rules', checkAuth);
// Virtual output channel definitions
const OUTPUT_CHANNELS = [
{ channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation Fan Level' },
{ channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' },
{ channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
{ channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' },
];
// GET /api/outputs - List output channel definitions
app.get('/api/outputs', (req, res) => {
res.json(OUTPUT_CHANNELS);
});
// GET /api/outputs/values - Get current output values
app.get('/api/outputs/values', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const result = {};
const stmt = db.prepare(`
SELECT channel, value FROM output_events
WHERE id IN (
SELECT MAX(id) FROM output_events GROUP BY channel
)
`);
const rows = stmt.all();
rows.forEach(row => {
result[row.channel] = row.value;
});
// Fill in defaults for missing channels
OUTPUT_CHANNELS.forEach(ch => {
if (result[ch.channel] === undefined) {
result[ch.channel] = 0;
}
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/outputs/commands - Get desired states for bound devices
// Agents poll this to get commands. Returns { "device:channel": { state: 0|1 } }
app.get('/api/outputs/commands', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
// Get current output values
const stmt = db.prepare(`
SELECT channel, value FROM output_events
WHERE id IN (
SELECT MAX(id) FROM output_events GROUP BY channel
)
`);
const rows = stmt.all();
const outputValues = {};
rows.forEach(row => {
outputValues[row.channel] = row.value;
});
// Map to device commands
const commands = {};
for (const [outputChannel, binding] of Object.entries(OUTPUT_BINDINGS)) {
const value = outputValues[outputChannel] ?? 0;
const deviceKey = `${binding.device}:${binding.channel}`;
commands[deviceKey] = {
state: value > 0 ? 1 : 0,
source: outputChannel
};
}
res.json(commands);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/rules - List all rules
app.get('/api/rules', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC');
const rows = stmt.all();
const rules = rows.map(row => ({
...row,
conditions: JSON.parse(row.conditions || '{}'),
action: JSON.parse(row.action || '{}')
}));
res.json(rules);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules - Create rule (admin only)
app.post('/api/rules', requireAdmin, (req, res) => {
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
if (!name || !conditions || !action) {
return res.status(400).json({ error: 'Missing required fields: name, conditions, action' });
}
try {
const stmt = db.prepare(`
INSERT INTO rules (name, type, enabled, conditions, action, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
name,
type,
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.user?.id || null
);
res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/rules/:id - Update rule (admin only)
app.put('/api/rules/:id', requireAdmin, (req, res) => {
const { name, type, enabled, conditions, action } = req.body;
try {
const stmt = db.prepare(`
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
WHERE id = ?
`);
const info = stmt.run(
name,
type || 'static',
enabled ? 1 : 0,
JSON.stringify(conditions),
JSON.stringify(action),
req.params.id
);
if (info.changes > 0) {
res.json({ id: req.params.id, name, type, enabled, conditions, action });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/rules/:id - Delete rule (admin only)
app.delete('/api/rules/:id', requireAdmin, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM rules WHERE id = ?');
const info = stmt.run(req.params.id);
if (info.changes > 0) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'Rule not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/rules/reorder - Reorder rules (admin only)
app.post('/api/rules/reorder', requireAdmin, (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?');
const updateMany = db.transaction((items) => {
for (const item of items) {
updateStmt.run(item.position, item.id);
}
});
try {
updateMany(order);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// =============================================
// RULE RUNNER (Background Job)
// =============================================
// Get current sensor value
function getSensorValue(channel) {
// Get current sensor value
function getSensorValue(channel) {
// channel format: "device:channel" e.g. "ac:controller:co2"
const lastColonIndex = channel.lastIndexOf(':');
if (lastColonIndex === -1) return null;
@@ -723,10 +409,10 @@ module.exports = {
`);
const row = stmt.get(device, ch);
return row ? row.value : null;
}
}
// Get current output value
function getOutputValue(channel) {
// Get current output value
function getOutputValue(channel) {
const stmt = db.prepare(`
SELECT value FROM output_events
WHERE channel = ?
@@ -734,10 +420,10 @@ module.exports = {
`);
const row = stmt.get(channel);
return row ? row.value : 0;
}
}
// Write output value with RLE
function writeOutputValue(channel, value) {
// Write output value with RLE
function writeOutputValue(channel, value) {
const now = new Date().toISOString();
const lastStmt = db.prepare(`
@@ -747,10 +433,6 @@ module.exports = {
`);
const last = lastStmt.get(channel);
if (channel === 'CircFanLevel') {
console.log('[RuleRunner] Debug Bindings:', JSON.stringify(OUTPUT_BINDINGS['CircFanLevel']));
}
const valueChanged = !last || Math.abs(last.value - value) >= Number.EPSILON;
if (!valueChanged) {
@@ -767,7 +449,8 @@ module.exports = {
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
// Send command to bound physical device
const binding = OUTPUT_BINDINGS[channel];
const bindings = getOutputBindings();
const binding = bindings[channel];
if (binding) {
let commandValue = value;
if (binding.type === 'switch') {
@@ -783,10 +466,10 @@ module.exports = {
});
}
}
}
}
// Compare values with operator
function compareValues(actual, operator, target) {
// Compare values with operator
function compareValues(actual, operator, target) {
if (actual === null || actual === undefined) return false;
switch (operator) {
case '=':
@@ -798,10 +481,10 @@ module.exports = {
case '>=': return actual >= target;
default: return false;
}
}
}
// Evaluate a single condition
function evaluateCondition(condition) {
// Evaluate a single condition
function evaluateCondition(condition) {
const { type, operator, value, channel } = condition;
// Handle AND/OR groups
@@ -844,7 +527,14 @@ module.exports = {
case 'sensor': {
const sensorValue = getSensorValue(channel);
return compareValues(sensorValue, operator, value);
let target = value;
if (value && typeof value === 'object' && value.type === 'dynamic') {
const targetSensorVal = getSensorValue(value.channel) || 0;
target = (targetSensorVal * (value.factor || 1)) + (value.offset || 0);
}
return compareValues(sensorValue, operator, target);
}
case 'output': {
@@ -856,18 +546,25 @@ module.exports = {
console.warn(`[RuleRunner] Unknown condition type: ${type}`);
return false;
}
}
}
// Run all rules
function runRules() {
// Global set to track currently active rule IDs
const activeRuleIds = new Set();
// Run all rules
function runRules() {
if (!db) return;
try {
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
// Clear active rules list at start of run
activeRuleIds.clear();
// Default all outputs to OFF (0) - if no rule sets them, they stay off
const desiredOutputs = {};
for (const ch of OUTPUT_CHANNELS) {
const outputChannels = getOutputChannels();
for (const ch of outputChannels) {
desiredOutputs[ch.channel] = 0;
}
@@ -877,9 +574,22 @@ module.exports = {
const action = JSON.parse(rule.action || '{}');
if (evaluateCondition(conditions)) {
// Rule matches - add to active list
activeRuleIds.add(rule.id);
// Rule matches - set output (later rules override)
if (action.channel && action.value !== undefined) {
desiredOutputs[action.channel] = action.value;
let finalValue = action.value;
// Handle calculated value
if (action.value && typeof action.value === 'object' && action.value.type === 'calculated') {
const valA = getSensorValue(action.value.sensorA) || 0;
const valB = action.value.sensorB ? (getSensorValue(action.value.sensorB) || 0) : 0;
const diff = valA - valB;
finalValue = (diff * (action.value.factor || 1)) + (action.value.offset || 0);
}
desiredOutputs[action.channel] = finalValue;
}
}
} catch (err) {
@@ -894,11 +604,93 @@ module.exports = {
} catch (err) {
console.error('[RuleRunner] Error running rules:', err.message);
}
}
// Also sync immediately on startup after a short delay
setTimeout(syncOutputStates, 5000);
// Start the WebSocket server
const agentWss = createAgentWebSocketServer();
// Import API setup
const setupAllApis = require('./api');
module.exports = {
entry: './src/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
clean: true,
},
mode: 'development',
devtool: 'source-map',
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
// Fix for ESM modules in node_modules (MUI X Charts v8)
test: /\.m?js$/,
resolve: {
fullySpecified: false,
},
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
devServer: {
port: DEV_SERVER_PORT,
historyApiFallback: true,
hot: true,
allowedHosts: 'all',
client: {
webSocketURL: 'auto://0.0.0.0:0/ws',
progress: true,
},
setupMiddlewares: (middlewares, devServer) => {
if (!devServer) {
throw new Error('webpack-dev-server is not defined');
}
// Start rule runner (every 10 seconds)
const ruleRunnerInterval = setInterval(runRules, 10000);
console.log('[RuleRunner] Started background job (10s interval)');
// Setup body parser
const app = devServer.app;
const bodyParser = require('body-parser');
app.use(bodyParser.json());
// Setup all API routes from extracted modules
setupAllApis(app, {
db,
bcrypt,
jwt,
JWT_SECRET,
getOutputChannels,
getOutputBindings,
runRules,
activeRuleIds
});
// Start rule runner
const ruleRunnerInterval = setInterval(runRules, RULE_RUNNER_INTERVAL);
console.log(`[RuleRunner] Started background job (${RULE_RUNNER_INTERVAL / 1000}s interval)`);
// Clean up on server close
devServer.server?.on('close', () => {
@@ -906,155 +698,6 @@ module.exports = {
console.log('[RuleRunner] Stopped background job');
});
// GET /api/devices
// Returns list of unique device/channel pairs (sensors + outputs)
app.get('/api/devices', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
// Get sensor channels
const sensorStmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel");
const sensorRows = sensorStmt.all();
// Add output channels with 'output' as device
const outputRows = OUTPUT_CHANNELS.map(ch => ({
device: 'output',
channel: ch.channel
}));
res.json([...sensorRows, ...outputRows]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/readings
// Query params: devices (comma sep), channels (comma sep), since (timestamp)
// Actually, user wants "Last 24h".
// We can accept `since` or valid ISO string.
// Expected params: `?device=x&channel=y` (single) or query for multiple?
// User asked for "chart that is refreshed once a minute... display the last 24 hours with the devices/channels previously selected"
// Efficient query: select * from sensor_events where timestamp > ? and (device,channel) IN (...)
// For simplicity, let's allow fetching by multiple devices/channels or just all for last 24h and filter client side?
// No, database filtering is better.
// Let's support ?since=ISO_DATE
app.get('/api/readings', (req, res) => {
try {
if (!db) throw new Error('Database not connected');
const { since, until } = req.query;
const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const endTime = until || new Date().toISOString();
const requestedSensorChannels = []; // [{device, channel}]
const requestedOutputChannels = []; // [channel]
if (req.query.selection) {
const selections = req.query.selection.split(',');
selections.forEach(s => {
const lastColonIndex = s.lastIndexOf(':');
if (lastColonIndex !== -1) {
const d = s.substring(0, lastColonIndex);
const c = s.substring(lastColonIndex + 1);
if (d === 'output') {
requestedOutputChannels.push(c);
} else {
requestedSensorChannels.push({ device: d, channel: c });
}
}
});
}
const result = {};
// 1. Fetch sensor data
if (requestedSensorChannels.length > 0) {
let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? ';
const params = [startTime, endTime];
const placeholders = [];
requestedSensorChannels.forEach(ch => {
placeholders.push('(device = ? AND channel = ?)');
params.push(ch.device, ch.channel);
});
if (placeholders.length > 0) {
sql += `AND (${placeholders.join(' OR ')}) `;
}
sql += 'ORDER BY timestamp ASC';
const rows = db.prepare(sql).all(...params);
// Backfill for sensors
const backfillStmt = db.prepare(`
SELECT * FROM sensor_events
WHERE device = ? AND channel = ?
AND timestamp <= ?
AND (until >= ? OR until IS NULL)
ORDER BY timestamp DESC LIMIT 1
`);
const backfillRows = [];
requestedSensorChannels.forEach(ch => {
const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime);
if (prev) backfillRows.push(prev);
});
[...backfillRows, ...rows].forEach(row => {
const key = `${row.device}:${row.channel}`;
if (!result[key]) result[key] = [];
const pt = [row.timestamp, row.value];
if (row.until) pt.push(row.until);
result[key].push(pt);
});
}
// 2. Fetch output data
if (requestedOutputChannels.length > 0) {
let sql = 'SELECT * FROM output_events WHERE timestamp > ? AND timestamp <= ? ';
const params = [startTime, endTime];
const placeholders = requestedOutputChannels.map(() => 'channel = ?');
sql += `AND (${placeholders.join(' OR ')}) `;
params.push(...requestedOutputChannels);
sql += 'ORDER BY timestamp ASC';
const rows = db.prepare(sql).all(...params);
// Backfill for outputs
const backfillStmt = db.prepare(`
SELECT * FROM output_events
WHERE channel = ?
AND timestamp <= ?
AND (until >= ? OR until IS NULL)
ORDER BY timestamp DESC LIMIT 1
`);
const backfillRows = [];
requestedOutputChannels.forEach(ch => {
const prev = backfillStmt.get(ch, startTime, startTime);
if (prev) {
backfillRows.push(prev);
} else {
// No data at all - add default 0 value at startTime
backfillRows.push({ channel: ch, timestamp: startTime, value: 0, until: null });
}
});
[...backfillRows, ...rows].forEach(row => {
const key = `output:${row.channel}`;
if (!result[key]) result[key] = [];
const pt = [row.timestamp, row.value];
if (row.until) pt.push(row.until);
result[key].push(pt);
});
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
return middlewares;
},
},