Compare commits

..

9 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
23 changed files with 2208 additions and 1877 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

@@ -1,17 +0,0 @@
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

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

View File

@@ -10,15 +10,15 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.0", "@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.18.0", "@mui/icons-material": "^6.0.0",
"@mui/material": "^5.14.0", "@mui/material": "^6.0.0",
"@mui/x-charts": "^6.0.0-alpha.0", "@mui/x-charts": "^8.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"react": "^18.2.0", "react": "^19.0.0",
"react-dom": "^18.2.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.11.0" "react-router-dom": "^7.11.0"
}, },
"devDependencies": { "devDependencies": {
@@ -1815,44 +1815,6 @@
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1917,32 +1879,35 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mui/base": { "node_modules/@mui/core-downloads-tracker": {
"version": "5.0.0-dev.20240529-082515-213b5e33ab", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-dev.20240529-082515-213b5e33ab.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz",
"integrity": "sha512-3ic6fc6BHstgM+MGqJEVx3zt9g5THxVXm3VVFUfdeplPqAWWgW2QoKfZDLT10s+pi+MAkpgEBP0kgRidf81Rsw==", "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==",
"deprecated": "This package has been replaced by @base-ui/react", "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", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.24.6", "@babel/runtime": "^7.26.0"
"@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"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=14.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/mui-org" "url": "https://opencollective.com/mui-org"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0", "@mui/material": "^6.5.0",
"react": "^17.0.0 || ^18.0.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0" "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/react": { "@types/react": {
@@ -1950,18 +1915,64 @@
} }
} }
}, },
"node_modules/@mui/base/node_modules/@mui/utils": { "node_modules/@mui/material": {
"version": "6.4.9", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz",
"integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.26.0", "@babel/runtime": "^7.26.0",
"@mui/core-downloads-tracker": "^6.5.0",
"@mui/system": "^6.5.0",
"@mui/types": "~7.2.24", "@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", "clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1", "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": { "engines": {
"node": ">=14.0.0" "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": { "node_modules/@mui/styled-engine": {
"version": "5.18.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz",
"integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.9", "@babel/runtime": "^7.26.0",
"@emotion/cache": "^11.13.5", "@emotion/cache": "^11.13.5",
"@emotion/serialize": "^1.3.3", "@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
"csstype": "^3.1.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=14.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -2122,22 +2026,22 @@
} }
}, },
"node_modules/@mui/system": { "node_modules/@mui/system": {
"version": "5.18.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz",
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.9", "@babel/runtime": "^7.26.0",
"@mui/private-theming": "^5.17.1", "@mui/private-theming": "^6.4.9",
"@mui/styled-engine": "^5.18.0", "@mui/styled-engine": "^6.5.0",
"@mui/types": "~7.2.15", "@mui/types": "~7.2.24",
"@mui/utils": "^5.17.1", "@mui/utils": "^6.4.9",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"csstype": "^3.1.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=14.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -2176,20 +2080,20 @@
} }
}, },
"node_modules/@mui/utils": { "node_modules/@mui/utils": {
"version": "5.17.1", "version": "6.4.9",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz",
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.9", "@babel/runtime": "^7.26.0",
"@mui/types": "~7.2.15", "@mui/types": "~7.2.24",
"@types/prop-types": "^15.7.12", "@types/prop-types": "^15.7.14",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.0.0" "react-is": "^19.0.0"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=14.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -2206,20 +2110,21 @@
} }
}, },
"node_modules/@mui/x-charts": { "node_modules/@mui/x-charts": {
"version": "6.19.8", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-6.19.8.tgz", "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.23.0.tgz",
"integrity": "sha512-cjwsCJrUPDlMytJHBV+g3gDoSRURiphjclZs8sRnkZ+h4QbHn24K5QkK4bxEj7aCkO2HVJmDE0aqYEg4BnWCOA==", "integrity": "sha512-eYUC3ja1+0Wk7STAbEqwbRXxH6a+KFD/P+KNgyqVK1C10faRxTm/TJ/3GOh6haDaZ0AlhVGXkt7Wex5jZWVIsw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.2", "@babel/runtime": "^7.28.4",
"@mui/base": "^5.0.0-beta.22", "@mui/utils": "^7.3.5",
"@react-spring/rafz": "^9.7.3", "@mui/x-charts-vendor": "8.23.0",
"@react-spring/web": "^9.7.3", "@mui/x-internal-gestures": "0.4.0",
"clsx": "^2.0.0", "@mui/x-internals": "8.23.0",
"d3-color": "^3.1.0", "bezier-easing": "^2.1.0",
"d3-scale": "^4.0.2", "clsx": "^2.1.1",
"d3-shape": "^3.2.0", "prop-types": "^15.8.1",
"prop-types": "^15.8.1" "reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -2227,10 +2132,10 @@
"peerDependencies": { "peerDependencies": {
"@emotion/react": "^11.9.0", "@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1", "@emotion/styled": "^11.8.1",
"@mui/material": "^5.4.1", "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.4.1", "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@emotion/react": { "@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": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -2251,78 +2312,6 @@
"url": "https://opencollective.com/popperjs" "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": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2365,6 +2354,75 @@
"@types/node": "*" "@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": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -3110,6 +3168,12 @@
"prebuild-install": "^7.1.1" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3858,6 +3922,15 @@
"node": ">=12" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4579,6 +4652,12 @@
"flat": "cli.js" "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": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -6609,28 +6688,24 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "scheduler": "^0.27.0"
"scheduler": "^0.23.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "react": "^19.2.3"
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {
@@ -6845,6 +6920,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6952,13 +7033,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT", "license": "MIT"
"dependencies": {
"loose-envify": "^1.1.0"
}
}, },
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "4.3.3", "version": "4.3.3",
@@ -7799,6 +7877,15 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,124 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Box, Paper, Typography, CircularProgress, IconButton } from '@mui/material'; import { Box, Paper, Typography, CircularProgress, IconButton } from '@mui/material';
import { LineChart } from '@mui/x-charts/LineChart'; import { LineChart } from '@mui/x-charts/LineChart';
import { useDrawingArea, useYScale, useXScale } from '@mui/x-charts/hooks';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; 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 { export default class Chart extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
data: [], data: [],
loading: true, loading: true,
hiddenSeries: {} // { seriesId: true/false } hiddenSeries: {}, // { seriesId: true/false }
lastValues: {}, // { channelId: lastValue } - for detecting changes
flashStates: {} // { channelId: 'up' | 'down' | null } - for flash animation
}; };
this.interval = null; this.interval = null;
this.flashTimeouts = {}; // Store timeouts to clear flash states
} }
componentDidMount() { componentDidMount() {
@@ -53,6 +159,8 @@ export default class Chart extends Component {
if (this.interval) { if (this.interval) {
clearInterval(this.interval); clearInterval(this.interval);
} }
// Clear any pending flash timeouts
Object.values(this.flashTimeouts).forEach(timeout => clearTimeout(timeout));
} }
getEffectiveChannels(props) { getEffectiveChannels(props) {
@@ -86,6 +194,16 @@ export default class Chart extends Component {
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`) fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}&until=${until}`)
.then(res => res.json()) .then(res => res.json())
.then(dataObj => { .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 // 1. Parse raw rows into intervals per channel
const intervals = []; const intervals = [];
const timestampsSet = new Set(); const timestampsSet = new Set();
@@ -93,7 +211,10 @@ export default class Chart extends Component {
// dataObj format: { "device:channel": [ [timestamp, value, until], ... ] } // dataObj format: { "device:channel": [ [timestamp, value, until], ... ] }
Object.entries(dataObj).forEach(([id, points]) => { Object.entries(dataObj).forEach(([id, points]) => {
// Check if this ID is in our effective/requested list // 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 // Ensure sorted by time
points.sort((a, b) => new Date(a[0]) - new Date(b[0])); points.sort((a, b) => new Date(a[0]) - new Date(b[0]));
@@ -115,9 +236,14 @@ export default class Chart extends Component {
// Calculate effective end // Calculate effective end
let end = explicitEnd; 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) { 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 // Strict Cutoff: Current interval cannot extend past the start of the next interval
@@ -166,10 +292,74 @@ export default class Chart extends Component {
row[inv.id] = inv.val; 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; 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 => { .catch(err => {
console.error(err); console.error(err);
@@ -182,7 +372,7 @@ export default class Chart extends Component {
let axisMin = Infinity; let axisMin = Infinity;
let axisMax = -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 if (axisSeries.length === 0) return {}; // No data for this axis
@@ -230,7 +420,7 @@ export default class Chart extends Component {
}; };
render() { render() {
const { loading, data, hiddenSeries } = this.state; const { loading, data, hiddenSeries, flashStates } = this.state;
const { channelConfig, windowEnd, range } = this.props; const { channelConfig, windowEnd, range } = this.props;
const effectiveChannels = this.getEffectiveChannels(this.props); const effectiveChannels = this.getEffectiveChannels(this.props);
@@ -277,7 +467,7 @@ export default class Chart extends Component {
label: label, label: label,
connectNulls: true, connectNulls: true,
showMark: false, showMark: false,
yAxisKey: yAxisKey, yAxisId: yAxisKey,
}; };
if (color) sObj.color = color; if (color) sObj.color = color;
// Enable area fill if fillColor is set (with configurable opacity) // Enable area fill if fillColor is set (with configurable opacity)
@@ -288,16 +478,16 @@ export default class Chart extends Component {
return sObj; 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 leftLimits = this.computeAxisLimits('left', effectiveChannels, series);
const rightLimits = this.computeAxisLimits('right', effectiveChannels, series); const rightLimits = this.computeAxisLimits('right', effectiveChannels, series);
const yAxes = [ const yAxes = [
{ id: 'left', scaleType: 'linear', ...leftLimits } { id: 'left', ...leftLimits }
]; ];
if (hasRightAxis) { if (hasRightAxis) {
yAxes.push({ id: 'right', scaleType: 'linear', ...rightLimits }); yAxes.push({ id: 'right', position: 'right', ...rightLimits });
} }
// Calculate X-Axis Limits // Calculate X-Axis Limits
@@ -305,12 +495,41 @@ export default class Chart extends Component {
const axisEnd = windowEnd ? windowEnd.getTime() : Date.now(); const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
const axisStart = axisEnd - rangeMs; 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 ( return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', p: 2, boxSizing: 'border-box' }}> <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' }}> <Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{/* Custom Interactive Legend */} {/* Custom Interactive Legend */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, justifyContent: 'center', mb: 1, py: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, justifyContent: 'center', mb: 1, py: 0.5 }}>
{legendItems.map(item => ( {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 <Box
key={item.id} key={item.id}
onClick={() => this.toggleSeries(item.id)} onClick={() => this.toggleSeries(item.id)}
@@ -321,8 +540,11 @@ export default class Chart extends Component {
cursor: 'pointer', cursor: 'pointer',
opacity: item.hidden ? 0.4 : 1, opacity: item.hidden ? 0.4 : 1,
textDecoration: item.hidden ? 'line-through' : 'none', textDecoration: item.hidden ? 'line-through' : 'none',
transition: 'opacity 0.2s', transition: 'opacity 0.2s, background-color 0.3s',
userSelect: 'none', userSelect: 'none',
backgroundColor: flashColor,
borderRadius: 1,
px: 0.5,
'&:hover': { opacity: item.hidden ? 0.6 : 0.8 }, '&:hover': { opacity: item.hidden ? 0.6 : 0.8 },
}} }}
> >
@@ -340,7 +562,8 @@ export default class Chart extends Component {
{item.label} {item.label}
</Typography> </Typography>
</Box> </Box>
))} );
})}
</Box> </Box>
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}> <Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
<LineChart <LineChart
@@ -354,10 +577,9 @@ export default class Chart extends Component {
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}]} }]}
yAxis={yAxes} yAxis={yAxes}
rightAxis={hasRightAxis ? 'right' : null}
hideLegend
slotProps={{ slotProps={{
legend: { hidden: true },
lineHighlight: { strokeWidth: 3 },
}} }}
sx={{ sx={{
'& .MuiLineElement-root': { '& .MuiLineElement-root': {
@@ -367,7 +589,20 @@ export default class Chart extends Component {
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5, 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> </Box>
</Paper> </Paper>
</Box> </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

@@ -24,6 +24,14 @@ const RANGES = {
'3m': 90 * 24 * 60 * 60 * 1000, '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 = [ const GRUVBOX_COLORS = [
'#cc241d', '#fb4934', // Red '#cc241d', '#fb4934', // Red
'#98971a', '#b8bb26', // Green '#98971a', '#b8bb26', // Green
@@ -74,6 +82,7 @@ class ViewManager extends Component {
this.refreshViews(); this.refreshViews();
this.loadRules(); this.loadRules();
this.loadOutputValues(); this.loadOutputValues();
this.loadRuleStatus(); // Load immediately on mount
// Refresh rules and outputs every 30s // Refresh rules and outputs every 30s
this.rulesInterval = setInterval(() => { this.rulesInterval = setInterval(() => {
this.loadRules(); this.loadRules();
@@ -169,65 +178,125 @@ class ViewManager extends Component {
// Emoji for rule based on action channel // Emoji for rule based on action channel
getRuleEmoji = (rule) => { getRuleEmoji = (rule) => {
const channel = rule.action?.channel || ''; return '';
const emojis = {
'CircFanLevel': '🌀',
'CO2Valve': '🫧',
'BigDehumid': '💧',
'TentExhaust': '💨'
};
return emojis[channel] || '⚡';
}; };
// Format conditions for display // Format conditions for display - returns React components with visual grouping
formatRuleConditions = (condition) => { formatRuleConditions = (condition, depth = 0) => {
if (!condition) return '(always)'; if (!condition) return <span style={{ color: '#888' }}>(always)</span>;
if (condition.operator === 'AND' || condition.operator === 'OR') { if (condition.operator === 'AND' || condition.operator === 'OR') {
const parts = (condition.conditions || []).map(c => this.formatRuleConditions(c)).filter(Boolean); const parts = (condition.conditions || []).map((c, i) => this.formatRuleConditions(c, depth + 1)).filter(Boolean);
if (parts.length === 0) return '(always)'; if (parts.length === 0) return <span style={{ color: '#888' }}>(always)</span>;
const sep = condition.operator === 'AND' ? ' & ' : ' | ';
return parts.join(sep); 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 { type, channel, operator, value } = condition;
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' }; const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
const op = opSymbols[operator] || operator; const op = opSymbols[operator] || operator;
let text = '?';
switch (type) { switch (type) {
case 'time': case 'time':
if (operator === 'between' && Array.isArray(value)) { 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': case 'date':
if (operator === 'between' && Array.isArray(value)) { 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': case 'sensor':
// Show device:channel for clarity
if (value && value.type === 'dynamic') { if (value && value.type === 'dynamic') {
return `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`; text = `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
} else {
text = `📡 ${channel} ${op} ${value}`;
} }
return `📡 ${channel} ${op} ${value}`; break;
case 'output': case 'output':
return `⚙️ ${channel} ${op} ${value}`; text = `⚙️ ${channel} ${op} ${value}`;
break;
default: default:
return '?'; 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 // Format action for display
formatRuleAction = (action) => { formatRuleAction = (action) => {
if (!action?.channel) return '?'; if (!action?.channel) return '?';
const channelNames = { const name = action.channel;
'CircFanLevel': '🌀 Circ Fan',
'CO2Valve': '🫧 CO2',
'BigDehumid': '💧 Big Dehumid',
'TentExhaust': '💨 Tent Exhaust Fan'
};
const name = channelNames[action.channel] || action.channel;
if (action.value && action.value.type === 'calculated') { 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.sensorA} - ${action.value.sensorB || '0'}) * ${action.value.factor} + ${action.value.offset}`;
@@ -414,6 +483,14 @@ class ViewManager extends Component {
this.setState({ viewConfig: newConfig }); 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) => { updateFillOpacity = (idx, value) => {
const newConfig = this.state.viewConfig.map((ch, i) => { const newConfig = this.state.viewConfig.map((ch, i) => {
if (i === idx) { if (i === idx) {
@@ -441,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() { render() {
const { const {
views, open, editingId, viewName, availableDevices, views, open, editingId, viewName, availableDevices,
@@ -464,14 +580,71 @@ class ViewManager extends Component {
return ( return (
<Container maxWidth="xl" sx={{ mt: 4 }}> <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 }}> <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>)} {Object.keys(RANGES).map(r => <ToggleButton key={r} value={r}>{r}</ToggleButton>)}
</ToggleButtonGroup> </ToggleButtonGroup>
<Box> <Box>
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton> <IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton>
<IconButton onClick={() => this.handleTimeNav(1)} disabled={!windowEnd}><ArrowForwardIcon /></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>
</Box> </Box>
<Typography variant="h6">{dateDisplay}</Typography> <Typography variant="h6">{dateDisplay}</Typography>
@@ -508,7 +681,8 @@ class ViewManager extends Component {
yAxis: c.yAxis || 'left', yAxis: c.yAxis || 'left',
color: c.color, color: c.color,
fillColor: c.fillColor, fillColor: c.fillColor,
fillOpacity: c.fillOpacity fillOpacity: c.fillOpacity,
sma: c.sma || 0
}))} }))}
axisConfig={axes} axisConfig={axes}
windowEnd={windowEnd} windowEnd={windowEnd}
@@ -563,7 +737,10 @@ class ViewManager extends Component {
)} )}
</Box> </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> <DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
@@ -615,11 +792,37 @@ class ViewManager extends Component {
</IconButton> </IconButton>
</> </>
)} )}
<ListItemText <Select
primary={ch.alias} size="small"
secondary={`${ch.device}:${ch.channel} (${ch.yAxis})`} value={ch.sma || 0}
sx={{ ml: 1 }} 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 === 0}><ArrowUpwardIcon /></IconButton>
<IconButton size="small" onClick={() => this.moveChannel(idx, 1)} disabled={idx === viewConfig.length - 1}><ArrowDownwardIcon /></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> <IconButton size="small" color="error" onClick={() => this.removeChannel(idx)}><DeleteIcon /></IconButton>

View File

@@ -12,7 +12,9 @@ config();
// Database connection for Dev Server API // Database connection for Dev Server API
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../server/data/sensors.db'); 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 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; let db;
try { try {
@@ -29,6 +31,22 @@ try {
) )
`); `);
// 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 // Helper to insert changelog entry
global.insertChangelog = (user, text) => { global.insertChangelog = (user, text) => {
try { try {
@@ -45,14 +63,36 @@ try {
console.error(`[UI Server] Failed to connect to database at ${dbPath}:`, err.message); console.error(`[UI Server] Failed to connect to database at ${dbPath}:`, err.message);
} }
// Output bindings: map virtual outputs to physical devices // Load output channels from database (replaces hardcoded OUTPUT_CHANNELS)
// Format: outputChannel -> { device, channel, type } function getOutputChannels() {
const OUTPUT_BINDINGS = { if (!db) return [];
'BigDehumid': { device: 'tapo', channel: 'r0', type: 'switch' }, const rows = db.prepare('SELECT * FROM output_configs ORDER BY position ASC').all();
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' }, return rows.map(r => ({
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' }, channel: r.channel,
'CircFanLevel': { device: 'ac', channel: 'tent:fan', type: 'level' }, 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) // WebSocket Server for Agents (port 3962)
@@ -312,6 +352,7 @@ function syncOutputStates() {
if (!db) return; if (!db) return;
try { try {
const bindings = getOutputBindings();
// Get current output values // Get current output values
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT channel, value FROM output_events SELECT channel, value FROM output_events
@@ -322,7 +363,7 @@ function syncOutputStates() {
for (const row of rows) { for (const row of rows) {
// Only sync non-zero values // Only sync non-zero values
if (row.value > 0) { if (row.value > 0) {
const binding = OUTPUT_BINDINGS[row.channel]; const binding = bindings[row.channel];
if (binding) { if (binding) {
let commandValue = row.value; let commandValue = row.value;
if (binding.type === 'switch') { if (binding.type === 'switch') {
@@ -353,14 +394,6 @@ setInterval(syncOutputStates, 60000);
// RULE ENGINE (Global Scope) // RULE ENGINE (Global Scope)
// ============================================= // =============================================
// 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 current sensor value // Get current sensor value
function getSensorValue(channel) { function getSensorValue(channel) {
// channel format: "device:channel" e.g. "ac:controller:co2" // channel format: "device:channel" e.g. "ac:controller:co2"
@@ -416,7 +449,8 @@ function writeOutputValue(channel, value) {
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`); console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
// Send command to bound physical device // Send command to bound physical device
const binding = OUTPUT_BINDINGS[channel]; const bindings = getOutputBindings();
const binding = bindings[channel];
if (binding) { if (binding) {
let commandValue = value; let commandValue = value;
if (binding.type === 'switch') { if (binding.type === 'switch') {
@@ -529,7 +563,8 @@ function runRules() {
// Default all outputs to OFF (0) - if no rule sets them, they stay off // Default all outputs to OFF (0) - if no rule sets them, they stay off
const desiredOutputs = {}; const desiredOutputs = {};
for (const ch of OUTPUT_CHANNELS) { const outputChannels = getOutputChannels();
for (const ch of outputChannels) {
desiredOutputs[ch.channel] = 0; desiredOutputs[ch.channel] = 0;
} }
@@ -570,12 +605,16 @@ function runRules() {
console.error('[RuleRunner] Error running rules:', err.message); console.error('[RuleRunner] Error running rules:', err.message);
} }
} }
// Also sync immediately on startup after a short delay // Also sync immediately on startup after a short delay
setTimeout(syncOutputStates, 5000); setTimeout(syncOutputStates, 5000);
// Start the WebSocket server // Start the WebSocket server
const agentWss = createAgentWebSocketServer(); const agentWss = createAgentWebSocketServer();
// Import API setup
const setupAllApis = require('./api');
module.exports = { module.exports = {
entry: './src/index.js', entry: './src/index.js',
output: { output: {
@@ -601,6 +640,13 @@ module.exports = {
test: /\.css$/, test: /\.css$/,
use: ['style-loader', 'css-loader'], use: ['style-loader', 'css-loader'],
}, },
{
// Fix for ESM modules in node_modules (MUI X Charts v8)
test: /\.m?js$/,
resolve: {
fullySpecified: false,
},
},
], ],
}, },
resolve: { resolve: {
@@ -612,377 +658,39 @@ module.exports = {
}), }),
], ],
devServer: { devServer: {
port: 3905, port: DEV_SERVER_PORT,
historyApiFallback: true, historyApiFallback: true,
hot: true, hot: true,
allowedHosts: 'all', allowedHosts: 'all',
client: {
webSocketURL: 'auto://0.0.0.0:0/ws',
progress: true,
},
setupMiddlewares: (middlewares, devServer) => { setupMiddlewares: (middlewares, devServer) => {
if (!devServer) { if (!devServer) {
throw new Error('webpack-dev-server is not defined'); throw new Error('webpack-dev-server is not defined');
} }
// API Endpoints // Setup body parser
const app = devServer.app; const app = devServer.app;
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
app.use(bodyParser.json()); app.use(bodyParser.json());
// --- Auth API --- // Setup all API routes from extracted modules
app.post('/api/login', (req, res) => { setupAllApis(app, {
const { username, password } = req.body; db,
try { bcrypt,
const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); jwt,
const user = stmt.get(username); JWT_SECRET,
getOutputChannels,
if (!user || !bcrypt.compareSync(password, user.password_hash)) { getOutputBindings,
return res.status(401).json({ error: 'Invalid credentials' }); runRules,
} activeRuleIds
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) // Start rule runner
const checkAuth = (req, res, next) => { const ruleRunnerInterval = setInterval(runRules, RULE_RUNNER_INTERVAL);
const authHeader = req.headers.authorization; console.log(`[RuleRunner] Started background job (${RULE_RUNNER_INTERVAL / 1000}s interval)`);
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);
global.insertChangelog(req.user.username, `Created view "${name}"`);
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 = ?');
// Get name before delete for logging
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 });
}
});
// 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) {
global.insertChangelog(req.user.username, `Updated view "${name}" (ID: ${req.params.id})`);
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
// Virtual output channel definitions - MOVED TO GLOBAL SCOPE
// 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/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 {
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
global.insertChangelog(req.user?.username || 'admin', `Updated rule "${name}" (ID: ${req.params.id})`);
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 = ?');
// Get name before delete
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 });
}
});
// =============================================
// RULE RUNNER (Background Job)
// =============================================
// Rule Engine functions moved to global scope
// Start rule runner (every 10 seconds)
const ruleRunnerInterval = setInterval(runRules, 10000);
console.log('[RuleRunner] Started background job (10s interval)');
// Clean up on server close // Clean up on server close
devServer.server?.on('close', () => { devServer.server?.on('close', () => {
@@ -990,155 +698,6 @@ module.exports = {
console.log('[RuleRunner] Stopped background job'); 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; return middlewares;
}, },
}, },

View File

@@ -1,48 +0,0 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.resolve(__dirname, 'server/data/sensors.db');
console.log(`Connecting to database at ${dbPath}`);
const db = new Database(dbPath);
// 1. Verify Table Creation
console.log('Creating changelog table...');
try {
db.exec(`
CREATE TABLE IF NOT EXISTS changelog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
user TEXT,
text TEXT NOT NULL
)
`);
console.log('PASS: Table creation successful (or already exists)');
} catch (err) {
console.error('FAIL: Table creation failed:', err.message);
process.exit(1);
}
// 2. Verify Insert
console.log('Inserting test entry...');
try {
const stmt = db.prepare('INSERT INTO changelog (date, user, text) VALUES (?, ?, ?)');
const info = stmt.run(new Date().toISOString(), 'test_user', 'Test changelog entry');
console.log(`PASS: Insert successful, ID: ${info.lastInsertRowid}`);
} catch (err) {
console.error('FAIL: Insert failed:', err.message);
process.exit(1);
}
// 3. Verify Read
console.log('Reading entries...');
try {
const rows = db.prepare('SELECT * FROM changelog ORDER BY id DESC LIMIT 5').all();
console.table(rows);
if (rows.length > 0 && rows[0].user === 'test_user') {
console.log('PASS: Read verification successful');
} else {
console.error('FAIL: Read verification failed or data mismatch');
}
} catch (err) {
console.error('FAIL: Read failed:', err.message);
}