Initial commit: tischlerctrl home automation project
This commit is contained in:
12
agents/ac-infinity/.env.example
Normal file
12
agents/ac-infinity/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# AC Infinity Agent Environment Configuration
|
||||
|
||||
# WebSocket server connection
|
||||
SERVER_URL=ws://localhost:8080
|
||||
API_KEY=your-api-key-here
|
||||
|
||||
# AC Infinity credentials
|
||||
AC_EMAIL=your@email.com
|
||||
AC_PASSWORD=your-password
|
||||
|
||||
# Polling interval in milliseconds (default: 60000 = 1 minute)
|
||||
POLL_INTERVAL_MS=60000
|
||||
52
agents/ac-infinity/package-lock.json
generated
Normal file
52
agents/ac-infinity/package-lock.json
generated
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "ac-infinity-agent",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ac-infinity-agent",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"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/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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
agents/ac-infinity/package.json
Normal file
17
agents/ac-infinity/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ac-infinity-agent",
|
||||
"version": "1.0.0",
|
||||
"description": "AC Infinity sensor data collection agent",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
208
agents/ac-infinity/src/ac-client.js
Normal file
208
agents/ac-infinity/src/ac-client.js
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* AC Infinity API Client
|
||||
* Ported from TypeScript homebridge-acinfinity plugin
|
||||
*/
|
||||
|
||||
const API_URL_LOGIN = '/api/user/appUserLogin';
|
||||
const API_URL_GET_DEVICE_INFO_LIST_ALL = '/api/user/devInfoListAll';
|
||||
|
||||
export class ACInfinityClientError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'ACInfinityClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ACInfinityClientCannotConnect extends ACInfinityClientError {
|
||||
constructor() {
|
||||
super('Cannot connect to AC Infinity API');
|
||||
}
|
||||
}
|
||||
|
||||
export class ACInfinityClientInvalidAuth extends ACInfinityClientError {
|
||||
constructor() {
|
||||
super('Invalid authentication credentials');
|
||||
}
|
||||
}
|
||||
|
||||
export class ACInfinityClient {
|
||||
constructor(host, email, password) {
|
||||
this.host = host;
|
||||
this.email = email;
|
||||
this.password = password;
|
||||
this.userId = null;
|
||||
}
|
||||
|
||||
async login() {
|
||||
try {
|
||||
// AC Infinity API does not accept passwords greater than 25 characters
|
||||
const normalizedPassword = this.password.substring(0, 25);
|
||||
|
||||
const response = await fetch(`${this.host}${API_URL_LOGIN}`, {
|
||||
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',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
appEmail: this.email,
|
||||
appPasswordl: normalizedPassword, // Note: intentional typo in API
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 200) {
|
||||
if (data.code === 10001) {
|
||||
throw new ACInfinityClientInvalidAuth();
|
||||
}
|
||||
throw new ACInfinityClientError(`Login failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
this.userId = data.data.appId;
|
||||
console.log('[AC] Successfully logged in to AC Infinity API');
|
||||
return this.userId;
|
||||
} catch (error) {
|
||||
if (error instanceof ACInfinityClientError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ACInfinityClientCannotConnect();
|
||||
}
|
||||
}
|
||||
|
||||
isLoggedIn() {
|
||||
return this.userId !== null;
|
||||
}
|
||||
|
||||
getAuthHeaders() {
|
||||
if (!this.userId) {
|
||||
throw new ACInfinityClientError('Client is not logged in');
|
||||
}
|
||||
return {
|
||||
token: this.userId,
|
||||
phoneType: '1',
|
||||
appVersion: '1.9.7',
|
||||
};
|
||||
}
|
||||
|
||||
async getDevicesListAll() {
|
||||
if (!this.isLoggedIn()) {
|
||||
throw new ACInfinityClientError('AC Infinity client is not logged in');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.host}${API_URL_GET_DEVICE_INFO_LIST_ALL}`, {
|
||||
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({
|
||||
userId: this.userId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 200) {
|
||||
throw new ACInfinityClientError(`Request failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data.data || [];
|
||||
} catch (error) {
|
||||
if (error instanceof ACInfinityClientError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ACInfinityClientCannotConnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sensor readings from device list
|
||||
* @returns {Array} Array of {device, channel, value} objects
|
||||
*/
|
||||
async getSensorReadings() {
|
||||
const devices = await this.getDevicesListAll();
|
||||
const readings = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const devId = device.devId;
|
||||
const devName = device.devName || `device-${devId}`;
|
||||
|
||||
// Normalize device name for use as identifier
|
||||
const deviceId = devName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
// Extract sensor data from device settings or sensor fields
|
||||
// Temperature is stored as Celsius * 100
|
||||
if (device.devSettings?.temperature !== undefined) {
|
||||
readings.push({
|
||||
device: deviceId,
|
||||
channel: 'temperature',
|
||||
value: device.devSettings.temperature / 100,
|
||||
});
|
||||
} else if (device.temperature !== undefined) {
|
||||
readings.push({
|
||||
device: deviceId,
|
||||
channel: 'temperature',
|
||||
value: device.temperature / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Humidity is stored as % * 100
|
||||
if (device.devSettings?.humidity !== undefined) {
|
||||
readings.push({
|
||||
device: deviceId,
|
||||
channel: 'humidity',
|
||||
value: device.devSettings.humidity / 100,
|
||||
});
|
||||
} else if (device.humidity !== undefined) {
|
||||
readings.push({
|
||||
device: deviceId,
|
||||
channel: 'humidity',
|
||||
value: device.humidity / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// VPD if available
|
||||
if (device.devSettings?.vpdnums !== undefined) {
|
||||
readings.push({
|
||||
device: deviceId,
|
||||
channel: 'vpd',
|
||||
value: device.devSettings.vpdnums / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for port-level sensors (some controllers have multiple ports)
|
||||
if (device.devPortList && Array.isArray(device.devPortList)) {
|
||||
for (const port of device.devPortList) {
|
||||
const portId = port.portId || port.port;
|
||||
const portDeviceId = `${deviceId}-port${portId}`;
|
||||
|
||||
if (port.temperature !== undefined) {
|
||||
readings.push({
|
||||
device: portDeviceId,
|
||||
channel: 'temperature',
|
||||
value: port.temperature / 100,
|
||||
});
|
||||
}
|
||||
|
||||
if (port.humidity !== undefined) {
|
||||
readings.push({
|
||||
device: portDeviceId,
|
||||
channel: 'humidity',
|
||||
value: port.humidity / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
}
|
||||
|
||||
export default ACInfinityClient;
|
||||
25
agents/ac-infinity/src/config.js
Normal file
25
agents/ac-infinity/src/config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
config({ path: join(__dirname, '..', '.env') });
|
||||
|
||||
export default {
|
||||
// WebSocket server connection
|
||||
serverUrl: process.env.SERVER_URL || 'ws://localhost:8080',
|
||||
apiKey: process.env.API_KEY || '',
|
||||
|
||||
// AC Infinity credentials
|
||||
acEmail: process.env.AC_EMAIL || '',
|
||||
acPassword: process.env.AC_PASSWORD || '',
|
||||
|
||||
// Polling interval (default: 60 seconds)
|
||||
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '60000', 10),
|
||||
|
||||
// AC Infinity API
|
||||
acApiHost: process.env.AC_API_HOST || 'https://www.acinfinity.com',
|
||||
};
|
||||
89
agents/ac-infinity/src/index.js
Normal file
89
agents/ac-infinity/src/index.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import config from './config.js';
|
||||
import ACInfinityClient from './ac-client.js';
|
||||
import WSClient from './ws-client.js';
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('AC Infinity Agent');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// Validate configuration
|
||||
if (!config.apiKey) {
|
||||
console.error('Error: API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!config.acEmail || !config.acPassword) {
|
||||
console.error('Error: AC_EMAIL and AC_PASSWORD are required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Initialize clients
|
||||
const acClient = new ACInfinityClient(
|
||||
config.acApiHost,
|
||||
config.acEmail,
|
||||
config.acPassword
|
||||
);
|
||||
|
||||
const wsClient = new WSClient(config.serverUrl, config.apiKey);
|
||||
|
||||
// Polling function
|
||||
async function pollSensors() {
|
||||
try {
|
||||
const readings = await acClient.getSensorReadings();
|
||||
|
||||
if (readings.length > 0) {
|
||||
console.log(`[Poll] Sending ${readings.length} readings`);
|
||||
wsClient.sendReadings(readings);
|
||||
} else {
|
||||
console.log('[Poll] No readings available');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Poll] Error:', err.message);
|
||||
|
||||
// Re-login if authentication failed
|
||||
if (err.message.includes('not logged in')) {
|
||||
console.log('[Poll] Attempting re-login...');
|
||||
try {
|
||||
await acClient.login();
|
||||
} catch (loginErr) {
|
||||
console.error('[Poll] Re-login failed:', loginErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
try {
|
||||
// Login to AC Infinity
|
||||
await acClient.login();
|
||||
|
||||
// Connect to WebSocket server
|
||||
await wsClient.connect();
|
||||
|
||||
// Start polling
|
||||
console.log(`[Main] Starting polling every ${config.pollIntervalMs / 1000}s`);
|
||||
|
||||
// Poll immediately
|
||||
await pollSensors();
|
||||
|
||||
// Then poll at interval
|
||||
setInterval(pollSensors, config.pollIntervalMs);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Main] Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown() {
|
||||
console.log('\n[Agent] Shutting down...');
|
||||
wsClient.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// Start
|
||||
main();
|
||||
194
agents/ac-infinity/src/ws-client.js
Normal file
194
agents/ac-infinity/src/ws-client.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import WebSocket from 'ws';
|
||||
|
||||
/**
|
||||
* WebSocket client with auto-reconnect and authentication
|
||||
*/
|
||||
export class WSClient {
|
||||
constructor(url, apiKey, options = {}) {
|
||||
this.url = url;
|
||||
this.apiKey = apiKey;
|
||||
this.options = {
|
||||
reconnectBaseMs: options.reconnectBaseMs || 1000,
|
||||
reconnectMaxMs: options.reconnectMaxMs || 60000,
|
||||
pingIntervalMs: options.pingIntervalMs || 30000,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.ws = null;
|
||||
this.authenticated = false;
|
||||
this.devicePrefix = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectTimer = null;
|
||||
this.pingTimer = null;
|
||||
this.messageQueue = [];
|
||||
this.onReadyCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket server
|
||||
* @returns {Promise} Resolves when authenticated
|
||||
*/
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.onReadyCallback = resolve;
|
||||
this._connect();
|
||||
});
|
||||
}
|
||||
|
||||
_connect() {
|
||||
console.log(`[WS] Connecting to ${this.url}...`);
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('[WS] Connected, authenticating...');
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Send authentication
|
||||
this._send({ type: 'auth', apiKey: this.apiKey });
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this._handleMessage(message);
|
||||
} catch (err) {
|
||||
console.error('[WS] Error parsing message:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('ping', () => {
|
||||
this.ws.pong();
|
||||
});
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
console.log(`[WS] Connection closed: ${code} ${reason}`);
|
||||
this._cleanup();
|
||||
this._scheduleReconnect();
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('[WS] Error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
_handleMessage(message) {
|
||||
switch (message.type) {
|
||||
case 'auth':
|
||||
if (message.success) {
|
||||
console.log(`[WS] Authenticated as ${message.name}`);
|
||||
this.authenticated = true;
|
||||
this.devicePrefix = message.devicePrefix;
|
||||
|
||||
// Start ping timer
|
||||
this._startPingTimer();
|
||||
|
||||
// Flush queued messages
|
||||
this._flushQueue();
|
||||
|
||||
// Resolve connect promise
|
||||
if (this.onReadyCallback) {
|
||||
this.onReadyCallback();
|
||||
this.onReadyCallback = null;
|
||||
}
|
||||
} else {
|
||||
console.error('[WS] Authentication failed:', message.error);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ack':
|
||||
// Data acknowledged
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[WS] Server error:', message.error);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[WS] Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
_startPingTimer() {
|
||||
this._stopPingTimer();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this._send({ type: 'pong' });
|
||||
}
|
||||
}, this.options.pingIntervalMs);
|
||||
}
|
||||
|
||||
_stopPingTimer() {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
this._stopPingTimer();
|
||||
this.authenticated = false;
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
|
||||
const delay = Math.min(
|
||||
this.options.reconnectBaseMs * Math.pow(2, this.reconnectAttempts),
|
||||
this.options.reconnectMaxMs
|
||||
);
|
||||
|
||||
console.log(`[WS] Reconnecting in ${delay}ms...`);
|
||||
this.reconnectAttempts++;
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this._connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
_send(message) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
_flushQueue() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
this._send(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send sensor readings to the server
|
||||
* @param {Array} readings - Array of {device, channel, value} objects
|
||||
*/
|
||||
sendReadings(readings) {
|
||||
const message = { type: 'data', readings };
|
||||
|
||||
if (this.authenticated) {
|
||||
this._send(message);
|
||||
} else {
|
||||
// Queue for later
|
||||
this.messageQueue.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection
|
||||
*/
|
||||
close() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this._cleanup();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WSClient;
|
||||
119
agents/cli/sensor-send
Executable file
119
agents/cli/sensor-send
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# sensor-send - CLI tool to send sensor data to TischlerCtrl server
|
||||
#
|
||||
# Usage:
|
||||
# sensor-send <device> <channel> <value>
|
||||
#
|
||||
# Environment variables:
|
||||
# SENSOR_API_KEY - API key for authentication (required)
|
||||
# SENSOR_SERVER - WebSocket server URL (default: ws://localhost:8080)
|
||||
#
|
||||
# Examples:
|
||||
# sensor-send growbox temperature 24.5
|
||||
# sensor-send pump-1 pressure 1.2
|
||||
#
|
||||
# Dependencies:
|
||||
# - websocat (install via: cargo install websocat)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
API_KEY="${SENSOR_API_KEY:-}"
|
||||
SERVER="${SENSOR_SERVER:-ws://localhost:8080}"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Usage function
|
||||
usage() {
|
||||
echo "Usage: sensor-send <device> <channel> <value>"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " SENSOR_API_KEY - API key for authentication (required)"
|
||||
echo " SENSOR_SERVER - WebSocket server URL (default: ws://localhost:8080)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " sensor-send growbox temperature 24.5"
|
||||
echo " sensor-send pump-1 pressure 1.2"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for websocat
|
||||
if ! command -v websocat &> /dev/null; then
|
||||
echo -e "${RED}Error: websocat is not installed${NC}"
|
||||
echo "Install via: cargo install websocat"
|
||||
echo "Or: apt install websocat (on some systems)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check arguments
|
||||
if [ $# -lt 3 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
DEVICE="$1"
|
||||
CHANNEL="$2"
|
||||
VALUE="$3"
|
||||
|
||||
# Validate value is a number
|
||||
if ! [[ "$VALUE" =~ ^-?[0-9]*\.?[0-9]+$ ]]; then
|
||||
echo -e "${RED}Error: value must be a number${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check API key
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo -e "${RED}Error: SENSOR_API_KEY environment variable is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create JSON messages
|
||||
AUTH_MSG=$(cat <<EOF
|
||||
{"type":"auth","apiKey":"$API_KEY"}
|
||||
EOF
|
||||
)
|
||||
|
||||
DATA_MSG=$(cat <<EOF
|
||||
{"type":"data","readings":[{"device":"$DEVICE","channel":"$CHANNEL","value":$VALUE}]}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Send data via websocat
|
||||
# We use a short-lived connection: auth, send data, close
|
||||
RESPONSE=$(echo -e "${AUTH_MSG}\n${DATA_MSG}" | websocat -n1 "$SERVER" 2>&1) || {
|
||||
echo -e "${RED}Error: Failed to connect to server${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check response
|
||||
if echo "$RESPONSE" | grep -q '"success":true'; then
|
||||
if echo "$RESPONSE" | grep -q '"type":"ack"'; then
|
||||
echo -e "${GREEN}✓ Data sent successfully${NC}"
|
||||
echo " Device: $DEVICE"
|
||||
echo " Channel: $CHANNEL"
|
||||
echo " Value: $VALUE"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Parse error if present
|
||||
if echo "$RESPONSE" | grep -q '"type":"error"'; then
|
||||
ERROR=$(echo "$RESPONSE" | grep -o '"error":"[^"]*"' | cut -d'"' -f4)
|
||||
echo -e "${RED}Error: $ERROR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auth failed
|
||||
if echo "$RESPONSE" | grep -q '"success":false'; then
|
||||
echo -e "${RED}Error: Authentication failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unknown response
|
||||
echo -e "${RED}Error: Unexpected response from server${NC}"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
24
agents/tapo/Cargo.toml
Normal file
24
agents/tapo/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "tapo-agent"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "Tapo smart plug sensor data collection agent"
|
||||
|
||||
[dependencies]
|
||||
tapo = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
futures-util = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# Add reqwest with rustls to override tapo's default
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
10
agents/tapo/Cross.toml
Normal file
10
agents/tapo/Cross.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[build.env]
|
||||
passthrough = [
|
||||
"RUST_BACKTRACE",
|
||||
]
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:main"
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main"
|
||||
148
agents/tapo/build-all.sh
Executable file
148
agents/tapo/build-all.sh
Executable file
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build Tapo agent for various Raspberry Pi targets
|
||||
#
|
||||
# Targets:
|
||||
# - Pi 2, Pi 3, Pi 4 (32-bit): armv7-unknown-linux-gnueabihf
|
||||
# - Pi 3, Pi 4 (64-bit): aarch64-unknown-linux-gnu
|
||||
#
|
||||
# Usage: ./build-all.sh
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "=========================================="
|
||||
echo "Tapo Agent Cross-Compilation Build"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# Prerequisites Check
|
||||
# ============================================
|
||||
|
||||
MISSING_DEPS=0
|
||||
|
||||
echo -e "${BLUE}Checking prerequisites...${NC}"
|
||||
echo ""
|
||||
|
||||
# Check for Rust/Cargo
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo -e "${RED}✗ Rust/Cargo not found${NC}"
|
||||
echo " Install with:"
|
||||
echo -e " ${YELLOW}curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh${NC}"
|
||||
echo " source \$HOME/.cargo/env"
|
||||
echo ""
|
||||
MISSING_DEPS=1
|
||||
else
|
||||
RUST_VERSION=$(rustc --version | cut -d' ' -f2)
|
||||
echo -e "${GREEN}✓ Rust/Cargo installed${NC} (v$RUST_VERSION)"
|
||||
fi
|
||||
|
||||
# Check for Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}✗ Docker not found${NC}"
|
||||
echo " Install with:"
|
||||
echo -e " ${YELLOW}sudo apt update && sudo apt install -y docker.io${NC}"
|
||||
echo -e " ${YELLOW}sudo usermod -aG docker \$USER${NC}"
|
||||
echo " (log out and back in after adding to docker group)"
|
||||
echo ""
|
||||
MISSING_DEPS=1
|
||||
else
|
||||
DOCKER_VERSION=$(docker --version | cut -d' ' -f3 | tr -d ',')
|
||||
echo -e "${GREEN}✓ Docker installed${NC} (v$DOCKER_VERSION)"
|
||||
|
||||
# Check if Docker daemon is running
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}✗ Docker daemon not running or no permission${NC}"
|
||||
echo " Try:"
|
||||
echo -e " ${YELLOW}sudo systemctl start docker${NC}"
|
||||
echo " Or if permission denied:"
|
||||
echo -e " ${YELLOW}sudo usermod -aG docker \$USER${NC}"
|
||||
echo " (log out and back in)"
|
||||
echo ""
|
||||
MISSING_DEPS=1
|
||||
else
|
||||
echo -e "${GREEN}✓ Docker daemon running${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for cross
|
||||
if ! command -v cross &> /dev/null; then
|
||||
echo -e "${YELLOW}! cross not found - will install automatically${NC}"
|
||||
NEED_CROSS=1
|
||||
else
|
||||
CROSS_VERSION=$(cross --version 2>/dev/null | head -1 | cut -d' ' -f2 || echo "unknown")
|
||||
echo -e "${GREEN}✓ cross installed${NC} (v$CROSS_VERSION)"
|
||||
NEED_CROSS=0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Exit if missing dependencies
|
||||
if [ $MISSING_DEPS -eq 1 ]; then
|
||||
echo -e "${RED}Please install missing dependencies and try again.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install cross if needed
|
||||
if [ "${NEED_CROSS:-0}" -eq 1 ]; then
|
||||
echo -e "${YELLOW}Installing 'cross' for cross-compilation...${NC}"
|
||||
cargo install cross --git https://github.com/cross-rs/cross
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# Build
|
||||
# ============================================
|
||||
|
||||
# Create output directory
|
||||
mkdir -p dist
|
||||
|
||||
# Define targets
|
||||
declare -A TARGETS=(
|
||||
["armv7-unknown-linux-gnueabihf"]="pi2_pi3_pi4_32bit"
|
||||
["aarch64-unknown-linux-gnu"]="pi3_pi4_64bit"
|
||||
)
|
||||
|
||||
echo -e "${BLUE}Starting builds...${NC}"
|
||||
echo ""
|
||||
|
||||
for target in "${!TARGETS[@]}"; do
|
||||
name="${TARGETS[$target]}"
|
||||
echo -e "${GREEN}Building for $target ($name)...${NC}"
|
||||
|
||||
cross build --release --target "$target"
|
||||
|
||||
# Copy binary to dist folder with descriptive name
|
||||
cp "target/$target/release/tapo-agent" "dist/tapo-agent-$name"
|
||||
|
||||
# Get binary size
|
||||
size=$(du -h "dist/tapo-agent-$name" | cut -f1)
|
||||
echo -e " → ${GREEN}dist/tapo-agent-$name${NC} ($size)"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "=========================================="
|
||||
echo -e "${GREEN}Build complete!${NC} Binaries in dist/"
|
||||
echo "=========================================="
|
||||
ls -lh dist/
|
||||
|
||||
echo ""
|
||||
echo "To deploy to Raspberry Pi:"
|
||||
echo -e " ${YELLOW}scp dist/tapo-agent-pi3_pi4_64bit pi@raspberrypi:~/tapo-agent${NC}"
|
||||
echo -e " ${YELLOW}ssh pi@raspberrypi 'chmod +x ~/tapo-agent && ./tapo-agent'${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}Upload to bashupload.com for web console deploy (3 days, 1 download):${NC}"
|
||||
echo -e " ${YELLOW}curl https://bashupload.com -F=@dist/tapo-agent-pi3_pi4_64bit${NC}"
|
||||
echo -e " ${YELLOW}curl https://bashupload.com -F=@dist/tapo-agent-pi2_pi3_pi4_32bit${NC}"
|
||||
echo ""
|
||||
echo "Then on Pi, download and run:"
|
||||
echo -e " ${YELLOW}curl -sSL https://bashupload.com/XXXXX -o tapo-agent && chmod +x tapo-agent${NC}"
|
||||
22
agents/tapo/config.toml.example
Normal file
22
agents/tapo/config.toml.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Tapo Agent Configuration Example
|
||||
|
||||
server_url = "ws://192.168.1.100:8080"
|
||||
api_key = "your-api-key-here"
|
||||
poll_interval_secs = 60
|
||||
|
||||
# Define your Tapo devices below
|
||||
# Each device needs: ip, name, type (P100 or P110), tapo_email, tapo_password
|
||||
|
||||
[[devices]]
|
||||
ip = "192.168.1.50"
|
||||
name = "grow-light-plug"
|
||||
type = "P110"
|
||||
tapo_email = "your@email.com"
|
||||
tapo_password = "your-tapo-password"
|
||||
|
||||
[[devices]]
|
||||
ip = "192.168.1.51"
|
||||
name = "fan-plug"
|
||||
type = "P100"
|
||||
tapo_email = "your@email.com"
|
||||
tapo_password = "your-tapo-password"
|
||||
383
agents/tapo/src/main.rs
Normal file
383
agents/tapo/src/main.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tapo::{ApiClient, DiscoveryResult};
|
||||
use tokio::time::{interval, sleep};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "tapo-agent")]
|
||||
#[command(about = "Tapo smart plug sensor data collection agent")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Path to config file
|
||||
#[arg(short, long, default_value = "config.toml")]
|
||||
config: String,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Initialize configuration file by discovering devices
|
||||
Init {
|
||||
/// Server WebSocket URL
|
||||
#[arg(long)]
|
||||
server: String,
|
||||
|
||||
/// API key for authentication
|
||||
#[arg(long)]
|
||||
key: String,
|
||||
|
||||
/// Tapo account email
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
|
||||
/// Tapo account password
|
||||
#[arg(long)]
|
||||
password: String,
|
||||
|
||||
/// Broadcast address for discovery (default: 192.168.1.255)
|
||||
#[arg(long, default_value = "192.168.1.255")]
|
||||
broadcast: String,
|
||||
|
||||
/// Discovery timeout in seconds
|
||||
#[arg(long, default_value = "10")]
|
||||
timeout: u64,
|
||||
|
||||
/// Output config file path
|
||||
#[arg(short, long, default_value = "config.toml")]
|
||||
output: String,
|
||||
},
|
||||
/// Run the agent (default if no subcommand)
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct Config {
|
||||
server_url: String,
|
||||
api_key: String,
|
||||
poll_interval_secs: u64,
|
||||
devices: Vec<DeviceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct DeviceConfig {
|
||||
ip: String,
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
device_type: String,
|
||||
tapo_email: String,
|
||||
tapo_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthMessage {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
#[serde(rename = "apiKey")]
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DataMessage {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
readings: Vec<Reading>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct Reading {
|
||||
device: String,
|
||||
channel: String,
|
||||
value: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerResponse {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
success: Option<bool>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn discover_and_create_config(
|
||||
server: String,
|
||||
key: String,
|
||||
email: String,
|
||||
password: String,
|
||||
broadcast: String,
|
||||
timeout: u64,
|
||||
output: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Discovering Tapo devices on {} ({}s timeout)...", broadcast, timeout);
|
||||
|
||||
let api_client = ApiClient::new(&email, &password);
|
||||
let mut discovery = api_client.discover_devices(&broadcast, timeout).await?;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
|
||||
while let Some(discovery_result) = discovery.next().await {
|
||||
if let Ok(device) = discovery_result {
|
||||
match device {
|
||||
DiscoveryResult::Plug { device_info, .. } => {
|
||||
println!(
|
||||
" Found Plug: {} ({}) at {}",
|
||||
device_info.nickname, device_info.model, device_info.ip
|
||||
);
|
||||
devices.push(DeviceConfig {
|
||||
ip: device_info.ip,
|
||||
name: device_info.nickname.replace(" ", "-").to_lowercase(),
|
||||
device_type: "P100".to_string(),
|
||||
tapo_email: email.clone(),
|
||||
tapo_password: password.clone(),
|
||||
});
|
||||
}
|
||||
DiscoveryResult::PlugEnergyMonitoring { device_info, .. } => {
|
||||
println!(
|
||||
" Found Energy Plug: {} ({}) at {}",
|
||||
device_info.nickname, device_info.model, device_info.ip
|
||||
);
|
||||
devices.push(DeviceConfig {
|
||||
ip: device_info.ip,
|
||||
name: device_info.nickname.replace(" ", "-").to_lowercase(),
|
||||
device_type: "P110".to_string(),
|
||||
tapo_email: email.clone(),
|
||||
tapo_password: password.clone(),
|
||||
});
|
||||
}
|
||||
DiscoveryResult::GenericDevice { device_info, .. } => {
|
||||
println!(
|
||||
" Found Unknown Device: {:?} ({}) at {} - skipping",
|
||||
device_info.nickname, device_info.model, device_info.ip
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Light bulbs and other devices - skip for now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if devices.is_empty() {
|
||||
return Err("No plugs discovered. Check your broadcast address and ensure devices are on the same network.".into());
|
||||
}
|
||||
|
||||
println!("\nDiscovered {} plug(s)", devices.len());
|
||||
|
||||
let config = Config {
|
||||
server_url: server,
|
||||
api_key: key,
|
||||
poll_interval_secs: 60,
|
||||
devices,
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
std::fs::write(&output, &toml_str)?;
|
||||
|
||||
println!("✓ Config written to: {}", output);
|
||||
println!("\nRun the agent with: RUST_LOG=info ./tapo-agent");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_device_data(device: &DeviceConfig) -> Vec<Reading> {
|
||||
let mut readings = Vec::new();
|
||||
let client = ApiClient::new(&device.tapo_email, &device.tapo_password);
|
||||
|
||||
match device.device_type.as_str() {
|
||||
"P110" => {
|
||||
match client.p110(&device.ip).await {
|
||||
Ok(plug) => {
|
||||
if let Ok(info) = plug.get_device_info().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "state".to_string(),
|
||||
value: if info.device_on { 1.0 } else { 0.0 },
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(energy) = plug.get_current_power().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "power".to_string(),
|
||||
value: energy.current_power as f64 / 1000.0,
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(usage) = plug.get_energy_usage().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "energy_today".to_string(),
|
||||
value: usage.today_energy as f64,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Failed to connect to P110 {}: {}", device.name, e),
|
||||
}
|
||||
}
|
||||
"P100" | "P105" => {
|
||||
match client.p100(&device.ip).await {
|
||||
Ok(plug) => {
|
||||
if let Ok(info) = plug.get_device_info().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "state".to_string(),
|
||||
value: if info.device_on { 1.0 } else { 0.0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Failed to connect to P100 {}: {}", device.name, e),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown device type: {}", device.device_type);
|
||||
}
|
||||
}
|
||||
|
||||
readings
|
||||
}
|
||||
|
||||
async fn run_agent(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
let max_reconnect_delay = Duration::from_secs(60);
|
||||
|
||||
loop {
|
||||
info!("Connecting to {}...", config.server_url);
|
||||
|
||||
match connect_async(&config.server_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
info!("Connected to server");
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
let auth = AuthMessage {
|
||||
msg_type: "auth".to_string(),
|
||||
api_key: config.api_key.clone(),
|
||||
};
|
||||
let auth_json = serde_json::to_string(&auth)?;
|
||||
write.send(Message::Text(auth_json)).await?;
|
||||
|
||||
let authenticated = if let Some(Ok(msg)) = read.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
let response: ServerResponse = serde_json::from_str(&text)?;
|
||||
if response.msg_type == "auth" && response.success == Some(true) {
|
||||
info!("Authenticated successfully");
|
||||
true
|
||||
} else {
|
||||
error!("Authentication failed: {:?}", response.error);
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !authenticated {
|
||||
sleep(reconnect_delay).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut poll_interval = interval(Duration::from_secs(config.poll_interval_secs));
|
||||
|
||||
loop {
|
||||
poll_interval.tick().await;
|
||||
|
||||
let mut all_readings = Vec::new();
|
||||
for device in &config.devices {
|
||||
let readings = collect_device_data(device).await;
|
||||
all_readings.extend(readings);
|
||||
}
|
||||
|
||||
if !all_readings.is_empty() {
|
||||
info!("Sending {} readings", all_readings.len());
|
||||
let data = DataMessage {
|
||||
msg_type: "data".to_string(),
|
||||
readings: all_readings,
|
||||
};
|
||||
let data_json = serde_json::to_string(&data)?;
|
||||
|
||||
if let Err(e) = write.send(Message::Text(data_json)).await {
|
||||
error!("Failed to send data: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(Some(msg)) = tokio::time::timeout(
|
||||
Duration::from_millis(100),
|
||||
read.next(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
match msg {
|
||||
Ok(Message::Ping(data)) => {
|
||||
let _ = write.send(Message::Pong(data)).await;
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("Server closed connection");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Connection failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Reconnecting in {:?}...", reconnect_delay);
|
||||
sleep(reconnect_delay).await;
|
||||
reconnect_delay = std::cmp::min(reconnect_delay * 2, max_reconnect_delay);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Init {
|
||||
server,
|
||||
key,
|
||||
email,
|
||||
password,
|
||||
broadcast,
|
||||
timeout,
|
||||
output,
|
||||
}) => {
|
||||
discover_and_create_config(server, key, email, password, broadcast, timeout, output).await?;
|
||||
}
|
||||
Some(Commands::Run) | None => {
|
||||
let config_path = &cli.config;
|
||||
|
||||
let config_content = std::fs::read_to_string(config_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read config file {}: {}\n\nCreate config with device discovery:\n ./tapo-agent init --server ws://SERVER:8080 --key YOUR_KEY --email tapo@email.com --password tapopass\n\nOr specify broadcast address:\n ./tapo-agent init --server ws://SERVER:8080 --key YOUR_KEY --email tapo@email.com --password tapopass --broadcast 192.168.0.255",
|
||||
config_path, e
|
||||
)
|
||||
})?;
|
||||
|
||||
let config: Config = toml::from_str(&config_content)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
info!("Tapo Agent starting with {} devices", config.devices.len());
|
||||
|
||||
run_agent(config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user