Compare commits
26 Commits
a1793d0998
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27f52b0c3d | ||
|
|
86bea2fa6d | ||
|
|
e9a66cd1f4 | ||
|
|
758684c598 | ||
|
|
ad7a0d1768 | ||
|
|
1dfa59ae13 | ||
|
|
3d43a42b12 | ||
|
|
d586d12e68 | ||
|
|
94a435c6f6 | ||
|
|
93e3baa1c5 | ||
|
|
4f52064b3d | ||
|
|
822045b06d | ||
|
|
dcdfb27684 | ||
|
|
10556cb698 | ||
|
|
22701a2614 | ||
|
|
a09da9a835 | ||
|
|
c794dbaab8 | ||
|
|
ab89cbc97f | ||
|
|
8f01f35470 | ||
|
|
ebfc848c82 | ||
|
|
9db13f3589 | ||
|
|
4610d80c4f | ||
|
|
489e07d4e1 | ||
|
|
0aa7995a8a | ||
|
|
ce87faa551 | ||
|
|
acbf168218 |
139
README.md
139
README.md
@@ -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
|
|
||||||
@@ -229,6 +229,138 @@ export class ACInfinityClient {
|
|||||||
|
|
||||||
return readings;
|
return readings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDeviceModeSettings(devId, port) {
|
||||||
|
if (!this.isLoggedIn()) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.host}/api/dev/getdevModeSettingList`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||||
|
...this.getAuthHeaders(),
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
devId: devId,
|
||||||
|
port: port.toString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AC] Error getting device settings:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set device port mode/level
|
||||||
|
* @param {string} devId - Device ID
|
||||||
|
* @param {number} port - Port number (1-4)
|
||||||
|
* @param {number} level - Level 0-10
|
||||||
|
*/
|
||||||
|
async setDevicePort(devId, port, level) {
|
||||||
|
if (!this.isLoggedIn()) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get existing settings
|
||||||
|
const settings = await this.getDeviceModeSettings(devId, port);
|
||||||
|
if (!settings) throw new Error('Could not fetch existing settings');
|
||||||
|
|
||||||
|
// 2. Prepare updates
|
||||||
|
// Constrain level 0-10
|
||||||
|
const safeLevel = Math.max(0, Math.min(10, Math.round(level)));
|
||||||
|
|
||||||
|
// AtType Constants from reverse engineering
|
||||||
|
const AtType = {
|
||||||
|
OFF: 1,
|
||||||
|
ON: 2,
|
||||||
|
AUTO: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mode 2 = ON (Manual), 1 = OFF
|
||||||
|
const mode = safeLevel === 0 ? AtType.OFF : AtType.ON;
|
||||||
|
|
||||||
|
// Merge with existing settings
|
||||||
|
// We need to send back mostly specific keys.
|
||||||
|
// Based on reference usage, we can try merging into a new object using existing keys
|
||||||
|
// but 'mode' and 'speak' are overrides.
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Add required base params
|
||||||
|
params.append('userId', this.userId);
|
||||||
|
params.append('devId', devId);
|
||||||
|
params.append('port', port.toString());
|
||||||
|
|
||||||
|
// Add mode/speak
|
||||||
|
params.append('mode', mode.toString());
|
||||||
|
|
||||||
|
// NOTE: In Mode 1 (OFF), 'speak' sets the Minimum Speed (usually 0).
|
||||||
|
// In Mode 2 (ON), 'speak' sets the Maximum/Target Speed.
|
||||||
|
const speakValue = mode === AtType.OFF ? 0 : safeLevel;
|
||||||
|
params.append('speak', speakValue.toString());
|
||||||
|
|
||||||
|
// CRITICAL FIX: Explicitly set atType to match the mode!
|
||||||
|
// atType: 1 = OFF, 2 = ON, 3 = AUTO
|
||||||
|
params.append('atType', mode.toString());
|
||||||
|
|
||||||
|
// Ensure onSpead (Max Speed) matches target if in ON mode
|
||||||
|
if (mode === AtType.ON) {
|
||||||
|
params.append('onSpead', safeLevel.toString());
|
||||||
|
} else {
|
||||||
|
// In OFF mode, ensure onSpead is at least present (maybe 10 or 0? Leaving existing or default)
|
||||||
|
if (!params.has('onSpead')) params.append('onSpead', '10');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy other relevant fields from settings if they exist to maintain state
|
||||||
|
// Common fields seen in other implementations:
|
||||||
|
// transitionType, surplus, backup, trigger related fields...
|
||||||
|
// For addDevMode, usually just the basics + what we want to change is enough IF the server merges?
|
||||||
|
// But the error 999999 suggests missing fields.
|
||||||
|
// Let's copy everything from settings that looks like a config parameter
|
||||||
|
|
||||||
|
const keyBlocklist = ['devId', 'port', 'mode', 'speak', 'devName', 'deviceInfo', 'devType', 'macAddr'];
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(settings)) {
|
||||||
|
if (!keyBlocklist.includes(key) && typeof val !== 'object') {
|
||||||
|
params.append(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure defaults if missing
|
||||||
|
if (!params.has('surplus')) params.append('surplus', '0');
|
||||||
|
if (!params.has('backup')) params.append('backup', '0');
|
||||||
|
if (!params.has('transitionType')) params.append('transitionType', '0');
|
||||||
|
|
||||||
|
// 3. Send update
|
||||||
|
const response = await fetch(`${this.host}/api/dev/addDevMode?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||||
|
...this.getAuthHeaders(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code !== 200) {
|
||||||
|
throw new ACInfinityClientError(`Set mode failed: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AC] Set device ${devId} port ${port} to level ${safeLevel} (mode ${mode}: ${mode === 1 ? 'OFF' : 'ON'})`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AC] Error setting device port:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ACInfinityClient;
|
export default ACInfinityClient;
|
||||||
|
|||||||
@@ -60,6 +60,68 @@ async function main() {
|
|||||||
// Connect to WebSocket server
|
// Connect to WebSocket server
|
||||||
await wsClient.connect();
|
await wsClient.connect();
|
||||||
|
|
||||||
|
// Handle commands from server
|
||||||
|
wsClient.onCommand(async (cmd) => {
|
||||||
|
console.log('[Main] Received command:', cmd);
|
||||||
|
const { device, value } = cmd; // e.g. device="tent:fan"
|
||||||
|
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch latest device list to get IDs and port mapping
|
||||||
|
const devices = await acClient.getDevicesListAll();
|
||||||
|
|
||||||
|
// Parse "tent:fan" -> devName="tent", portName="fan"
|
||||||
|
// If just "tent", assume port 1 or device level
|
||||||
|
const parts = device.split(':');
|
||||||
|
const targetDevName = parts[0];
|
||||||
|
const targetPortName = parts[1];
|
||||||
|
|
||||||
|
// Find matching device by name
|
||||||
|
const dev = devices.find(d => {
|
||||||
|
const name = (d.devName || `device-${d.devId}`).toLowerCase();
|
||||||
|
return name.includes(targetDevName.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dev) {
|
||||||
|
console.error(`[Main] Device not found: ${targetDevName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find port index
|
||||||
|
// Structure varies: dev.deviceInfo.ports OR dev.devPortList
|
||||||
|
const info = dev.deviceInfo || dev;
|
||||||
|
const ports = info.ports || dev.devPortList || [];
|
||||||
|
|
||||||
|
let portId = 0; // 0 usually means "All" or "Device"? But setDevicePort expects 1-4.
|
||||||
|
// If explicit port set
|
||||||
|
if (targetPortName) {
|
||||||
|
const port = ports.find(p => {
|
||||||
|
const pName = (p.portName || `port${p.port || p.portId}`).toLowerCase();
|
||||||
|
return pName.includes(targetPortName.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (port) {
|
||||||
|
portId = port.port || port.portId;
|
||||||
|
} else {
|
||||||
|
// Check if it's a number
|
||||||
|
const pNum = parseInt(targetPortName);
|
||||||
|
if (!isNaN(pNum)) portId = pNum;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default to first port if available, or 0?
|
||||||
|
// Let's assume port 1 if no specific port requested but ports exist
|
||||||
|
if (ports.length > 0) portId = ports[0].port || ports[0].portId;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Main] Setting ${dev.devName} (${dev.devId}) port ${portId} to ${value}`);
|
||||||
|
await acClient.setDevicePort(dev.devId, portId, value);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Main] Error handling command:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Start polling
|
// Start polling
|
||||||
console.log(`[Main] Starting polling every ${config.pollIntervalMs / 1000}s`);
|
console.log(`[Main] Starting polling every ${config.pollIntervalMs / 1000}s`);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export class WSClient {
|
|||||||
this.pingTimer = null;
|
this.pingTimer = null;
|
||||||
this.messageQueue = [];
|
this.messageQueue = [];
|
||||||
this.onReadyCallback = null;
|
this.onReadyCallback = null;
|
||||||
|
this.onCommandCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommand(callback) {
|
||||||
|
this.onCommandCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +109,12 @@ export class WSClient {
|
|||||||
console.error('[WS] Server error:', message.error);
|
console.error('[WS] Server error:', message.error);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'command':
|
||||||
|
if (this.onCommandCallback) {
|
||||||
|
this.onCommandCallback(message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('[WS] Unknown message type:', message.type);
|
console.log('[WS] Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,3 +181,43 @@ echo -e " ${YELLOW}curl https://bashupload.com -F=@dist/tapo-countdown-pi3_pi4_
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Then on Pi, download and run:"
|
echo "Then on Pi, download and run:"
|
||||||
echo -e " ${YELLOW}curl -sSL https://bashupload.com/XXXXX -o tapo-agent && chmod +x tapo-agent${NC}"
|
echo -e " ${YELLOW}curl -sSL https://bashupload.com/XXXXX -o tapo-agent && chmod +x tapo-agent${NC}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}=========================================="
|
||||||
|
echo "Installing as User Service (no sudo needed)"
|
||||||
|
echo -e "==========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "1. Setup binary and config:"
|
||||||
|
echo -e " ${YELLOW}chmod +x ~/tapo-agent${NC}"
|
||||||
|
echo -e " ${YELLOW}mkdir -p ~/.config/tapo${NC}"
|
||||||
|
echo -e " ${YELLOW}cp /path/to/config.toml ~/.config/tapo/config.toml${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "2. Create service file:"
|
||||||
|
echo -e " ${YELLOW}mkdir -p ~/.config/systemd/user${NC}"
|
||||||
|
echo -e " ${YELLOW}cat > ~/.config/systemd/user/tapo-agent.service << 'EOF'"
|
||||||
|
echo "[Unit]"
|
||||||
|
echo "Description=Tapo Smart Plug Agent"
|
||||||
|
echo "After=network-online.target"
|
||||||
|
echo ""
|
||||||
|
echo "[Service]"
|
||||||
|
echo "Type=simple"
|
||||||
|
echo "ExecStart=%h/tapo-agent --config %h/.config/tapo/config.toml"
|
||||||
|
echo "Restart=always"
|
||||||
|
echo "RestartSec=10"
|
||||||
|
echo ""
|
||||||
|
echo "[Install]"
|
||||||
|
echo "WantedBy=default.target"
|
||||||
|
echo -e "EOF${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "3. Enable and start service:"
|
||||||
|
echo -e " ${YELLOW}systemctl --user daemon-reload${NC}"
|
||||||
|
echo -e " ${YELLOW}systemctl --user enable tapo-agent${NC}"
|
||||||
|
echo -e " ${YELLOW}systemctl --user start tapo-agent${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "4. Enable linger (service runs at boot, before login):"
|
||||||
|
echo -e " ${YELLOW}loginctl enable-linger \$USER${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "5. Manage service:"
|
||||||
|
echo -e " ${YELLOW}systemctl --user status tapo-agent${NC} # Check status"
|
||||||
|
echo -e " ${YELLOW}systemctl --user restart tapo-agent${NC} # Restart"
|
||||||
|
echo -e " ${YELLOW}journalctl --user -u tapo-agent -f${NC} # View logs"
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ struct Config {
|
|||||||
server_url: String,
|
server_url: String,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
poll_interval_secs: u64,
|
poll_interval_secs: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
command_url: Option<String>, // HTTP URL for command polling (e.g., http://localhost:3905/api/outputs/commands)
|
||||||
devices: Vec<DeviceConfig>,
|
devices: Vec<DeviceConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +178,7 @@ async fn discover_and_create_config(
|
|||||||
server_url: server,
|
server_url: server,
|
||||||
api_key: key,
|
api_key: key,
|
||||||
poll_interval_secs: 60,
|
poll_interval_secs: 60,
|
||||||
|
command_url: None,
|
||||||
devices,
|
devices,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,6 +409,36 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec<Reading> {
|
|||||||
readings
|
readings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Switch a device on or off
|
||||||
|
async fn switch_device(device: &DeviceConfig, turn_on: bool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let client = ApiClient::new(&device.tapo_email, &device.tapo_password);
|
||||||
|
|
||||||
|
match device.device_type.as_str() {
|
||||||
|
"P110" | "P115" => {
|
||||||
|
let plug = client.p110(&device.ip).await?;
|
||||||
|
if turn_on {
|
||||||
|
plug.on().await?;
|
||||||
|
} else {
|
||||||
|
plug.off().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"P100" | "P105" => {
|
||||||
|
let plug = client.p100(&device.ip).await?;
|
||||||
|
if turn_on {
|
||||||
|
plug.on().await?;
|
||||||
|
} else {
|
||||||
|
plug.off().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(format!("Unknown device type: {}", device.device_type).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[Switch] Device {} turned {}", device.name, if turn_on { "ON" } else { "OFF" });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_agent(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_agent(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@@ -447,6 +480,9 @@ async fn run_agent(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clone devices for command handling in main loop
|
||||||
|
let devices_for_commands = config.devices.clone();
|
||||||
|
|
||||||
// Connection and sending loop
|
// Connection and sending loop
|
||||||
let mut reconnect_delay = Duration::from_secs(1);
|
let mut reconnect_delay = Duration::from_secs(1);
|
||||||
let max_reconnect_delay = Duration::from_secs(60);
|
let max_reconnect_delay = Duration::from_secs(60);
|
||||||
@@ -510,6 +546,35 @@ async fn run_agent(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Handle incoming WebSocket messages
|
// Handle incoming WebSocket messages
|
||||||
msg = read.next() => {
|
msg = read.next() => {
|
||||||
match msg {
|
match msg {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
// Handle incoming commands from server
|
||||||
|
if let Ok(cmd) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
|
if cmd.get("type").and_then(|v| v.as_str()) == Some("command") {
|
||||||
|
let device_name = cmd.get("device").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let value = cmd.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
|
||||||
|
info!("[Command] Received: device={}, action={}, value={}", device_name, action, value);
|
||||||
|
|
||||||
|
// Find matching device in our config
|
||||||
|
if let Some(device) = devices_for_commands.iter().find(|d| d.name == device_name) {
|
||||||
|
if action == "set_state" {
|
||||||
|
let turn_on = value > 0;
|
||||||
|
info!("[Command] Switching {} {}", device_name, if turn_on { "ON" } else { "OFF" });
|
||||||
|
|
||||||
|
let device_clone = device.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = switch_device(&device_clone, turn_on).await {
|
||||||
|
error!("[Command] Failed to switch {}: {}", device_clone.name, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("[Command] Unknown device: {}", device_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(Ok(Message::Ping(data))) => {
|
Some(Ok(Message::Ping(data))) => {
|
||||||
let _ = write.send(Message::Pong(data)).await;
|
let _ = write.send(Message::Pong(data)).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
120
nginx_proxy.md
120
nginx_proxy.md
@@ -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/"
|
|
||||||
```
|
|
||||||
258
promptlog.txt
258
promptlog.txt
@@ -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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Server Environment Configuration
|
|
||||||
PORT=8080
|
|
||||||
DB_PATH=./data/sensors.db
|
|
||||||
|
|
||||||
# Job intervals (optional, defaults shown)
|
|
||||||
# AGGREGATION_INTERVAL_MS=600000
|
|
||||||
# CLEANUP_INTERVAL_MS=3600000
|
|
||||||
501
server/package-lock.json
generated
501
server/package-lock.json
generated
@@ -1,501 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tischlerctrl-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "tischlerctrl-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"better-sqlite3": "^11.6.0",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"ws": "^8.18.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/base64-js": {
|
|
||||||
"version": "1.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/better-sqlite3": {
|
|
||||||
"version": "11.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
|
||||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bindings": "^1.5.0",
|
|
||||||
"prebuild-install": "^7.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bindings": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"file-uri-to-path": "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer": "^5.5.0",
|
|
||||||
"inherits": "^2.0.4",
|
|
||||||
"readable-stream": "^3.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/buffer": {
|
|
||||||
"version": "5.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.1.13"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chownr": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/decompress-response": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mimic-response": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/deep-extend": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "16.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/end-of-stream": {
|
|
||||||
"version": "1.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
|
||||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"once": "^1.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expand-template": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
|
||||||
"license": "(MIT OR WTFPL)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/file-uri-to-path": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fs-constants": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/github-from-package": {
|
|
||||||
"version": "0.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
|
||||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ini": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/mimic-response": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimist": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mkdirp-classic": {
|
|
||||||
"version": "0.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
|
||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/napi-build-utils": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/node-abi": {
|
|
||||||
"version": "3.85.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
|
|
||||||
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"semver": "^7.3.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/once": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prebuild-install": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.0",
|
|
||||||
"expand-template": "^2.0.3",
|
|
||||||
"github-from-package": "0.0.0",
|
|
||||||
"minimist": "^1.2.3",
|
|
||||||
"mkdirp-classic": "^0.5.3",
|
|
||||||
"napi-build-utils": "^2.0.0",
|
|
||||||
"node-abi": "^3.3.0",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"rc": "^1.2.7",
|
|
||||||
"simple-get": "^4.0.0",
|
|
||||||
"tar-fs": "^2.0.0",
|
|
||||||
"tunnel-agent": "^0.6.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"prebuild-install": "bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pump": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"end-of-stream": "^1.1.0",
|
|
||||||
"once": "^1.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rc": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
|
||||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
|
||||||
"dependencies": {
|
|
||||||
"deep-extend": "^0.6.0",
|
|
||||||
"ini": "~1.3.0",
|
|
||||||
"minimist": "^1.2.0",
|
|
||||||
"strip-json-comments": "~2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"rc": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/semver": {
|
|
||||||
"version": "7.7.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-concat": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/simple-get": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"decompress-response": "^6.0.0",
|
|
||||||
"once": "^1.3.1",
|
|
||||||
"simple-concat": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string_decoder": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-json-comments": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar-fs": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chownr": "^1.1.1",
|
|
||||||
"mkdirp-classic": "^0.5.2",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"tar-stream": "^2.1.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar-stream": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bl": "^4.0.3",
|
|
||||||
"end-of-stream": "^1.4.1",
|
|
||||||
"fs-constants": "^1.0.0",
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"readable-stream": "^3.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tunnel-agent": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/util-deprecate": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/wrappy": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tischlerctrl-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Sensor data collection server with WebSocket API",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node src/index.js",
|
|
||||||
"dev": "node --watch src/index.js",
|
|
||||||
"generate-key": "node src/cli/generate-key.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"better-sqlite3": "^11.6.0",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"ws": "^8.18.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CLI tool to generate API keys for agents
|
|
||||||
* Usage: node generate-key.js <name> <device_prefix>
|
|
||||||
* Example: node generate-key.js "ac-infinity-agent" "ac:"
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { initDatabase } from '../db/schema.js';
|
|
||||||
import { generateApiKey, listApiKeys } from '../db/queries.js';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const dbPath = process.env.DB_PATH || join(__dirname, '..', '..', 'data', 'sensors.db');
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (args.length === 0 || args[0] === '--list') {
|
|
||||||
// List existing keys
|
|
||||||
const db = initDatabase(dbPath);
|
|
||||||
const keys = listApiKeys(db);
|
|
||||||
|
|
||||||
if (keys.length === 0) {
|
|
||||||
console.log('No API keys found.');
|
|
||||||
} else {
|
|
||||||
console.log('\nExisting API keys:\n');
|
|
||||||
console.log('ID | Name | Prefix | Preview | Last Used');
|
|
||||||
console.log('-'.repeat(75));
|
|
||||||
for (const key of keys) {
|
|
||||||
const lastUsed = key.last_used_at || 'never';
|
|
||||||
console.log(`${key.id.toString().padEnd(3)} | ${key.name.padEnd(20)} | ${key.device_prefix.padEnd(7)} | ${key.key_preview.padEnd(12)} | ${lastUsed}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nUsage: node generate-key.js <name> <device_prefix>');
|
|
||||||
console.log('Example: node generate-key.js "ac-infinity-agent" "ac:"');
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.length < 2) {
|
|
||||||
console.error('Error: Both name and device_prefix are required');
|
|
||||||
console.error('Usage: node generate-key.js <name> <device_prefix>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [name, devicePrefix] = args;
|
|
||||||
|
|
||||||
const db = initDatabase(dbPath);
|
|
||||||
const key = generateApiKey(db, name, devicePrefix);
|
|
||||||
|
|
||||||
console.log('\n✓ API key generated successfully!\n');
|
|
||||||
console.log(`Name: ${name}`);
|
|
||||||
console.log(`Device Prefix: ${devicePrefix}`);
|
|
||||||
console.log(`API Key: ${key}`);
|
|
||||||
console.log('\n⚠ Save this key securely - it cannot be recovered!\n');
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
config({ path: join(__dirname, '..', '.env') });
|
|
||||||
|
|
||||||
export default {
|
|
||||||
port: parseInt(process.env.PORT || '8080', 10),
|
|
||||||
dbPath: process.env.DB_PATH || join(__dirname, '..', 'data', 'sensors.db'),
|
|
||||||
|
|
||||||
// Job intervals
|
|
||||||
aggregationIntervalMs: parseInt(process.env.AGGREGATION_INTERVAL_MS || String(10 * 60 * 1000), 10),
|
|
||||||
cleanupIntervalMs: parseInt(process.env.CLEANUP_INTERVAL_MS || String(60 * 60 * 1000), 10),
|
|
||||||
};
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database query functions for sensor data operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an API key and return the associated metadata
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @param {string} apiKey - The API key to validate
|
|
||||||
* @returns {object|null} - API key metadata or null if invalid
|
|
||||||
*/
|
|
||||||
export function validateApiKey(db, apiKey) {
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
SELECT id, name, device_prefix
|
|
||||||
FROM api_keys
|
|
||||||
WHERE key = ?
|
|
||||||
`);
|
|
||||||
const result = stmt.get(apiKey);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// Update last_used_at timestamp
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?
|
|
||||||
`).run(result.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a new API key
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @param {string} name - Name/description for the API key
|
|
||||||
* @param {string} devicePrefix - Prefix to prepend to device names (e.g., "ac:", "tapo:")
|
|
||||||
* @returns {string} - The generated API key
|
|
||||||
*/
|
|
||||||
export function generateApiKey(db, name, devicePrefix) {
|
|
||||||
const key = crypto.randomBytes(32).toString('hex');
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO api_keys (key, name, device_prefix)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`).run(key, name, devicePrefix);
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert sensor readings with RLE (Run-Length Encoding) logic
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @param {string} devicePrefix - Prefix to prepend to device names
|
|
||||||
* @param {Array} readings - Array of readings
|
|
||||||
* @param {Date} timestamp - Timestamp for all readings
|
|
||||||
*/
|
|
||||||
export function insertReadingsSmart(db, devicePrefix, readings, timestamp = new Date()) {
|
|
||||||
const isoTimestamp = timestamp.toISOString();
|
|
||||||
|
|
||||||
const stmtLast = db.prepare(`
|
|
||||||
SELECT id, value, data, data_type
|
|
||||||
FROM sensor_events
|
|
||||||
WHERE device = ? AND channel = ?
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
|
|
||||||
const stmtUpdate = db.prepare(`
|
|
||||||
UPDATE sensor_events SET until = ? WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const stmtInsert = db.prepare(`
|
|
||||||
INSERT INTO sensor_events (timestamp, until, device, channel, value, data, data_type)
|
|
||||||
VALUES (?, NULL, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const transaction = db.transaction((items) => {
|
|
||||||
let inserted = 0;
|
|
||||||
let updated = 0;
|
|
||||||
|
|
||||||
for (const reading of items) {
|
|
||||||
const fullDevice = `${devicePrefix}${reading.device}`;
|
|
||||||
const channel = reading.channel;
|
|
||||||
|
|
||||||
// Determine type and values
|
|
||||||
let dataType = 'number';
|
|
||||||
let value = null;
|
|
||||||
let data = null;
|
|
||||||
|
|
||||||
if (reading.value !== undefined && reading.value !== null) {
|
|
||||||
dataType = 'number';
|
|
||||||
value = reading.value;
|
|
||||||
} else if (reading.data !== undefined) {
|
|
||||||
dataType = 'json';
|
|
||||||
data = typeof reading.data === 'string' ? reading.data : JSON.stringify(reading.data);
|
|
||||||
} else {
|
|
||||||
continue; // Skip invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check last reading for RLE
|
|
||||||
const last = stmtLast.get(fullDevice, channel);
|
|
||||||
let isDuplicate = false;
|
|
||||||
|
|
||||||
if (last && last.data_type === dataType) {
|
|
||||||
if (dataType === 'number') {
|
|
||||||
// Compare defined numbers with small epsilon? Or exact match?
|
|
||||||
// For sensors, exact match is typical for RLE if "identical".
|
|
||||||
if (Math.abs(last.value - value) < Number.EPSILON) {
|
|
||||||
isDuplicate = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Compare JSON strings
|
|
||||||
if (last.data === data) {
|
|
||||||
isDuplicate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDuplicate) {
|
|
||||||
stmtUpdate.run(isoTimestamp, last.id);
|
|
||||||
updated++;
|
|
||||||
} else {
|
|
||||||
stmtInsert.run(isoTimestamp, fullDevice, channel, value, data, dataType);
|
|
||||||
inserted++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { inserted, updated };
|
|
||||||
});
|
|
||||||
|
|
||||||
return transaction(readings);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporary stubs for aggregators until they are redesigned for the new schema
|
|
||||||
export function aggregate10Minutes(db) { return 0; }
|
|
||||||
export function aggregate1Hour(db) { return 0; }
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up old data according to retention policy
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @returns {object} - Number of deleted records per table
|
|
||||||
*/
|
|
||||||
export function cleanupOldData(db) {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Delete events older than 30 days
|
|
||||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
const eventsDeleted = db.prepare(`
|
|
||||||
DELETE FROM sensor_events WHERE timestamp < ?
|
|
||||||
`).run(monthAgo.toISOString());
|
|
||||||
|
|
||||||
return {
|
|
||||||
eventsDeleted: eventsDeleted.changes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all API keys (without showing the actual key values)
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @returns {Array} - List of API key metadata
|
|
||||||
*/
|
|
||||||
export function listApiKeys(db) {
|
|
||||||
return db.prepare(`
|
|
||||||
SELECT id, name, device_prefix, created_at, last_used_at,
|
|
||||||
substr(key, 1, 8) || '...' as key_preview
|
|
||||||
FROM api_keys
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`).all();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
validateApiKey,
|
|
||||||
generateApiKey,
|
|
||||||
insertReadingsSmart,
|
|
||||||
aggregate10Minutes,
|
|
||||||
aggregate1Hour,
|
|
||||||
cleanupOldData,
|
|
||||||
listApiKeys
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import Database from 'better-sqlite3';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the SQLite database with all required tables
|
|
||||||
* @param {string} dbPath - Path to the SQLite database file
|
|
||||||
* @returns {Database} - The initialized database instance
|
|
||||||
*/
|
|
||||||
export function initDatabase(dbPath) {
|
|
||||||
// Ensure data directory exists
|
|
||||||
const dataDir = dirname(dbPath);
|
|
||||||
if (!existsSync(dataDir)) {
|
|
||||||
mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent performance
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
|
|
||||||
// Create tables
|
|
||||||
// API keys for agent authentication
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key TEXT UNIQUE NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
device_prefix TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_used_at DATETIME
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// --- MIGRATION: Drop old tables if they exist ---
|
|
||||||
// User requested deleting old sensor data but keeping keys.
|
|
||||||
db.exec(`
|
|
||||||
DROP TABLE IF EXISTS sensor_data;
|
|
||||||
DROP TABLE IF EXISTS sensor_data_10m;
|
|
||||||
DROP TABLE IF EXISTS sensor_data_1h;
|
|
||||||
`);
|
|
||||||
|
|
||||||
// --- NEW SCHEMA: Sensor Events with RLE support ---
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS sensor_events (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
timestamp DATETIME NOT NULL,
|
|
||||||
until DATETIME, -- NULL if point, Time if duplicated range end
|
|
||||||
device TEXT NOT NULL,
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
value REAL, -- Nullable
|
|
||||||
data TEXT, -- Nullable (JSON)
|
|
||||||
data_type TEXT NOT NULL -- 'number' or 'json'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sensor_events_search
|
|
||||||
ON sensor_events(device, channel, timestamp);
|
|
||||||
|
|
||||||
-- Phase 2: Authentication & Views
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL CHECK(role IN ('admin', 'normal')),
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS views (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT UNIQUE NOT NULL,
|
|
||||||
config TEXT NOT NULL, -- JSON string of view configuration
|
|
||||||
created_by INTEGER,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY(created_by) REFERENCES users(id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('[DB] Database initialized successfully');
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { initDatabase };
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import config from './config.js';
|
|
||||||
import { initDatabase } from './db/schema.js';
|
|
||||||
import { createWebSocketServer } from './websocket/server.js';
|
|
||||||
import { startAggregationJob } from './jobs/aggregator.js';
|
|
||||||
import { startCleanupJob } from './jobs/cleanup.js';
|
|
||||||
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
console.log('TischlerCtrl Sensor Server');
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
const db = initDatabase(config.dbPath);
|
|
||||||
|
|
||||||
// Start WebSocket server
|
|
||||||
const wss = createWebSocketServer({
|
|
||||||
port: config.port,
|
|
||||||
db
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start background jobs
|
|
||||||
const aggregationTimer = startAggregationJob(db, config.aggregationIntervalMs);
|
|
||||||
const cleanupTimer = startCleanupJob(db, config.cleanupIntervalMs);
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
function shutdown() {
|
|
||||||
console.log('\n[Server] Shutting down...');
|
|
||||||
|
|
||||||
clearInterval(aggregationTimer);
|
|
||||||
clearInterval(cleanupTimer);
|
|
||||||
|
|
||||||
wss.close(() => {
|
|
||||||
db.close();
|
|
||||||
console.log('[Server] Goodbye!');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force exit after 5 seconds
|
|
||||||
setTimeout(() => process.exit(1), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGINT', shutdown);
|
|
||||||
process.on('SIGTERM', shutdown);
|
|
||||||
|
|
||||||
console.log('[Server] Ready to accept connections');
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { aggregate10Minutes, aggregate1Hour, cleanupOldData } from '../db/queries.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the aggregation job that runs periodically
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @param {number} intervalMs - Interval in milliseconds (default: 10 minutes)
|
|
||||||
* @returns {NodeJS.Timer} - The interval timer
|
|
||||||
*/
|
|
||||||
export function startAggregationJob(db, intervalMs = 10 * 60 * 1000) {
|
|
||||||
console.log(`[Aggregator] Starting aggregation job (interval: ${intervalMs / 1000}s)`);
|
|
||||||
|
|
||||||
// Run immediately on start
|
|
||||||
runAggregation(db);
|
|
||||||
|
|
||||||
// Then run periodically
|
|
||||||
return setInterval(() => runAggregation(db), intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the aggregation process
|
|
||||||
*/
|
|
||||||
function runAggregation(db) {
|
|
||||||
try {
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
// Aggregate raw data to 10-minute buckets
|
|
||||||
const count10m = aggregate10Minutes(db);
|
|
||||||
|
|
||||||
// Aggregate 10-minute data to 1-hour buckets
|
|
||||||
const count1h = aggregate1Hour(db);
|
|
||||||
|
|
||||||
const elapsed = Date.now() - start;
|
|
||||||
|
|
||||||
if (count10m > 0 || count1h > 0) {
|
|
||||||
console.log(`[Aggregator] Completed in ${elapsed}ms: ${count10m} 10m records, ${count1h} 1h records`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Aggregator] Error during aggregation:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { startAggregationJob };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { cleanupOldData } from '../db/queries.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the cleanup job that runs periodically
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @param {number} intervalMs - Interval in milliseconds (default: 1 hour)
|
|
||||||
* @returns {NodeJS.Timer} - The interval timer
|
|
||||||
*/
|
|
||||||
export function startCleanupJob(db, intervalMs = 60 * 60 * 1000) {
|
|
||||||
console.log(`[Cleanup] Starting cleanup job (interval: ${intervalMs / 1000}s)`);
|
|
||||||
|
|
||||||
// Run after a delay on start (don't compete with aggregator)
|
|
||||||
setTimeout(() => runCleanup(db), 5 * 60 * 1000);
|
|
||||||
|
|
||||||
// Then run periodically
|
|
||||||
return setInterval(() => runCleanup(db), intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the cleanup process
|
|
||||||
*/
|
|
||||||
function runCleanup(db) {
|
|
||||||
try {
|
|
||||||
const start = Date.now();
|
|
||||||
const result = cleanupOldData(db);
|
|
||||||
const elapsed = Date.now() - start;
|
|
||||||
|
|
||||||
if (result.rawDeleted > 0 || result.aggregatedDeleted > 0) {
|
|
||||||
console.log(`[Cleanup] Completed in ${elapsed}ms: deleted ${result.rawDeleted} raw, ${result.aggregatedDeleted} 10m records`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Cleanup] Error during cleanup:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { startCleanupJob };
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { WebSocketServer } from 'ws';
|
|
||||||
import { validateApiKey, insertReadingsSmart } from '../db/queries.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and configure the WebSocket server
|
|
||||||
* @param {object} options - Server options
|
|
||||||
* @param {number} options.port - Port to listen on
|
|
||||||
* @param {Database} options.db - SQLite database instance
|
|
||||||
* @returns {WebSocketServer} - The WebSocket server instance
|
|
||||||
*/
|
|
||||||
export function createWebSocketServer({ port, db }) {
|
|
||||||
const wss = new WebSocketServer({ port });
|
|
||||||
|
|
||||||
// Track authenticated clients
|
|
||||||
const clients = new Map();
|
|
||||||
|
|
||||||
wss.on('connection', (ws, req) => {
|
|
||||||
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
|
|
||||||
console.log(`[WS] Client connected: ${clientId}`);
|
|
||||||
|
|
||||||
// Client state
|
|
||||||
const clientState = {
|
|
||||||
authenticated: false,
|
|
||||||
devicePrefix: null,
|
|
||||||
name: null,
|
|
||||||
lastPong: Date.now()
|
|
||||||
};
|
|
||||||
clients.set(ws, clientState);
|
|
||||||
|
|
||||||
// Set up ping/pong for keepalive
|
|
||||||
ws.isAlive = true;
|
|
||||||
ws.on('pong', () => {
|
|
||||||
ws.isAlive = true;
|
|
||||||
clientState.lastPong = Date.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(data.toString());
|
|
||||||
handleMessage(ws, message, clientState, db);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[WS] Error parsing message from ${clientId}:`, err.message);
|
|
||||||
sendError(ws, 'Invalid JSON message');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
console.log(`[WS] Client disconnected: ${clientId} (${clientState.name || 'unauthenticated'})`);
|
|
||||||
clients.delete(ws);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('error', (err) => {
|
|
||||||
console.error(`[WS] Error for ${clientId}:`, err.message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ping interval to detect dead connections
|
|
||||||
const pingInterval = setInterval(() => {
|
|
||||||
wss.clients.forEach((ws) => {
|
|
||||||
if (ws.isAlive === false) {
|
|
||||||
console.log('[WS] Terminating unresponsive client');
|
|
||||||
return ws.terminate();
|
|
||||||
}
|
|
||||||
ws.isAlive = false;
|
|
||||||
ws.ping();
|
|
||||||
});
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
wss.on('close', () => {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[WS] WebSocket server listening on port ${port}`);
|
|
||||||
return wss;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming WebSocket messages
|
|
||||||
* @param {WebSocket} ws - The WebSocket connection
|
|
||||||
* @param {object} message - Parsed message object
|
|
||||||
* @param {object} clientState - Client state object
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
*/
|
|
||||||
function handleMessage(ws, message, clientState, db) {
|
|
||||||
const { type } = message;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'auth':
|
|
||||||
handleAuth(ws, message, clientState, db);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'data':
|
|
||||||
handleData(ws, message, clientState, db);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'pong':
|
|
||||||
// Client responded to our ping
|
|
||||||
clientState.lastPong = Date.now();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
sendError(ws, `Unknown message type: ${type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle authentication request
|
|
||||||
*/
|
|
||||||
function handleAuth(ws, message, clientState, db) {
|
|
||||||
const { apiKey } = message;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
return sendError(ws, 'Missing apiKey in auth message');
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyInfo = validateApiKey(db, apiKey);
|
|
||||||
|
|
||||||
if (!keyInfo) {
|
|
||||||
send(ws, { type: 'auth', success: false, error: 'Invalid API key' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clientState.authenticated = true;
|
|
||||||
clientState.devicePrefix = keyInfo.device_prefix;
|
|
||||||
clientState.name = keyInfo.name;
|
|
||||||
|
|
||||||
console.log(`[WS] Client authenticated: ${keyInfo.name} (prefix: ${keyInfo.device_prefix})`);
|
|
||||||
|
|
||||||
send(ws, {
|
|
||||||
type: 'auth',
|
|
||||||
success: true,
|
|
||||||
devicePrefix: keyInfo.device_prefix,
|
|
||||||
name: keyInfo.name
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle data ingestion
|
|
||||||
*/
|
|
||||||
function handleData(ws, message, clientState, db) {
|
|
||||||
if (!clientState.authenticated) {
|
|
||||||
return sendError(ws, 'Not authenticated. Send auth message first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { readings } = message;
|
|
||||||
|
|
||||||
if (!Array.isArray(readings) || readings.length === 0) {
|
|
||||||
return sendError(ws, 'Invalid readings: expected non-empty array');
|
|
||||||
}
|
|
||||||
|
|
||||||
const validReadings = [];
|
|
||||||
let skippedCount = 0;
|
|
||||||
|
|
||||||
// Validate readings format
|
|
||||||
for (const reading of readings) {
|
|
||||||
// We require device, channel, and EITHER value (number) OR data (json)
|
|
||||||
if (!reading.device || !reading.channel) {
|
|
||||||
console.warn(`[WS] Skipped invalid reading (missing device/channel) from ${clientState.name}:`, JSON.stringify(reading));
|
|
||||||
skippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasValue = reading.value !== undefined && reading.value !== null;
|
|
||||||
const hasData = reading.data !== undefined;
|
|
||||||
|
|
||||||
if (!hasValue && !hasData) {
|
|
||||||
console.warn(`[WS] Skipped invalid reading (no value/data) from ${clientState.name}:`, JSON.stringify(reading));
|
|
||||||
skippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
validReadings.push(reading);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validReadings.length === 0) {
|
|
||||||
if (skippedCount > 0) {
|
|
||||||
console.log(`[WS] Received ${skippedCount} readings, but all were invalid.`);
|
|
||||||
return send(ws, { type: 'ack', count: 0 });
|
|
||||||
}
|
|
||||||
return sendError(ws, 'No valid readings found in batch');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = insertReadingsSmart(db, clientState.devicePrefix, validReadings);
|
|
||||||
const count = result.inserted + result.updated;
|
|
||||||
|
|
||||||
if (skippedCount > 0) {
|
|
||||||
console.log(`[WS] Processed ${count} readings (inserted: ${result.inserted}, updated: ${result.updated}, skipped: ${skippedCount}).`);
|
|
||||||
}
|
|
||||||
send(ws, { type: 'ack', count });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[WS] Error inserting readings:', err.message);
|
|
||||||
sendError(ws, 'Failed to insert readings');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a message to a WebSocket client
|
|
||||||
*/
|
|
||||||
function send(ws, message) {
|
|
||||||
if (ws.readyState === 1) { // OPEN
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an error message
|
|
||||||
*/
|
|
||||||
function sendError(ws, error) {
|
|
||||||
send(ws, { type: 'error', error });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { createWebSocketServer };
|
|
||||||
14
uiserver/.env.example
Normal file
14
uiserver/.env.example
Normal 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
28
uiserver/api/auth.js
Normal 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
26
uiserver/api/devices.js
Normal 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
45
uiserver/api/index.js
Normal 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 });
|
||||||
|
};
|
||||||
162
uiserver/api/output-config.js
Normal file
162
uiserver/api/output-config.js
Normal 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
75
uiserver/api/outputs.js
Normal 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
124
uiserver/api/readings.js
Normal 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
166
uiserver/api/rules.js
Normal 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
186
uiserver/api/views.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Views API - CRUD for dashboard views
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function setupViewsApi(app, { db, checkAuth, requireAdmin }) {
|
||||||
|
// Apply checkAuth middleware to views routes
|
||||||
|
app.use('/api/views', checkAuth);
|
||||||
|
|
||||||
|
// POST /api/views - Create view (admin only)
|
||||||
|
app.post('/api/views', requireAdmin, (req, res) => {
|
||||||
|
const { name, config } = req.body;
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)');
|
||||||
|
const info = stmt.run(name, JSON.stringify(config), req.user.id);
|
||||||
|
global.insertChangelog(req.user.username, `Created view "${name}"`);
|
||||||
|
res.json({ id: info.lastInsertRowid, name, config });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/views - List all views (public)
|
||||||
|
app.get('/api/views', (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
|
||||||
|
const rows = stmt.all();
|
||||||
|
const views = rows.map(row => {
|
||||||
|
try {
|
||||||
|
return { ...row, config: JSON.parse(row.config) };
|
||||||
|
} catch (e) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(views);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/views/:id - Get single view
|
||||||
|
app.get('/api/views/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT * FROM views WHERE id = ?');
|
||||||
|
const view = stmt.get(req.params.id);
|
||||||
|
if (view) {
|
||||||
|
view.config = JSON.parse(view.config);
|
||||||
|
res.json(view);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/views/:id - Delete view (admin only)
|
||||||
|
app.delete('/api/views/:id', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('DELETE FROM views WHERE id = ?');
|
||||||
|
const viewName = db.prepare('SELECT name FROM views WHERE id = ?').get(req.params.id)?.name || 'Unknown View';
|
||||||
|
const info = stmt.run(req.params.id);
|
||||||
|
if (info.changes > 0) {
|
||||||
|
global.insertChangelog(req.user.username, `Deleted view "${viewName}" (ID: ${req.params.id})`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/views/:id - Update view (admin only)
|
||||||
|
app.put('/api/views/:id', requireAdmin, (req, res) => {
|
||||||
|
const { name, config } = req.body;
|
||||||
|
try {
|
||||||
|
// Get old view for comparison
|
||||||
|
const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id);
|
||||||
|
if (!oldView) {
|
||||||
|
return res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
|
||||||
|
const info = stmt.run(name, JSON.stringify(config), req.params.id);
|
||||||
|
if (info.changes > 0) {
|
||||||
|
// Build detailed changelog
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
// Check name change
|
||||||
|
if (oldView.name !== name) {
|
||||||
|
changes.push(`renamed: "${oldView.name}" → "${name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse configs for comparison
|
||||||
|
let oldConfig = {};
|
||||||
|
try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { }
|
||||||
|
const newConfig = config || {};
|
||||||
|
|
||||||
|
// Compare channels
|
||||||
|
const oldChannels = (oldConfig.channels || []).map(ch =>
|
||||||
|
typeof ch === 'string' ? ch : ch.channel
|
||||||
|
);
|
||||||
|
const newChannels = (newConfig.channels || []).map(ch =>
|
||||||
|
typeof ch === 'string' ? ch : ch.channel
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = newChannels.filter(ch => !oldChannels.includes(ch));
|
||||||
|
const removed = oldChannels.filter(ch => !newChannels.includes(ch));
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
changes.push(`added channels: ${added.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (removed.length > 0) {
|
||||||
|
changes.push(`removed channels: ${removed.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for color/fill changes
|
||||||
|
const oldChannelConfigs = {};
|
||||||
|
(oldConfig.channels || []).forEach(ch => {
|
||||||
|
if (typeof ch === 'object') {
|
||||||
|
oldChannelConfigs[ch.channel] = ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const newChannelConfigs = {};
|
||||||
|
(newConfig.channels || []).forEach(ch => {
|
||||||
|
if (typeof ch === 'object') {
|
||||||
|
newChannelConfigs[ch.channel] = ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorChanges = [];
|
||||||
|
for (const ch of newChannels) {
|
||||||
|
const oldCh = oldChannelConfigs[ch] || {};
|
||||||
|
const newCh = newChannelConfigs[ch] || {};
|
||||||
|
if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) {
|
||||||
|
colorChanges.push(ch.split(':').pop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colorChanges.length > 0) {
|
||||||
|
changes.push(`colors changed for: ${colorChanges.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check order change
|
||||||
|
if (added.length === 0 && removed.length === 0 &&
|
||||||
|
JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) {
|
||||||
|
changes.push('channel order changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeText = changes.length > 0
|
||||||
|
? `Updated view "${name}": ${changes.join('; ')}`
|
||||||
|
: `Updated view "${name}" (no significant changes)`;
|
||||||
|
global.insertChangelog(req.user.username, changeText);
|
||||||
|
|
||||||
|
res.json({ id: req.params.id, name, config });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/views/reorder - Reorder views (admin only)
|
||||||
|
app.post('/api/views/reorder', requireAdmin, (req, res) => {
|
||||||
|
const { order } = req.body;
|
||||||
|
console.log('[API] Reorder request:', order);
|
||||||
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
|
||||||
|
|
||||||
|
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
|
||||||
|
const updateMany = db.transaction((items) => {
|
||||||
|
for (const item of items) {
|
||||||
|
console.log('[API] Updating view', item.id, 'to position', item.position);
|
||||||
|
updateStmt.run(item.position, item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateMany(order);
|
||||||
|
console.log('[API] Reorder successful');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Reorder error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
17
uiserver/debug_db.js
Normal file
17
uiserver/debug_db.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.resolve(__dirname, '../server/data/sensors.db');
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
|
||||||
|
console.log('--- RULES ---');
|
||||||
|
const rules = db.prepare('SELECT * FROM rules').all();
|
||||||
|
console.log(JSON.stringify(rules, null, 2));
|
||||||
|
|
||||||
|
console.log('\n--- OUTPUT CHANNELS ---');
|
||||||
|
const outputs = db.prepare("SELECT * FROM output_events WHERE channel = 'CircFanLevel' ORDER BY timestamp DESC LIMIT 10").all();
|
||||||
|
console.table(outputs);
|
||||||
|
|
||||||
|
console.log('\n--- SENSOR DATA (ac:tent:temperature) ---');
|
||||||
|
const sensors = db.prepare("SELECT * FROM sensor_events WHERE device = 'ac' AND channel = 'tent:temperature' ORDER BY timestamp DESC LIMIT 5").all();
|
||||||
|
console.table(sensors);
|
||||||
691
uiserver/package-lock.json
generated
691
uiserver/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,16 +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 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 OutputConfigEditor from './components/OutputConfigEditor';
|
||||||
|
|
||||||
const darkTheme = createTheme({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@@ -32,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');
|
||||||
@@ -61,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);
|
||||||
@@ -81,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.
|
||||||
|
|
||||||
@@ -93,12 +77,16 @@ 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>
|
||||||
<Button color="inherit" component={Link} to="/live" startIcon={<ShowChartIcon />}>Live</Button>
|
{user && user.role === 'admin' && (
|
||||||
<Button color="inherit" component={Link} to="/settings" startIcon={<SettingsIcon />}>Settings</Button>
|
<>
|
||||||
|
<Button color="inherit" component={Link} to="/rules" startIcon={<RuleIcon />}>Rules</Button>
|
||||||
|
<Button color="inherit" component={Link} to="/outputs" startIcon={<SettingsInputComponentIcon />}>Outputs</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<Button color="inherit" onClick={this.handleLogout}>Logout ({user.username})</Button>
|
<Button color="inherit" onClick={this.handleLogout}>Logout ({user.username})</Button>
|
||||||
@@ -111,17 +99,8 @@ export default class App extends Component {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<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="/live" element={
|
<Route path="/rules" element={<RuleEditor user={user} />} />
|
||||||
<Chart
|
<Route path="/outputs" element={<OutputConfigEditor user={user} />} />
|
||||||
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>
|
||||||
|
|||||||
345
uiserver/src/components/Chart.js
vendored
345
uiserver/src/components/Chart.js
vendored
@@ -1,17 +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 }
|
||||||
|
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() {
|
||||||
@@ -52,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) {
|
||||||
@@ -85,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();
|
||||||
@@ -92,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]));
|
||||||
@@ -114,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
|
||||||
@@ -165,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);
|
||||||
@@ -181,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
|
||||||
|
|
||||||
@@ -219,20 +410,47 @@ export default class Chart extends Component {
|
|||||||
return { min: axisMin, max: axisMax };
|
return { min: axisMin, max: axisMax };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSeries = (seriesId) => {
|
||||||
|
this.setState(prev => ({
|
||||||
|
hiddenSeries: {
|
||||||
|
...prev.hiddenSeries,
|
||||||
|
[seriesId]: !prev.hiddenSeries[seriesId]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { loading, data } = 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);
|
||||||
|
|
||||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||||
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
|
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
|
||||||
|
|
||||||
const series = effectiveChannels.map(id => {
|
// Build legend config (all channels, for rendering custom legend)
|
||||||
|
const legendItems = effectiveChannels.map(id => {
|
||||||
|
let label = id;
|
||||||
|
let color = '#888';
|
||||||
|
if (channelConfig) {
|
||||||
|
const item = channelConfig.find(c => c.id === id);
|
||||||
|
if (item) {
|
||||||
|
if (item.alias) label = item.alias;
|
||||||
|
if (item.color) color = item.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id, label, color, hidden: !!hiddenSeries[id] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out hidden series
|
||||||
|
const visibleChannels = effectiveChannels.filter(id => !hiddenSeries[id]);
|
||||||
|
|
||||||
|
const series = visibleChannels.map(id => {
|
||||||
// Find alias and axis if config exists
|
// Find alias and axis if config exists
|
||||||
let label = id;
|
let label = id;
|
||||||
let yAxisKey = 'left';
|
let yAxisKey = 'left';
|
||||||
let color = undefined;
|
let color = undefined;
|
||||||
let fillColor = undefined;
|
let fillColor = undefined;
|
||||||
|
let fillOpacity = 0.5;
|
||||||
if (channelConfig) {
|
if (channelConfig) {
|
||||||
const item = channelConfig.find(c => c.id === id);
|
const item = channelConfig.find(c => c.id === id);
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -240,6 +458,7 @@ export default class Chart extends Component {
|
|||||||
if (item.yAxis) yAxisKey = item.yAxis;
|
if (item.yAxis) yAxisKey = item.yAxis;
|
||||||
if (item.color) color = item.color;
|
if (item.color) color = item.color;
|
||||||
if (item.fillColor) fillColor = item.fillColor;
|
if (item.fillColor) fillColor = item.fillColor;
|
||||||
|
if (item.fillOpacity !== undefined) fillOpacity = item.fillOpacity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,32 +467,27 @@ 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 50% transparency)
|
// Enable area fill if fillColor is set (with configurable opacity)
|
||||||
if (fillColor) {
|
if (fillColor) {
|
||||||
sObj.area = true;
|
sObj.area = true;
|
||||||
// Convert hex to rgba with 50% opacity
|
sObj.fillOpacity = fillOpacity;
|
||||||
const hex = fillColor.replace('#', '');
|
|
||||||
const r = parseInt(hex.substring(0, 2), 16);
|
|
||||||
const g = parseInt(hex.substring(2, 4), 16);
|
|
||||||
const b = parseInt(hex.substring(4, 6), 16);
|
|
||||||
sObj.areaColor = `rgba(${r}, ${g}, ${b}, 0.5)`;
|
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -281,9 +495,76 @@ 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 */}
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, justifyContent: 'center', mb: 1, py: 0.5 }}>
|
||||||
|
{legendItems.map(item => {
|
||||||
|
const flash = flashStates[item.id];
|
||||||
|
const flashColor = flash === 'up' ? 'rgba(76, 175, 80, 0.4)' : flash === 'down' ? 'rgba(244, 67, 54, 0.4)' : 'transparent';
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => this.toggleSeries(item.id)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: item.hidden ? 0.4 : 1,
|
||||||
|
textDecoration: item.hidden ? 'line-through' : 'none',
|
||||||
|
transition: 'opacity 0.2s, background-color 0.3s',
|
||||||
|
userSelect: 'none',
|
||||||
|
backgroundColor: flashColor,
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 0.5,
|
||||||
|
'&:hover': { opacity: item.hidden ? 0.6 : 0.8 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: item.color,
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: item.hidden ? 'grey.500' : item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" component="span">
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
|
<Box sx={{ flexGrow: 1, width: '100%', height: '100%' }}>
|
||||||
<LineChart
|
<LineChart
|
||||||
dataset={data}
|
dataset={data}
|
||||||
@@ -296,24 +577,32 @@ 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: {
|
|
||||||
direction: 'row',
|
|
||||||
position: { vertical: 'top', horizontal: 'middle' },
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
lineHighlight: { strokeWidth: 3 },
|
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiLineElement-root': {
|
'& .MuiLineElement-root': {
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
},
|
},
|
||||||
'& .MuiAreaElement-root': {
|
'& .MuiAreaElement-root': {
|
||||||
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>
|
||||||
|
|||||||
371
uiserver/src/components/OutputConfigEditor.js
Normal file
371
uiserver/src/components/OutputConfigEditor.js
Normal 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;
|
||||||
821
uiserver/src/components/RuleEditor.js
Normal file
821
uiserver/src/components/RuleEditor.js
Normal file
@@ -0,0 +1,821 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
||||||
|
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
FormControl, InputLabel, Select, MenuItem, Box, IconButton, Switch,
|
||||||
|
FormControlLabel, Chip, Divider, Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
|
import RuleIcon from '@mui/icons-material/Rule';
|
||||||
|
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 PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import PauseIcon from '@mui/icons-material/Pause';
|
||||||
|
|
||||||
|
// Condition operators by type
|
||||||
|
const CONDITION_OPERATORS = {
|
||||||
|
time: [
|
||||||
|
{ value: 'between', label: 'Between' },
|
||||||
|
{ value: '=', label: '=' },
|
||||||
|
{ value: '<', label: '<' },
|
||||||
|
{ value: '>', label: '>' }
|
||||||
|
],
|
||||||
|
date: [
|
||||||
|
{ value: 'before', label: 'Before' },
|
||||||
|
{ value: 'after', label: 'After' },
|
||||||
|
{ value: 'between', label: 'Between' }
|
||||||
|
],
|
||||||
|
sensor: [
|
||||||
|
{ value: '=', label: '=' },
|
||||||
|
{ value: '!=', label: '!=' },
|
||||||
|
{ value: '<', label: '<' },
|
||||||
|
{ value: '>', label: '>' },
|
||||||
|
{ value: '<=', label: '<=' },
|
||||||
|
{ value: '>=', label: '>=' }
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
{ value: '=', label: '=' },
|
||||||
|
{ value: '!=', label: '!=' },
|
||||||
|
{ value: '<', label: '<' },
|
||||||
|
{ value: '>', label: '>' },
|
||||||
|
{ value: '<=', label: '<=' },
|
||||||
|
{ value: '>=', label: '>=' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
class RuleEditor extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
rules: [],
|
||||||
|
outputChannels: [],
|
||||||
|
devices: [],
|
||||||
|
outputValues: {},
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
open: false,
|
||||||
|
editingId: null,
|
||||||
|
ruleName: '',
|
||||||
|
ruleEnabled: true,
|
||||||
|
conditions: { operator: 'AND', conditions: [] },
|
||||||
|
action: { channel: '', value: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.refreshRules();
|
||||||
|
this.loadOutputChannels();
|
||||||
|
this.loadDevices();
|
||||||
|
this.loadOutputValues();
|
||||||
|
// Refresh output values every 10s
|
||||||
|
this.refreshInterval = setInterval(() => this.loadOutputValues(), 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.refreshInterval) clearInterval(this.refreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin() {
|
||||||
|
const { user } = this.props;
|
||||||
|
return user && user.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshRules = () => {
|
||||||
|
fetch('/api/rules')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(rules => this.setState({ rules }))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOutputChannels = () => {
|
||||||
|
fetch('/api/outputs')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(outputChannels => this.setState({ outputChannels }))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDevices = () => {
|
||||||
|
fetch('/api/devices')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(devices => this.setState({ devices }))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOutputValues = () => {
|
||||||
|
fetch('/api/outputs/values')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(outputValues => this.setState({ outputValues }))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dialog handlers
|
||||||
|
handleOpenCreate = () => {
|
||||||
|
this.setState({
|
||||||
|
editingId: null,
|
||||||
|
ruleName: '',
|
||||||
|
ruleEnabled: true,
|
||||||
|
conditions: { operator: 'AND', conditions: [] },
|
||||||
|
action: { channel: this.state.outputChannels[0]?.channel || '', value: 0 },
|
||||||
|
open: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOpenEdit = (rule, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setState({
|
||||||
|
editingId: rule.id,
|
||||||
|
ruleName: rule.name,
|
||||||
|
ruleEnabled: !!rule.enabled,
|
||||||
|
conditions: rule.conditions || { operator: 'AND', conditions: [] },
|
||||||
|
action: rule.action || { channel: '', value: 0 },
|
||||||
|
open: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDelete = async (id, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!window.confirm("Delete this rule?")) return;
|
||||||
|
const { user } = this.props;
|
||||||
|
await fetch(`/api/rules/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${user.token}` }
|
||||||
|
});
|
||||||
|
this.refreshRules();
|
||||||
|
};
|
||||||
|
|
||||||
|
moveRule = async (idx, dir) => {
|
||||||
|
const newRules = [...this.state.rules];
|
||||||
|
const target = idx + dir;
|
||||||
|
if (target < 0 || target >= newRules.length) return;
|
||||||
|
|
||||||
|
[newRules[idx], newRules[target]] = [newRules[target], newRules[idx]];
|
||||||
|
this.setState({ rules: newRules });
|
||||||
|
|
||||||
|
const order = newRules.map((r, i) => ({ id: r.id, position: i }));
|
||||||
|
const { user } = this.props;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/rules/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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSave = async () => {
|
||||||
|
const { ruleName, ruleEnabled, conditions, action, editingId } = this.state;
|
||||||
|
const { user } = this.props;
|
||||||
|
|
||||||
|
if (!ruleName || !action.channel) {
|
||||||
|
alert('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = editingId ? `/api/rules/${editingId}` : '/api/rules';
|
||||||
|
const method = editingId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${user.token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: ruleName,
|
||||||
|
enabled: ruleEnabled,
|
||||||
|
conditions,
|
||||||
|
action
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
this.setState({ open: false });
|
||||||
|
this.refreshRules();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert('Failed to save rule: ' + err.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleRuleEnabled = async (rule) => {
|
||||||
|
const { user } = this.props;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/rules/${rule.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${user.token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...rule,
|
||||||
|
enabled: !rule.enabled
|
||||||
|
})
|
||||||
|
});
|
||||||
|
this.refreshRules();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Condition editing
|
||||||
|
addCondition = (parentPath = []) => {
|
||||||
|
this.setState(prev => {
|
||||||
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
||||||
|
let target = newConditions;
|
||||||
|
for (const idx of parentPath) {
|
||||||
|
target = target.conditions[idx];
|
||||||
|
}
|
||||||
|
target.conditions.push({
|
||||||
|
type: 'sensor',
|
||||||
|
operator: '>',
|
||||||
|
channel: '',
|
||||||
|
value: 0
|
||||||
|
});
|
||||||
|
return { conditions: newConditions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addConditionGroup = (parentPath = [], groupType = 'AND') => {
|
||||||
|
this.setState(prev => {
|
||||||
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
||||||
|
let target = newConditions;
|
||||||
|
for (const idx of parentPath) {
|
||||||
|
target = target.conditions[idx];
|
||||||
|
}
|
||||||
|
target.conditions.push({
|
||||||
|
operator: groupType,
|
||||||
|
conditions: []
|
||||||
|
});
|
||||||
|
return { conditions: newConditions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCondition = (path, updates) => {
|
||||||
|
this.setState(prev => {
|
||||||
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
||||||
|
let target = newConditions;
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
target = target.conditions[path[i]];
|
||||||
|
}
|
||||||
|
const idx = path[path.length - 1];
|
||||||
|
target.conditions[idx] = { ...target.conditions[idx], ...updates };
|
||||||
|
return { conditions: newConditions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
removeCondition = (path) => {
|
||||||
|
this.setState(prev => {
|
||||||
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
||||||
|
let target = newConditions;
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
target = target.conditions[path[i]];
|
||||||
|
}
|
||||||
|
const idx = path[path.length - 1];
|
||||||
|
target.conditions.splice(idx, 1);
|
||||||
|
return { conditions: newConditions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleGroupOperator = (path) => {
|
||||||
|
this.setState(prev => {
|
||||||
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
||||||
|
let target = newConditions;
|
||||||
|
for (const idx of path) {
|
||||||
|
target = target.conditions[idx];
|
||||||
|
}
|
||||||
|
if (path.length === 0) {
|
||||||
|
// Root level
|
||||||
|
newConditions.operator = newConditions.operator === 'AND' ? 'OR' : 'AND';
|
||||||
|
} else {
|
||||||
|
target.operator = target.operator === 'AND' ? 'OR' : 'AND';
|
||||||
|
}
|
||||||
|
return { conditions: newConditions };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a condition group recursively
|
||||||
|
renderConditionGroup = (group, path = []) => {
|
||||||
|
const { devices, outputChannels } = this.state;
|
||||||
|
const isRoot = path.length === 0;
|
||||||
|
|
||||||
|
// Build sensor channels list
|
||||||
|
const sensorChannels = devices.map(d => `${d.device}:${d.channel}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
pl: isRoot ? 0 : 2,
|
||||||
|
borderLeft: isRoot ? 'none' : '2px solid',
|
||||||
|
borderColor: group.operator === 'AND' ? '#83a598' : '#fabd2f',
|
||||||
|
ml: isRoot ? 0 : 1,
|
||||||
|
mb: 1
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={group.operator}
|
||||||
|
size="small"
|
||||||
|
color={group.operator === 'AND' ? 'primary' : 'warning'}
|
||||||
|
onClick={this.isAdmin() ? () => this.toggleGroupOperator(path) : undefined}
|
||||||
|
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }}
|
||||||
|
/>
|
||||||
|
{this.isAdmin() && (
|
||||||
|
<>
|
||||||
|
<Button size="small" onClick={() => this.addCondition(path)}>+ Condition</Button>
|
||||||
|
<Button size="small" onClick={() => this.addConditionGroup(path, 'AND')}>+ AND Group</Button>
|
||||||
|
<Button size="small" onClick={() => this.addConditionGroup(path, 'OR')}>+ OR Group</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{group.conditions?.map((cond, idx) => {
|
||||||
|
const condPath = [...path, idx];
|
||||||
|
|
||||||
|
// Nested group
|
||||||
|
if (cond.operator === 'AND' || cond.operator === 'OR') {
|
||||||
|
return (
|
||||||
|
<Box key={idx} sx={{ mb: 1 }}>
|
||||||
|
{this.renderConditionGroup(cond, condPath)}
|
||||||
|
{this.isAdmin() && (
|
||||||
|
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single condition
|
||||||
|
return (
|
||||||
|
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={cond.type || 'sensor'}
|
||||||
|
onChange={e => this.updateCondition(condPath, { type: e.target.value, operator: CONDITION_OPERATORS[e.target.value][0].value })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ minWidth: 100 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="time">Time</MenuItem>
|
||||||
|
<MenuItem value="date">Date</MenuItem>
|
||||||
|
<MenuItem value="sensor">Sensor</MenuItem>
|
||||||
|
<MenuItem value="output">Output</MenuItem>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{(cond.type === 'sensor' || cond.type === 'output') && (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={cond.channel || ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, { channel: e.target.value })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ minWidth: 180 }}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Select Channel</em></MenuItem>
|
||||||
|
{(cond.type === 'sensor' ? sensorChannels : outputChannels.map(c => c.channel))
|
||||||
|
.map(ch => <MenuItem key={ch} value={ch}>{ch}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={cond.operator || '='}
|
||||||
|
onChange={e => this.updateCondition(condPath, { operator: e.target.value })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ minWidth: 80 }}
|
||||||
|
>
|
||||||
|
{(CONDITION_OPERATORS[cond.type] || CONDITION_OPERATORS.sensor).map(op => (
|
||||||
|
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{cond.operator === 'between' ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type={cond.type === 'time' ? 'time' : 'date'}
|
||||||
|
value={Array.isArray(cond.value) ? cond.value[0] : ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: [e.target.value, (cond.value?.[1] || '')] })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<Typography>to</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type={cond.type === 'time' ? 'time' : 'date'}
|
||||||
|
value={Array.isArray(cond.value) ? cond.value[1] : ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: [(cond.value?.[0] || ''), e.target.value] })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
cond.type === 'sensor' ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{/* Dynamic Target Toggle */}
|
||||||
|
<Tooltip title="Compare to Value or Another Sensor">
|
||||||
|
<Chip
|
||||||
|
label={cond.value?.type === 'dynamic' ? 'Sensor' : 'Value'}
|
||||||
|
size="small"
|
||||||
|
color={cond.value?.type === 'dynamic' ? 'secondary' : 'default'}
|
||||||
|
onClick={this.isAdmin() ? () => {
|
||||||
|
const isDynamic = cond.value?.type === 'dynamic';
|
||||||
|
this.updateCondition(condPath, {
|
||||||
|
value: isDynamic
|
||||||
|
? 0 // Switch to static
|
||||||
|
: { type: 'dynamic', channel: '', factor: 1, offset: 0 } // Switch to dynamic
|
||||||
|
});
|
||||||
|
} : undefined}
|
||||||
|
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default', minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{cond.value?.type === 'dynamic' ? (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={cond.value.channel || ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, channel: e.target.value } })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Target Sensor</em></MenuItem>
|
||||||
|
{sensorChannels.map(ch => <MenuItem key={ch} value={ch}>{ch}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
<Typography>*</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Factor"
|
||||||
|
type="number"
|
||||||
|
value={cond.value.factor}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, factor: parseFloat(e.target.value) || 0 } })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
<Typography>+</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Offset"
|
||||||
|
type="number"
|
||||||
|
value={cond.value.offset}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: { ...cond.value, offset: parseFloat(e.target.value) || 0 } })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={cond.value ?? ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, { value: parseFloat(e.target.value) || 0 })}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type={cond.type === 'time' ? 'time' : (cond.type === 'date' ? 'date' : 'number')}
|
||||||
|
value={cond.value ?? ''}
|
||||||
|
onChange={e => this.updateCondition(condPath, {
|
||||||
|
value: cond.type === 'output'
|
||||||
|
? parseFloat(e.target.value) || 0
|
||||||
|
: e.target.value
|
||||||
|
})}
|
||||||
|
disabled={!this.isAdmin()}
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{this.isAdmin() && (
|
||||||
|
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
formatConditionSummary = (condition) => {
|
||||||
|
if (!condition) return '';
|
||||||
|
|
||||||
|
if (condition.operator === 'AND' || condition.operator === 'OR') {
|
||||||
|
const parts = (condition.conditions || []).map(c => this.formatConditionSummary(c)).filter(Boolean);
|
||||||
|
return parts.length > 0 ? `(${parts.join(` ${condition.operator} `)})` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, channel, operator, value } = condition;
|
||||||
|
let formatted = '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'time':
|
||||||
|
formatted = operator === 'between'
|
||||||
|
? `${value?.[0] || '?'} - ${value?.[1] || '?'}`
|
||||||
|
: `time ${operator} ${value}`;
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
formatted = operator === 'between'
|
||||||
|
? `date ${value?.[0] || '?'} to ${value?.[1] || '?'}`
|
||||||
|
: `date ${operator} ${value}`;
|
||||||
|
break;
|
||||||
|
case 'sensor':
|
||||||
|
if (value && value.type === 'dynamic') {
|
||||||
|
formatted = `${channel} ${operator} (${value.channel} * ${value.factor} + ${value.offset})`;
|
||||||
|
} else {
|
||||||
|
formatted = `${channel || '?'} ${operator} ${value}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
formatted = `${channel || '?'} ${operator} ${value}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
formatted = JSON.stringify(condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { rules, outputChannels, outputValues, open, editingId, ruleName, ruleEnabled, conditions, action } = this.state;
|
||||||
|
const isAdmin = this.isAdmin();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||||
|
<Paper sx={{ p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="h5">
|
||||||
|
<RuleIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Rule Editor
|
||||||
|
</Typography>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
|
||||||
|
Create Rule
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Current Output Values */}
|
||||||
|
<Paper sx={{ p: 2, mb: 4 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>Current Output Values</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
{outputChannels.map(ch => (
|
||||||
|
<Chip
|
||||||
|
key={ch.channel}
|
||||||
|
label={`${ch.description}: ${outputValues[ch.channel] ?? 0}`}
|
||||||
|
color={outputValues[ch.channel] > 0 ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Rules List */}
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>Rules (Priority Order)</Typography>
|
||||||
|
<List>
|
||||||
|
{rules.map((rule, idx) => (
|
||||||
|
<ListItem
|
||||||
|
key={rule.id}
|
||||||
|
sx={{
|
||||||
|
bgcolor: rule.enabled ? 'transparent' : 'rgba(0,0,0,0.2)',
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
border: '1px solid #504945'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{isAdmin ? (
|
||||||
|
<IconButton onClick={() => this.toggleRuleEnabled(rule)}>
|
||||||
|
{rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />}
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />
|
||||||
|
)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="subtitle1">{rule.name}</Typography>
|
||||||
|
<Chip size="small" label={rule.type || 'static'} />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
When: {this.formatConditionSummary(rule.conditions)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Then: Set {rule.action?.channel} = {
|
||||||
|
rule.action?.value?.type === 'calculated'
|
||||||
|
? `(${rule.action.value.sensorA} - ${rule.action.value.sensorB || '0'}) * ${rule.action.value.factor} + ${rule.action.value.offset}`
|
||||||
|
: rule.action?.value
|
||||||
|
}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<IconButton size="small" onClick={() => this.moveRule(idx, -1)} disabled={idx === 0}>
|
||||||
|
<ArrowUpwardIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={() => this.moveRule(idx, 1)} disabled={idx === rules.length - 1}>
|
||||||
|
<ArrowDownwardIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={(e) => this.handleOpenEdit(rule, e)}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton color="error" onClick={(e) => this.handleDelete(rule.id, e)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<Typography color="text.secondary" sx={{ p: 2 }}>
|
||||||
|
No rules defined. {isAdmin ? 'Click "Create Rule" to add one.' : ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Edit/Create Dialog */}
|
||||||
|
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>{editingId ? 'Edit Rule' : 'Create New Rule'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Rule Name"
|
||||||
|
value={ruleName}
|
||||||
|
onChange={e => this.setState({ ruleName: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={ruleEnabled}
|
||||||
|
onChange={e => this.setState({ ruleEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enabled"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" gutterBottom>Conditions (When)</Typography>
|
||||||
|
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}>
|
||||||
|
{this.renderConditionGroup(conditions)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" gutterBottom>Action (Then)</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{/* Value Type Toggle */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">Value Type:</Typography>
|
||||||
|
<Chip
|
||||||
|
label={action.value?.type === 'calculated' ? 'Calculated' : 'Static'}
|
||||||
|
color={action.value?.type === 'calculated' ? 'secondary' : 'default'}
|
||||||
|
onClick={() => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: action.value?.type === 'calculated'
|
||||||
|
? 0 // Reset to static
|
||||||
|
: { type: 'calculated', sensorA: '', sensorB: '', factor: 1, offset: 0 }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography>Set</Typography>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={action.channel}
|
||||||
|
onChange={e => this.setState({ action: { ...action, channel: e.target.value } })}
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
>
|
||||||
|
{outputChannels.map(ch => (
|
||||||
|
<MenuItem key={ch.channel} value={ch.channel}>
|
||||||
|
{ch.description} ({ch.channel})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Typography>=</Typography>
|
||||||
|
|
||||||
|
{action.value?.type === 'calculated' ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1, border: '1px solid #444', borderRadius: 1 }}>
|
||||||
|
<Typography>(</Typography>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={action.value.sensorA || ''}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, sensorA: e.target.value }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Sensor A</em></MenuItem>
|
||||||
|
{this.state.devices.map(d => `${d.device}:${d.channel}`).map(ch => (
|
||||||
|
<MenuItem key={ch} value={ch}>{ch}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Typography>-</Typography>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={action.value.sensorB || ''}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, sensorB: e.target.value }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Sensor B (0)</em></MenuItem>
|
||||||
|
{this.state.devices.map(d => `${d.device}:${d.channel}`).map(ch => (
|
||||||
|
<MenuItem key={ch} value={ch}>{ch}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Typography>)</Typography>
|
||||||
|
<Typography>*</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Factor"
|
||||||
|
value={action.value.factor}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, factor: parseFloat(e.target.value) || 0 }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
sx={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
<Typography>+</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Offset"
|
||||||
|
value={action.value.offset}
|
||||||
|
onChange={e => this.setState({
|
||||||
|
action: {
|
||||||
|
...action,
|
||||||
|
value: { ...action.value, offset: parseFloat(e.target.value) || 0 }
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
sx={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={action.value}
|
||||||
|
onChange={e => this.setState({ action: { ...action, value: parseFloat(e.target.value) || 0 } })}
|
||||||
|
inputProps={{
|
||||||
|
min: outputChannels.find(c => c.channel === action.channel)?.min || 0,
|
||||||
|
max: outputChannels.find(c => c.channel === action.channel)?.max || 10
|
||||||
|
}}
|
||||||
|
sx={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
|
||||||
|
<Button onClick={this.handleSave} variant="contained">Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RuleEditor;
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
||||||
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton,
|
FormControl, InputLabel, Select, MenuItem, Box, Chip, IconButton,
|
||||||
ToggleButton, ToggleButtonGroup
|
ToggleButton, ToggleButtonGroup, Slider
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
@@ -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
|
||||||
@@ -42,6 +50,9 @@ class ViewManager extends Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
views: [],
|
views: [],
|
||||||
|
rules: [],
|
||||||
|
activeRuleIds: [],
|
||||||
|
outputValues: {},
|
||||||
open: false,
|
open: false,
|
||||||
colorPickerOpen: false,
|
colorPickerOpen: false,
|
||||||
colorPickerMode: 'line',
|
colorPickerMode: 'line',
|
||||||
@@ -69,6 +80,15 @@ class ViewManager extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.refreshViews();
|
this.refreshViews();
|
||||||
|
this.loadRules();
|
||||||
|
this.loadOutputValues();
|
||||||
|
this.loadRuleStatus(); // Load immediately on mount
|
||||||
|
// Refresh rules and outputs every 30s
|
||||||
|
this.rulesInterval = setInterval(() => {
|
||||||
|
this.loadRules();
|
||||||
|
this.loadOutputValues();
|
||||||
|
this.loadRuleStatus();
|
||||||
|
}, 5000);
|
||||||
if (this.isAdmin()) {
|
if (this.isAdmin()) {
|
||||||
fetch('/api/devices')
|
fetch('/api/devices')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -77,6 +97,10 @@ class ViewManager extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.rulesInterval) clearInterval(this.rulesInterval);
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (prevProps.user !== this.props.user) {
|
if (prevProps.user !== this.props.user) {
|
||||||
this.refreshViews();
|
this.refreshViews();
|
||||||
@@ -101,6 +125,27 @@ class ViewManager extends Component {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
loadRules = () => {
|
||||||
|
fetch('/api/rules')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(rules => this.setState({ rules }))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOutputValues = () => {
|
||||||
|
fetch('/api/outputs/values')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(outputValues => this.setState({ outputValues }))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadRuleStatus = () => {
|
||||||
|
fetch('/api/rules/status')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => this.setState({ activeRuleIds: data.activeIds || [] }))
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
parseViewData(view) {
|
parseViewData(view) {
|
||||||
let channels = [];
|
let channels = [];
|
||||||
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
|
let axes = { left: { min: '', max: '' }, right: { min: '', max: '' } };
|
||||||
@@ -131,6 +176,135 @@ class ViewManager extends Component {
|
|||||||
return { channels, axes };
|
return { channels, axes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emoji for rule based on action channel
|
||||||
|
getRuleEmoji = (rule) => {
|
||||||
|
return '⚡';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format conditions for display - returns React components with visual grouping
|
||||||
|
formatRuleConditions = (condition, depth = 0) => {
|
||||||
|
if (!condition) return <span style={{ color: '#888' }}>(always)</span>;
|
||||||
|
|
||||||
|
if (condition.operator === 'AND' || condition.operator === 'OR') {
|
||||||
|
const parts = (condition.conditions || []).map((c, i) => this.formatRuleConditions(c, depth + 1)).filter(Boolean);
|
||||||
|
if (parts.length === 0) return <span style={{ color: '#888' }}>(always)</span>;
|
||||||
|
|
||||||
|
const isAnd = condition.operator === 'AND';
|
||||||
|
const borderColor = isAnd ? 'rgba(100, 150, 255, 0.5)' : 'rgba(255, 150, 100, 0.5)';
|
||||||
|
const bgColor = isAnd ? 'rgba(100, 150, 255, 0.08)' : 'rgba(255, 150, 100, 0.08)';
|
||||||
|
const label = isAnd ? 'ALL' : 'ANY';
|
||||||
|
const symbol = isAnd ? 'and' : 'or';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: bgColor,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
fontSize: depth > 0 ? '0.9em' : '1em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isAnd ? '#6496ff' : '#ff9664',
|
||||||
|
mr: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}:
|
||||||
|
</Typography>
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{part}
|
||||||
|
{i < parts.length - 1 && (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
mx: 0.5,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isAnd ? '#6496ff' : '#ff9664',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{symbol}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, channel, operator, value } = condition;
|
||||||
|
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
|
||||||
|
const op = opSymbols[operator] || operator;
|
||||||
|
|
||||||
|
let text = '?';
|
||||||
|
switch (type) {
|
||||||
|
case 'time':
|
||||||
|
if (operator === 'between' && Array.isArray(value)) {
|
||||||
|
text = `🕐 ${value[0]} - ${value[1]}`;
|
||||||
|
} else {
|
||||||
|
text = `🕐 ${op} ${value}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
if (operator === 'between' && Array.isArray(value)) {
|
||||||
|
text = `📅 ${value[0]} to ${value[1]}`;
|
||||||
|
} else {
|
||||||
|
text = `📅 ${operator} ${value}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'sensor':
|
||||||
|
if (value && value.type === 'dynamic') {
|
||||||
|
text = `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
|
||||||
|
} else {
|
||||||
|
text = `📡 ${channel} ${op} ${value}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
text = `⚙️ ${channel} ${op} ${value}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
text = '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
px: 0.5,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format action for display
|
||||||
|
formatRuleAction = (action) => {
|
||||||
|
if (!action?.channel) return '?';
|
||||||
|
const name = action.channel;
|
||||||
|
|
||||||
|
if (action.value && action.value.type === 'calculated') {
|
||||||
|
return `${name} = (${action.value.sensorA} - ${action.value.sensorB || '0'}) * ${action.value.factor} + ${action.value.offset}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${name} = ${action.value}`;
|
||||||
|
};
|
||||||
|
|
||||||
getNextColor(idx) {
|
getNextColor(idx) {
|
||||||
return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length];
|
return GRUVBOX_COLORS[idx % GRUVBOX_COLORS.length];
|
||||||
}
|
}
|
||||||
@@ -305,6 +479,25 @@ class ViewManager extends Component {
|
|||||||
clearFillColor = (idx) => {
|
clearFillColor = (idx) => {
|
||||||
const newConfig = [...this.state.viewConfig];
|
const newConfig = [...this.state.viewConfig];
|
||||||
delete newConfig[idx].fillColor;
|
delete newConfig[idx].fillColor;
|
||||||
|
delete newConfig[idx].fillOpacity;
|
||||||
|
this.setState({ viewConfig: newConfig });
|
||||||
|
};
|
||||||
|
|
||||||
|
updateChannel = (idx, updates) => {
|
||||||
|
const newConfig = this.state.viewConfig.map((ch, i) => {
|
||||||
|
if (i === idx) return { ...ch, ...updates };
|
||||||
|
return ch;
|
||||||
|
});
|
||||||
|
this.setState({ viewConfig: newConfig });
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFillOpacity = (idx, value) => {
|
||||||
|
const newConfig = this.state.viewConfig.map((ch, i) => {
|
||||||
|
if (i === idx) {
|
||||||
|
return { ...ch, fillOpacity: value };
|
||||||
|
}
|
||||||
|
return ch;
|
||||||
|
});
|
||||||
this.setState({ viewConfig: newConfig });
|
this.setState({ viewConfig: newConfig });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,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,
|
||||||
@@ -348,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>
|
||||||
@@ -391,7 +680,9 @@ class ViewManager extends Component {
|
|||||||
alias: c.alias,
|
alias: c.alias,
|
||||||
yAxis: c.yAxis || 'left',
|
yAxis: c.yAxis || 'left',
|
||||||
color: c.color,
|
color: c.color,
|
||||||
fillColor: c.fillColor
|
fillColor: c.fillColor,
|
||||||
|
fillOpacity: c.fillOpacity,
|
||||||
|
sma: c.sma || 0
|
||||||
}))}
|
}))}
|
||||||
axisConfig={axes}
|
axisConfig={axes}
|
||||||
windowEnd={windowEnd}
|
windowEnd={windowEnd}
|
||||||
@@ -402,9 +693,54 @@ class ViewManager extends Component {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{views.length === 0 && <Typography>No views available.</Typography>}
|
{views.length === 0 && <Typography>No views available.</Typography>}
|
||||||
|
|
||||||
|
{/* Rules Summary */}
|
||||||
|
{this.state.rules.length > 0 && (
|
||||||
|
<Paper sx={{ p: 2, mt: 4 }}>
|
||||||
|
<Typography variant="h5" sx={{ mb: 2 }}>🤖 Active Rules</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{this.state.rules.filter(r => r.enabled).map((rule, idx) => {
|
||||||
|
const isActive = this.state.activeRuleIds.includes(rule.id);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={rule.id}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: isActive ? 'rgba(76, 175, 80, 0.15)' : 'background.paper',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: isActive ? '1px solid #4caf50' : '1px solid #504945',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: '1.2em' }}>
|
||||||
|
{this.getRuleEmoji(rule)}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{rule.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{this.formatRuleConditions(rule.conditions)} → {this.formatRuleAction(rule.action)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontSize: '0.85em', color: 'text.secondary' }}>
|
||||||
|
#{idx + 1}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
@@ -440,15 +776,53 @@ class ViewManager extends Component {
|
|||||||
<Box sx={{ width: 20, height: 20, bgcolor: ch.fillColor || 'transparent', borderRadius: '50%', border: ch.fillColor ? '2px solid #fff' : '2px dashed #666' }} />
|
<Box sx={{ width: 20, height: 20, bgcolor: ch.fillColor || 'transparent', borderRadius: '50%', border: ch.fillColor ? '2px solid #fff' : '2px dashed #666' }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{ch.fillColor && (
|
{ch.fillColor && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
size="small"
|
||||||
|
value={ch.fillOpacity ?? 0.5}
|
||||||
|
min={0.1}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(e, val) => this.updateFillOpacity(idx, val)}
|
||||||
|
sx={{ width: 60, ml: 1 }}
|
||||||
|
title="Fill opacity"
|
||||||
|
/>
|
||||||
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
|
<IconButton size="small" onClick={() => this.clearFillColor(idx)} title="Remove fill" sx={{ ml: -0.5 }}>
|
||||||
<DeleteIcon sx={{ fontSize: 14 }} />
|
<DeleteIcon sx={{ fontSize: 14 }} />
|
||||||
</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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const Database = require('better-sqlite3');
|
|||||||
const { config } = require('dotenv');
|
const { config } = require('dotenv');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
// Load env vars
|
// Load env vars
|
||||||
config();
|
config();
|
||||||
@@ -11,15 +12,609 @@ 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 = 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 {
|
||||||
db = new Database(dbPath);
|
db = new Database(dbPath);
|
||||||
console.log(`[UI Server] Connected to database at ${dbPath}`);
|
console.log(`[UI Server] Connected to database at ${dbPath}`);
|
||||||
|
|
||||||
|
// Create changelog table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS changelog (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
user TEXT,
|
||||||
|
text TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create output_configs table (unified channels + bindings)
|
||||||
|
// Note: binding_type derived from device (ac=level, tapo=switch)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS output_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
value_type TEXT NOT NULL,
|
||||||
|
min_value REAL DEFAULT 0,
|
||||||
|
max_value REAL DEFAULT 1,
|
||||||
|
device TEXT,
|
||||||
|
device_channel TEXT,
|
||||||
|
position INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Helper to insert changelog entry
|
||||||
|
global.insertChangelog = (user, text) => {
|
||||||
|
try {
|
||||||
|
if (!db) return;
|
||||||
|
const stmt = db.prepare('INSERT INTO changelog (date, user, text) VALUES (?, ?, ?)');
|
||||||
|
stmt.run(new Date().toISOString(), user || 'system', text);
|
||||||
|
console.log(`[Changelog] ${user || 'system'}: ${text}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Changelog] Error inserting entry:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load output channels from database (replaces hardcoded OUTPUT_CHANNELS)
|
||||||
|
function getOutputChannels() {
|
||||||
|
if (!db) return [];
|
||||||
|
const rows = db.prepare('SELECT * FROM output_configs ORDER BY position ASC').all();
|
||||||
|
return rows.map(r => ({
|
||||||
|
channel: r.channel,
|
||||||
|
type: r.value_type,
|
||||||
|
min: r.min_value,
|
||||||
|
max: r.max_value,
|
||||||
|
description: r.description
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load output bindings from database (replaces hardcoded OUTPUT_BINDINGS)
|
||||||
|
// Binding type derived: ac=level, tapo=switch
|
||||||
|
function getOutputBindings() {
|
||||||
|
if (!db) return {};
|
||||||
|
const rows = db.prepare('SELECT * FROM output_configs WHERE device IS NOT NULL').all();
|
||||||
|
const bindings = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.device && r.device_channel) {
|
||||||
|
bindings[r.channel] = {
|
||||||
|
device: r.device,
|
||||||
|
channel: r.device_channel,
|
||||||
|
type: r.device === 'ac' ? 'level' : 'switch'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// WebSocket Server for Agents (port 3962)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Track authenticated clients by devicePrefix
|
||||||
|
const agentClients = new Map(); // devicePrefix -> Set<ws>
|
||||||
|
|
||||||
|
function validateApiKey(apiKey) {
|
||||||
|
if (!db) return null;
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT id, name, device_prefix FROM api_keys WHERE key = ?');
|
||||||
|
const result = stmt.get(apiKey);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Update last_used_at timestamp
|
||||||
|
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(result.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WS] Error validating API key:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertReadingsSmart(devicePrefix, readings) {
|
||||||
|
if (!db) throw new Error('Database not connected');
|
||||||
|
|
||||||
|
const isoTimestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
const stmtLast = db.prepare(`
|
||||||
|
SELECT id, value, data, data_type
|
||||||
|
FROM sensor_events
|
||||||
|
WHERE device = ? AND channel = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const stmtUpdate = db.prepare(`
|
||||||
|
UPDATE sensor_events SET until = ? WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const stmtInsert = db.prepare(`
|
||||||
|
INSERT INTO sensor_events (timestamp, until, device, channel, value, data, data_type)
|
||||||
|
VALUES (?, NULL, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const transaction = db.transaction((items) => {
|
||||||
|
let inserted = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const reading of items) {
|
||||||
|
const fullDevice = `${devicePrefix}${reading.device}`;
|
||||||
|
const channel = reading.channel;
|
||||||
|
|
||||||
|
// Determine type and values
|
||||||
|
let dataType = 'number';
|
||||||
|
let value = null;
|
||||||
|
let data = null;
|
||||||
|
|
||||||
|
if (reading.value !== undefined && reading.value !== null) {
|
||||||
|
dataType = 'number';
|
||||||
|
value = reading.value;
|
||||||
|
} else if (reading.data !== undefined) {
|
||||||
|
dataType = 'json';
|
||||||
|
data = typeof reading.data === 'string' ? reading.data : JSON.stringify(reading.data);
|
||||||
|
} else {
|
||||||
|
continue; // Skip invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check last reading for RLE
|
||||||
|
const last = stmtLast.get(fullDevice, channel);
|
||||||
|
let isDuplicate = false;
|
||||||
|
|
||||||
|
if (last && last.data_type === dataType) {
|
||||||
|
if (dataType === 'number') {
|
||||||
|
if (Math.abs(last.value - value) < Number.EPSILON) {
|
||||||
|
isDuplicate = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Compare JSON strings
|
||||||
|
if (last.data === data) {
|
||||||
|
isDuplicate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
stmtUpdate.run(isoTimestamp, last.id);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
stmtInsert.run(isoTimestamp, fullDevice, channel, value, data, dataType);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { inserted, updated };
|
||||||
|
});
|
||||||
|
|
||||||
|
return transaction(readings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgentWebSocketServer() {
|
||||||
|
const wss = new WebSocketServer({ port: WS_PORT });
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
|
||||||
|
console.log(`[WS] Client connected: ${clientId}`);
|
||||||
|
|
||||||
|
const clientState = {
|
||||||
|
authenticated: false,
|
||||||
|
devicePrefix: null,
|
||||||
|
name: null,
|
||||||
|
lastPong: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up ping/pong for keepalive
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.isAlive = true;
|
||||||
|
clientState.lastPong = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
handleAgentMessage(ws, message, clientState, clientId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[WS] Error parsing message from ${clientId}:`, err.message);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log(`[WS] Client disconnected: ${clientId} (${clientState.name || 'unauthenticated'})`);
|
||||||
|
if (clientState.devicePrefix && agentClients.has(clientState.devicePrefix)) {
|
||||||
|
agentClients.get(clientState.devicePrefix).delete(ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error(`[WS] Error for ${clientId}:`, err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ping interval to detect dead connections
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
wss.clients.forEach((ws) => {
|
||||||
|
if (ws.isAlive === false) {
|
||||||
|
console.log('[WS] Terminating unresponsive client');
|
||||||
|
return ws.terminate();
|
||||||
|
}
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
wss.on('close', () => {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[WS] WebSocket server listening on port ${WS_PORT}`);
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAgentMessage(ws, message, clientState, clientId) {
|
||||||
|
const { type } = message;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'auth':
|
||||||
|
const { apiKey } = message;
|
||||||
|
if (!apiKey) {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', success: false, error: 'Missing apiKey' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyInfo = validateApiKey(apiKey);
|
||||||
|
if (!keyInfo) {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', success: false, error: 'Invalid API key' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientState.authenticated = true;
|
||||||
|
clientState.devicePrefix = keyInfo.device_prefix;
|
||||||
|
clientState.name = keyInfo.name;
|
||||||
|
|
||||||
|
// Track this connection
|
||||||
|
if (!agentClients.has(keyInfo.device_prefix)) {
|
||||||
|
agentClients.set(keyInfo.device_prefix, new Set());
|
||||||
|
}
|
||||||
|
agentClients.get(keyInfo.device_prefix).add(ws);
|
||||||
|
|
||||||
|
console.log(`[WS] Client authenticated: ${keyInfo.name} (prefix: ${keyInfo.device_prefix})`);
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', success: true, devicePrefix: keyInfo.device_prefix, name: keyInfo.name }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
// Keepalive from agent - just update timestamp
|
||||||
|
clientState.lastPong = Date.now();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'data':
|
||||||
|
if (!clientState.authenticated) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Not authenticated' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readings } = message;
|
||||||
|
if (!Array.isArray(readings) || readings.length === 0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Invalid readings' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validReadings = readings.filter(r => r.device && r.channel && (r.value !== undefined || r.data !== undefined));
|
||||||
|
const result = insertReadingsSmart(clientState.devicePrefix, validReadings);
|
||||||
|
|
||||||
|
// Trigger rules immediately on new data
|
||||||
|
if (runRules) runRules();
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'ack', count: result.inserted + result.updated }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WS] Error inserting readings:', err.message);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Failed to insert readings' }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${type}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to all agents with the given device prefix
|
||||||
|
function sendCommandToDevicePrefix(devicePrefix, command) {
|
||||||
|
const clients = agentClients.get(devicePrefix);
|
||||||
|
if (!clients || clients.size === 0) {
|
||||||
|
console.log(`[WS] No connected agents for prefix: ${devicePrefix}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = JSON.stringify({ type: 'command', ...command });
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const ws of clients) {
|
||||||
|
if (ws.readyState === 1) { // OPEN
|
||||||
|
ws.send(message);
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[WS] Sent command to ${sent} agent(s) with prefix ${devicePrefix}:`, command);
|
||||||
|
return sent > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic sync: push non-zero output states to agents every 60s
|
||||||
|
function syncOutputStates() {
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bindings = getOutputBindings();
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Only sync non-zero values
|
||||||
|
if (row.value > 0) {
|
||||||
|
const binding = bindings[row.channel];
|
||||||
|
if (binding) {
|
||||||
|
let commandValue = row.value;
|
||||||
|
if (binding.type === 'switch') {
|
||||||
|
commandValue = row.value > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = sendCommandToDevicePrefix(`${binding.device}:`, {
|
||||||
|
device: binding.channel,
|
||||||
|
action: 'set_state',
|
||||||
|
value: commandValue
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
console.error(`[Sync] ERROR: Cannot deliver 'on' command for ${row.channel} -> ${binding.device}:${binding.channel} (no agent connected)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Sync] Error syncing output states:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start output state sync interval (every 60s)
|
||||||
|
setInterval(syncOutputStates, 60000);
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// RULE ENGINE (Global Scope)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Get current sensor value
|
||||||
|
function getSensorValue(channel) {
|
||||||
|
// channel format: "device:channel" e.g. "ac:controller:co2"
|
||||||
|
const lastColonIndex = channel.lastIndexOf(':');
|
||||||
|
if (lastColonIndex === -1) return null;
|
||||||
|
const device = channel.substring(0, lastColonIndex);
|
||||||
|
const ch = channel.substring(lastColonIndex + 1);
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT value FROM sensor_events
|
||||||
|
WHERE device = ? AND channel = ?
|
||||||
|
ORDER BY timestamp DESC LIMIT 1
|
||||||
|
`);
|
||||||
|
const row = stmt.get(device, ch);
|
||||||
|
return row ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current output value
|
||||||
|
function getOutputValue(channel) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT value FROM output_events
|
||||||
|
WHERE channel = ?
|
||||||
|
ORDER BY timestamp DESC LIMIT 1
|
||||||
|
`);
|
||||||
|
const row = stmt.get(channel);
|
||||||
|
return row ? row.value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write output value with RLE
|
||||||
|
function writeOutputValue(channel, value) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const lastStmt = db.prepare(`
|
||||||
|
SELECT id, value FROM output_events
|
||||||
|
WHERE channel = ?
|
||||||
|
ORDER BY timestamp DESC LIMIT 1
|
||||||
|
`);
|
||||||
|
const last = lastStmt.get(channel);
|
||||||
|
|
||||||
|
const valueChanged = !last || Math.abs(last.value - value) >= Number.EPSILON;
|
||||||
|
|
||||||
|
if (!valueChanged) {
|
||||||
|
// Same value - update the until timestamp (RLE)
|
||||||
|
const updateStmt = db.prepare('UPDATE output_events SET until = ? WHERE id = ?');
|
||||||
|
updateStmt.run(now, last.id);
|
||||||
|
} else {
|
||||||
|
// New value - insert new record
|
||||||
|
const insertStmt = db.prepare(`
|
||||||
|
INSERT INTO output_events (timestamp, until, channel, value, data_type)
|
||||||
|
VALUES (?, NULL, ?, ?, 'number')
|
||||||
|
`);
|
||||||
|
insertStmt.run(now, channel, value);
|
||||||
|
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
|
||||||
|
|
||||||
|
// Send command to bound physical device
|
||||||
|
const bindings = getOutputBindings();
|
||||||
|
const binding = bindings[channel];
|
||||||
|
if (binding) {
|
||||||
|
let commandValue = value;
|
||||||
|
if (binding.type === 'switch') {
|
||||||
|
commandValue = value > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RuleRunner] Binding for ${channel}: type=${binding.type}, val=${value}, cmdVal=${commandValue}`);
|
||||||
|
|
||||||
|
sendCommandToDevicePrefix(`${binding.device}:`, {
|
||||||
|
device: binding.channel,
|
||||||
|
action: 'set_state',
|
||||||
|
value: commandValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare values with operator
|
||||||
|
function compareValues(actual, operator, target) {
|
||||||
|
if (actual === null || actual === undefined) return false;
|
||||||
|
switch (operator) {
|
||||||
|
case '=':
|
||||||
|
case '==': return actual === target;
|
||||||
|
case '!=': return actual !== target;
|
||||||
|
case '<': return actual < target;
|
||||||
|
case '>': return actual > target;
|
||||||
|
case '<=': return actual <= target;
|
||||||
|
case '>=': return actual >= target;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate a single condition
|
||||||
|
function evaluateCondition(condition) {
|
||||||
|
const { type, operator, value, channel } = condition;
|
||||||
|
|
||||||
|
// Handle AND/OR groups
|
||||||
|
if (operator === 'AND' || operator === 'OR') {
|
||||||
|
const results = (condition.conditions || []).map(c => evaluateCondition(c));
|
||||||
|
return operator === 'AND'
|
||||||
|
? results.every(r => r)
|
||||||
|
: results.some(r => r);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'time': {
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight
|
||||||
|
|
||||||
|
if (operator === 'between' && Array.isArray(value)) {
|
||||||
|
const [start, end] = value.map(t => {
|
||||||
|
const [h, m] = t.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
});
|
||||||
|
return currentTime >= start && currentTime <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [h, m] = String(value).split(':').map(Number);
|
||||||
|
const targetTime = h * 60 + m;
|
||||||
|
return compareValues(currentTime, operator, targetTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'date': {
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (operator === 'between' && Array.isArray(value)) {
|
||||||
|
return today >= value[0] && today <= value[1];
|
||||||
|
}
|
||||||
|
if (operator === 'before') return today < value;
|
||||||
|
if (operator === 'after') return today > value;
|
||||||
|
return today === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sensor': {
|
||||||
|
const sensorValue = getSensorValue(channel);
|
||||||
|
let target = value;
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && value.type === 'dynamic') {
|
||||||
|
const targetSensorVal = getSensorValue(value.channel) || 0;
|
||||||
|
target = (targetSensorVal * (value.factor || 1)) + (value.offset || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareValues(sensorValue, operator, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'output': {
|
||||||
|
const outputValue = getOutputValue(channel);
|
||||||
|
return compareValues(outputValue, operator, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`[RuleRunner] Unknown condition type: ${type}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global set to track currently active rule IDs
|
||||||
|
const activeRuleIds = new Set();
|
||||||
|
|
||||||
|
// Run all rules
|
||||||
|
function runRules() {
|
||||||
|
if (!db) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
|
||||||
|
|
||||||
|
// Clear active rules list at start of run
|
||||||
|
activeRuleIds.clear();
|
||||||
|
|
||||||
|
// Default all outputs to OFF (0) - if no rule sets them, they stay off
|
||||||
|
const desiredOutputs = {};
|
||||||
|
const outputChannels = getOutputChannels();
|
||||||
|
for (const ch of outputChannels) {
|
||||||
|
desiredOutputs[ch.channel] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
try {
|
||||||
|
const conditions = JSON.parse(rule.conditions || '{}');
|
||||||
|
const action = JSON.parse(rule.action || '{}');
|
||||||
|
|
||||||
|
if (evaluateCondition(conditions)) {
|
||||||
|
// Rule matches - add to active list
|
||||||
|
activeRuleIds.add(rule.id);
|
||||||
|
|
||||||
|
// Rule matches - set output (later rules override)
|
||||||
|
if (action.channel && action.value !== undefined) {
|
||||||
|
let finalValue = action.value;
|
||||||
|
|
||||||
|
// Handle calculated value
|
||||||
|
if (action.value && typeof action.value === 'object' && action.value.type === 'calculated') {
|
||||||
|
const valA = getSensorValue(action.value.sensorA) || 0;
|
||||||
|
const valB = action.value.sensorB ? (getSensorValue(action.value.sensorB) || 0) : 0;
|
||||||
|
const diff = valA - valB;
|
||||||
|
finalValue = (diff * (action.value.factor || 1)) + (action.value.offset || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
desiredOutputs[action.channel] = finalValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[RuleRunner] Error evaluating rule ${rule.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write output values
|
||||||
|
for (const [channel, value] of Object.entries(desiredOutputs)) {
|
||||||
|
writeOutputValue(channel, value);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RuleRunner] Error running rules:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also sync immediately on startup after a short delay
|
||||||
|
setTimeout(syncOutputStates, 5000);
|
||||||
|
|
||||||
|
// Start the WebSocket server
|
||||||
|
const agentWss = createAgentWebSocketServer();
|
||||||
|
|
||||||
|
// Import API setup
|
||||||
|
const setupAllApis = require('./api');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './src/index.js',
|
entry: './src/index.js',
|
||||||
output: {
|
output: {
|
||||||
@@ -45,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: {
|
||||||
@@ -56,273 +658,44 @@ 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) => {
|
// Clean up on server close
|
||||||
if (!req.user || req.user.role !== 'admin') {
|
devServer.server?.on('close', () => {
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
clearInterval(ruleRunnerInterval);
|
||||||
}
|
console.log('[RuleRunner] Stopped background job');
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
app.use('/api/views', checkAuth);
|
|
||||||
|
|
||||||
// --- Views API ---
|
|
||||||
app.post('/api/views', requireAdmin, (req, res) => {
|
|
||||||
const { name, config } = req.body;
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)');
|
|
||||||
const info = stmt.run(name, JSON.stringify(config), req.user.id);
|
|
||||||
res.json({ id: info.lastInsertRowid, name, config });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Publicly list views
|
|
||||||
app.get('/api/views', (req, res) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
|
|
||||||
const rows = stmt.all();
|
|
||||||
const views = rows.map(row => {
|
|
||||||
try {
|
|
||||||
return { ...row, config: JSON.parse(row.config) };
|
|
||||||
} catch (e) {
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(views);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/views/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('SELECT * FROM views WHERE id = ?');
|
|
||||||
const view = stmt.get(req.params.id);
|
|
||||||
if (view) {
|
|
||||||
view.config = JSON.parse(view.config);
|
|
||||||
res.json(view);
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'View not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete View
|
|
||||||
app.delete('/api/views/:id', requireAdmin, (req, res) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('DELETE FROM views WHERE id = ?');
|
|
||||||
const info = stmt.run(req.params.id);
|
|
||||||
if (info.changes > 0) {
|
|
||||||
res.json({ success: true });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'View not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update View
|
|
||||||
app.put('/api/views/:id', requireAdmin, (req, res) => {
|
|
||||||
const { name, config } = req.body;
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
|
|
||||||
const info = stmt.run(name, JSON.stringify(config), req.params.id);
|
|
||||||
if (info.changes > 0) {
|
|
||||||
res.json({ id: req.params.id, name, config });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'View not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reorder Views
|
|
||||||
app.post('/api/views/reorder', requireAdmin, (req, res) => {
|
|
||||||
const { order } = req.body;
|
|
||||||
console.log('[API] Reorder request:', order);
|
|
||||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
|
|
||||||
|
|
||||||
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
|
|
||||||
const updateMany = db.transaction((items) => {
|
|
||||||
for (const item of items) {
|
|
||||||
console.log('[API] Updating view', item.id, 'to position', item.position);
|
|
||||||
updateStmt.run(item.position, item.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
updateMany(order);
|
|
||||||
console.log('[API] Reorder successful');
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[API] Reorder error:', err);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/devices
|
|
||||||
// Returns list of unique device/channel pairs
|
|
||||||
app.get('/api/devices', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!db) throw new Error('Database not connected');
|
|
||||||
// Filter to only numeric channels which can be charted
|
|
||||||
const stmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel");
|
|
||||||
const rows = stmt.all();
|
|
||||||
res.json(rows);
|
|
||||||
} 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();
|
|
||||||
|
|
||||||
// 1. Fetch main data window
|
|
||||||
let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? ';
|
|
||||||
const params = [startTime, endTime];
|
|
||||||
|
|
||||||
const requestedChannels = []; // [{device, channel}]
|
|
||||||
|
|
||||||
if (req.query.selection) {
|
|
||||||
const selections = req.query.selection.split(',');
|
|
||||||
if (selections.length > 0) {
|
|
||||||
const placeholders = [];
|
|
||||||
selections.forEach(s => {
|
|
||||||
const lastColonIndex = s.lastIndexOf(':');
|
|
||||||
if (lastColonIndex !== -1) {
|
|
||||||
const d = s.substring(0, lastColonIndex);
|
|
||||||
const c = s.substring(lastColonIndex + 1);
|
|
||||||
placeholders.push('(device = ? AND channel = ?)');
|
|
||||||
params.push(d, c);
|
|
||||||
requestedChannels.push({ device: d, channel: c });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (placeholders.length > 0) {
|
|
||||||
sql += `AND (${placeholders.join(' OR ')}) `;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += 'ORDER BY timestamp ASC';
|
|
||||||
|
|
||||||
const stmt = db.prepare(sql);
|
|
||||||
const rows = stmt.all(...params);
|
|
||||||
|
|
||||||
// 2. Backfill: Ensure we have a starting point for each channel
|
|
||||||
// For each requested channel, check if we have data at/near start.
|
|
||||||
// If the first point for a channel is > startTime, we should try to find the previous value.
|
|
||||||
// We check for the value that started before (or at) startTime AND ended after (or at) startTime (or hasn't ended).
|
|
||||||
|
|
||||||
const backfillRows = [];
|
|
||||||
// Find the record that started most recently before startTime BUT was still valid at startTime
|
|
||||||
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
|
|
||||||
`);
|
|
||||||
|
|
||||||
requestedChannels.forEach(ch => {
|
|
||||||
const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime);
|
|
||||||
if (prev) {
|
|
||||||
// We found a point that covers the startTime.
|
|
||||||
backfillRows.push(prev);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Combine and sort
|
|
||||||
const allRows = [...backfillRows, ...rows];
|
|
||||||
|
|
||||||
// Transform to Compact Dictionary Format
|
|
||||||
// { "device:channel": [ [timestamp, value, until], ... ] }
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
allRows.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);
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return middlewares;
|
return middlewares;
|
||||||
|
|||||||
Reference in New Issue
Block a user