Initial commit: tischlerctrl home automation project

This commit is contained in:
sebseb7
2025-12-22 23:32:55 +01:00
commit f3cca149f9
31 changed files with 3243 additions and 0 deletions

24
agents/tapo/Cargo.toml Normal file
View 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
View 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
View 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}"

View 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
View 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(())
}