Initial commit - Tasmota skill for OpenClaw
This commit is contained in:
251
SKILL.md
Normal file
251
SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: tasmota
|
||||
description: Discover, monitor, and control Tasmota smart home devices on local networks. Use when tasks involve finding Tasmota devices via network scanning, checking device status and power states, controlling devices (on-off, brightness, color), managing device inventory, or any other Tasmota management operations on ESP8266 or ESP32 devices running Tasmota firmware.
|
||||
---
|
||||
|
||||
# Tasmota Device Management
|
||||
|
||||
## Overview
|
||||
|
||||
Automated discovery and control of Tasmota-powered smart home devices (ESP8266/ESP32) on local networks. Includes network scanning, status monitoring, power control, dimming, color control, and inventory management.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Scan network for Tasmota devices:**
|
||||
```bash
|
||||
python3 scripts/tasmota-discovery.py
|
||||
```
|
||||
|
||||
**Check device status:**
|
||||
```bash
|
||||
python3 scripts/tasmota-control.py <IP> status 0
|
||||
```
|
||||
|
||||
**Control device:**
|
||||
```bash
|
||||
python3 scripts/tasmota-control.py <IP> power on|off|toggle
|
||||
python3 scripts/tasmota-control.py <IP> brightness 0-100
|
||||
python3 scripts/tasmota-control.py <IP> color <hex-rgb>
|
||||
```
|
||||
|
||||
## Discovery
|
||||
|
||||
### Network Scan
|
||||
|
||||
Run a full network scan to find all Tasmota devices:
|
||||
|
||||
```bash
|
||||
python3 scripts/tasmota-discovery.py
|
||||
```
|
||||
|
||||
The script:
|
||||
1. Performs ping sweep to identify live hosts
|
||||
2. Scans HTTP (port 80) on live hosts
|
||||
3. Detects Tasmota via Server header (e.g., "Tasmota/13.1.0")
|
||||
4. Confirms via JSON API (`/cm?cmnd=status%200`)
|
||||
5. Retrieves device names, versions, and hardware
|
||||
|
||||
Output includes:
|
||||
- IP address
|
||||
- Device friendly name
|
||||
- Tasmota version
|
||||
- Hardware platform (ESP8266/ESP32)
|
||||
- Response time
|
||||
|
||||
### Identification Signals
|
||||
|
||||
Tasmota devices are identified by:
|
||||
- **Server header:** `Tasmota/<version> (<hardware>)`
|
||||
- **JSON API response:** `/cm?cmnd=status%200` returns structured status
|
||||
- **Content keywords:** "Tasmota" in HTML
|
||||
|
||||
## Device Control
|
||||
|
||||
### Power Control
|
||||
|
||||
Toggle or set power state:
|
||||
|
||||
```bash
|
||||
# Toggle
|
||||
python3 scripts/tasmota-control.py <IP> power toggle
|
||||
|
||||
# On/Off
|
||||
python3 scripts/tasmota-control.py <IP> power on
|
||||
python3 scripts/tasmota-control.py <IP> power off
|
||||
```
|
||||
|
||||
### Brightness (Dimmers)
|
||||
|
||||
Set brightness level (0-100):
|
||||
|
||||
```bash
|
||||
python3 scripts/tasmota-control.py <IP> brightness 50
|
||||
```
|
||||
|
||||
Works on devices with Dimmer support (check StatusSTS).
|
||||
|
||||
### Color (RGB Lights)
|
||||
|
||||
Set RGB color (hex or comma-separated):
|
||||
|
||||
```bash
|
||||
# Hex format
|
||||
python3 scripts/tasmota-control.py <IP> color FF0000 # Red
|
||||
python3 scripts/tasmota-control.py <IP> color 00FF00 # Green
|
||||
|
||||
# RGB comma format
|
||||
python3 scripts/tasmota-control.py <IP> color 255,0,0
|
||||
```
|
||||
|
||||
Works on devices with RGB support (AiYaTo-RGBCW, etc.).
|
||||
|
||||
## Status Queries
|
||||
|
||||
### Device Status
|
||||
|
||||
Retrieve status information:
|
||||
|
||||
```bash
|
||||
# Basic status
|
||||
python3 scripts/tasmota-control.py <IP> status 0
|
||||
|
||||
# All statuses
|
||||
python3 scripts/tasmota-control.py <IP> status all
|
||||
```
|
||||
|
||||
**Status codes:**
|
||||
- `0 = Status` - Device info, friendly name, power state
|
||||
- `1 = StatusPRM` - Parameters, uptime, MAC
|
||||
- `2 = StatusFWR` - Firmware version, hardware
|
||||
- `3 = StatusLOG` - Log settings
|
||||
- `4 = StatusNET` - Network config (IP, gateway, WiFi)
|
||||
- `5 = StatusMQT` - MQTT configuration
|
||||
- `9 = StatusTIM` - Time, timezone, sunrise/sunset
|
||||
|
||||
### Key Status Fields
|
||||
|
||||
**StatusSTS (Status 0):**
|
||||
- `POWER` - Current state (ON/OFF)
|
||||
- `Dimmer` - Brightness level (0-100)
|
||||
- `Wifi.RSSI` - Signal strength
|
||||
- `Wifi.SSId` - Connected WiFi network
|
||||
|
||||
**StatusNET:**
|
||||
- `IPAddress` - Device IP
|
||||
- `Hostname` - mDNS hostname
|
||||
- `Mac` - MAC address
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
### Get All Device Status
|
||||
|
||||
```bash
|
||||
python3 scripts/tasmota-status.py
|
||||
```
|
||||
|
||||
Iterates through inventory file and shows power state for all devices.
|
||||
|
||||
### Managing Inventory
|
||||
|
||||
Devices are tracked in a CSV inventory file. Format:
|
||||
|
||||
```
|
||||
IP Address,Device Name,Version,Hardware,Response Time (ms)
|
||||
192.168.1.116,Office Hall Light,13.1.0,ESP8266EX,53
|
||||
```
|
||||
|
||||
After discovery, save output to inventory file for batch operations.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Finding Labeled Devices
|
||||
|
||||
```bash
|
||||
# Scan and grep for specific device names
|
||||
python3 scripts/tasmota-discovery.py | grep "Kitchen"
|
||||
python3 scripts/tasmota-discovery.py | grep "Bulb"
|
||||
```
|
||||
|
||||
### Checking All Lights
|
||||
|
||||
```bash
|
||||
# Get status of all devices
|
||||
python3 scripts/tasmota-status.py
|
||||
```
|
||||
|
||||
### Power Cycle a Device
|
||||
|
||||
```bash
|
||||
# Off, wait 2s, on
|
||||
python3 scripts/tasmota-control.py 192.168.1.116 power off
|
||||
sleep 2
|
||||
python3 scripts/tasmota-control.py 192.168.1.116 power on
|
||||
```
|
||||
|
||||
## Tasmota API Reference
|
||||
|
||||
### Command Format
|
||||
|
||||
```
|
||||
http://<IP>/cm?cmnd=<COMMAND>
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `Power` | Toggle power |
|
||||
| `Power ON` | Turn on |
|
||||
| `Power OFF` | Turn off |
|
||||
| `Power TOGGLE` | Toggle state |
|
||||
| `Status 0` | Device status |
|
||||
| `Status 4` | Network status |
|
||||
| `Dimmer <0-100>` | Set brightness |
|
||||
| `Color <hex>` | Set RGB color |
|
||||
| `Fade <ON|OFF>` | Enable fade effects |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Device Not Found
|
||||
|
||||
- Verify device is on same subnet
|
||||
- Check device has HTTP server enabled (Webserver 2 in config)
|
||||
- Ensure device is powered on and connected to WiFi
|
||||
- Try direct HTTP request: `curl http://<IP>/cm?cmnd=Status%200`
|
||||
|
||||
### Timeout Errors
|
||||
|
||||
- Device may be power saving (WiFi sleep)
|
||||
- Network latency or packet loss
|
||||
- Check device uptime for recent restarts
|
||||
|
||||
### Unknown Power State
|
||||
|
||||
Some devices (BLE gateways, sensors) don't have power control. Check capability in StatusSTS.
|
||||
|
||||
## Network Configuration
|
||||
|
||||
Tasmota devices typically:
|
||||
- Connect on WiFi channel 1-11
|
||||
- Use DHCP (check StatusNET for current IP)
|
||||
- May respond to mDNS (hostname patterns: tasmota-XXXXXX)
|
||||
- Use HTTP on port 80 (standard)
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Scan during network maintenance windows (avoid peak usage)
|
||||
- Cache inventory file to avoid repeated scans
|
||||
- Use device friendly names for easier identification
|
||||
- Set static IPs for critical devices (via Tasmota web UI or DHCP reservations)
|
||||
- Group devices by location/function in inventory comments
|
||||
|
||||
## Resources
|
||||
|
||||
### scripts/tasmota-discovery.py
|
||||
Network scanner that finds live hosts and identifies Tasmota devices via HTTP and JSON API.
|
||||
|
||||
### scripts/tasmota-control.py
|
||||
Device controller supporting power, brightness, color, and status queries via Tasmota JSON API.
|
||||
|
||||
### scripts/tasmota-status.py
|
||||
Bulk status checker that queries all devices in inventory and displays power states.
|
||||
101
scripts/tasmota-control.py
Executable file
101
scripts/tasmota-control.py
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tasmota Device Controller
|
||||
Control Tasmota devices via HTTP JSON API
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
def tasmota_command(ip, command, timeout=5):
|
||||
"""Send command to Tasmota device"""
|
||||
try:
|
||||
# Tasmota JSON API format: /cm?cmnd=COMMAND
|
||||
cmd = command.replace(' ', '%20')
|
||||
url = f"http://{ip}/cm?cmnd={cmd}"
|
||||
|
||||
req = Request(url, headers={'User-Agent': 'Tasmota-Controller'})
|
||||
with urlopen(req, timeout=timeout) as response:
|
||||
data = response.read()
|
||||
return json.loads(data.decode('utf-8'))
|
||||
except Exception as e:
|
||||
return {"ERROR": str(e)}
|
||||
|
||||
def toggle_power(ip):
|
||||
"""Toggle power on/off"""
|
||||
return tasmota_command(ip, "Power")
|
||||
|
||||
def set_power(ip, state):
|
||||
"""Set power state (ON/OFF/TOGGLE)"""
|
||||
return tasmota_command(ip, f"Power {state.upper()}")
|
||||
|
||||
def get_status(ip, status_type="0"):
|
||||
"""Get device status
|
||||
status_type codes:
|
||||
0 = Status
|
||||
1 = StatusPRM (Parameters)
|
||||
2 = StatusFWR (Firmware)
|
||||
3 = StatusLOG (Log)
|
||||
4 = StatusNET (Network)
|
||||
5 = StatusMQT (MQTT)
|
||||
9 = StatusTIM (Time)
|
||||
"""
|
||||
return tasmota_command(ip, f"Status {status_type}")
|
||||
|
||||
def set_brightness(ip, level):
|
||||
"""Set brightness (0-100)"""
|
||||
return tasmota_command(ip, f"Dimmer {level}")
|
||||
|
||||
def set_color(ip, color):
|
||||
"""Set RGB color (hex or r,g,b)"""
|
||||
return tasmota_command(ip, f"Color {color}")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage:")
|
||||
print(" python3 tasmota-control.py <IP> power [on|off|toggle]")
|
||||
print(" python3 tasmota-control.py <IP> status [0-9|all]")
|
||||
print(" python3 tasmota-control.py <IP> brightness <0-100>")
|
||||
print(" python3 tasmota-control.py <IP> color <hex>")
|
||||
sys.exit(1)
|
||||
|
||||
ip = sys.argv[1]
|
||||
cmd = sys.argv[2].lower()
|
||||
|
||||
if cmd == "power":
|
||||
if len(sys.argv) > 3:
|
||||
result = set_power(ip, sys.argv[3])
|
||||
else:
|
||||
result = toggle_power(ip)
|
||||
elif cmd == "status":
|
||||
if len(sys.argv) > 3:
|
||||
status_type = sys.argv[3]
|
||||
if status_type == "all":
|
||||
# Get all statuses
|
||||
result = {"statuses": {}}
|
||||
for st in ["0", "1", "2", "3", "4", "5", "9"]:
|
||||
result["statuses"][st] = get_status(ip, st)
|
||||
else:
|
||||
result = get_status(ip, status_type)
|
||||
else:
|
||||
result = get_status(ip, "0")
|
||||
elif cmd == "brightness":
|
||||
if len(sys.argv) < 4:
|
||||
print("Error: brightness requires level (0-100)")
|
||||
sys.exit(1)
|
||||
result = set_brightness(ip, sys.argv[3])
|
||||
elif cmd == "color":
|
||||
if len(sys.argv) < 4:
|
||||
print("Error: color requires hex value (e.g., FF0000)")
|
||||
sys.exit(1)
|
||||
result = set_color(ip, sys.argv[3])
|
||||
else:
|
||||
# Raw command
|
||||
result = tasmota_command(ip, ' '.join(sys.argv[2:]))
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
166
scripts/tasmota-discovery.py
Executable file
166
scripts/tasmota-discovery.py
Executable file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tasmota Device Discovery
|
||||
Scans network for Tasmota devices by:
|
||||
1. HTTP-based detection (port 80)
|
||||
2. Checking for Tasmota JSON API response
|
||||
3. Looking for Tasmota user-agent in responses
|
||||
"""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import concurrent.futures
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError, HTTPError
|
||||
import json
|
||||
import time
|
||||
|
||||
# Network configuration
|
||||
SUBNET = "192.168.1.0/24"
|
||||
TIMEOUT = 2 # seconds
|
||||
|
||||
def get_local_ip():
|
||||
"""Get local IP to determine subnet"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except:
|
||||
return "192.168.1.148"
|
||||
|
||||
def get_subnet_ips():
|
||||
"""Generate list of IPs in subnet"""
|
||||
local_ip = get_local_ip()
|
||||
parts = local_ip.split('.')
|
||||
base = f"{parts[0]}.{parts[1]}.{parts[2]}"
|
||||
return [f"{base}.{i}" for i in range(1, 255)]
|
||||
|
||||
def check_http(ip):
|
||||
"""Check if IP has HTTP service that might be Tasmota"""
|
||||
try:
|
||||
# Try basic HTTP request first
|
||||
req = Request(f"http://{ip}/", headers={'User-Agent': 'Tasmota-Scanner'})
|
||||
start = time.time()
|
||||
with urlopen(req, timeout=TIMEOUT) as response:
|
||||
elapsed = time.time() - start
|
||||
data = response.read(5000).decode('utf-8', errors='ignore')
|
||||
headers = dict(response.headers)
|
||||
|
||||
# Check for Tasmota indicators
|
||||
is_tasmota = False
|
||||
evidence = []
|
||||
|
||||
# Check for Tasmota-specific headers/content
|
||||
if 'tasmota' in headers.get('Server', '').lower():
|
||||
is_tasmota = True
|
||||
evidence.append(f"Server header: {headers.get('Server')}")
|
||||
|
||||
# Check content for Tasmota patterns
|
||||
if 'tasmota' in data.lower():
|
||||
is_tasmota = True
|
||||
evidence.append("Tasmota keyword in content")
|
||||
|
||||
# Check for known Tasmota page titles/elements
|
||||
if 'Status' in data and 'tasmota' in data.lower():
|
||||
is_tasmota = True
|
||||
evidence.append("Tasmota status page detected")
|
||||
|
||||
# Try Tasmota JSON API
|
||||
try:
|
||||
req_api = Request(f"http://{ip}/cm?cmnd=status%200")
|
||||
with urlopen(req_api, timeout=1) as api_response:
|
||||
api_data = api_response.read(1000).decode('utf-8', errors='ignore')
|
||||
if 'tasmota' in api_data.lower() or 'StatusSNS' in api_data or 'StatusNET' in api_data:
|
||||
is_tasmota = True
|
||||
evidence.append("Tasmota JSON API response")
|
||||
except:
|
||||
pass
|
||||
|
||||
if is_tasmota:
|
||||
try:
|
||||
title = data.split('<title>')[1].split('</title>')[0].strip()
|
||||
except:
|
||||
title = "N/A"
|
||||
|
||||
return {
|
||||
'ip': ip,
|
||||
'port': 80,
|
||||
'title': title,
|
||||
'server': headers.get('Server', 'Unknown'),
|
||||
'evidence': evidence,
|
||||
'elapsed': elapsed
|
||||
}
|
||||
return None
|
||||
|
||||
except (URLError, HTTPError, socket.timeout, ConnectionResetError):
|
||||
return None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def ping_scan(ip):
|
||||
"""Quick ping check to see if host is up"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ping', '-c', '1', '-W', '1', ip],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
return result.returncode == 0
|
||||
except:
|
||||
return False
|
||||
|
||||
def discover_tasmota():
|
||||
"""Main discovery function"""
|
||||
print(f"🔍 Scanning network for Tasmota devices...")
|
||||
print(f"📍 Local subnet: {SUBNET}")
|
||||
|
||||
ips = get_subnet_ips()
|
||||
print(f"📡 Checking {len(ips)} IP addresses...")
|
||||
|
||||
tasmota_devices = []
|
||||
|
||||
# First, quick ping sweep to find live hosts
|
||||
print("\n1️⃣ Ping sweep (finding live hosts)...")
|
||||
live_hosts = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
|
||||
future_to_ip = {executor.submit(ping_scan, ip): ip for ip in ips}
|
||||
for future in concurrent.futures.as_completed(future_to_ip):
|
||||
ip = future_to_ip[future]
|
||||
if future.result():
|
||||
live_hosts.append(ip)
|
||||
print(f" ✅ {ip}")
|
||||
|
||||
print(f"\n📍 Found {len(live_hosts)} live hosts")
|
||||
print(f"\n2️⃣ Scanning for Tasmota (HTTP) on live hosts...")
|
||||
|
||||
# Then check HTTP on live hosts
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
|
||||
future_to_ip = {executor.submit(check_http, ip): ip for ip in live_hosts}
|
||||
for future in concurrent.futures.as_completed(future_to_ip):
|
||||
result = future.result()
|
||||
if result:
|
||||
tasmota_devices.append(result)
|
||||
|
||||
return tasmota_devices
|
||||
|
||||
if __name__ == "__main__":
|
||||
devices = discover_tasmota()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if devices:
|
||||
print(f"✅ Found {len(devices)} Tasmota device(s):\n")
|
||||
for i, device in enumerate(devices, 1):
|
||||
print(f"🔌 Device {i}:")
|
||||
print(f" IP: {device['ip']}:{device['port']}")
|
||||
print(f" Title: {device['title']}")
|
||||
print(f" Server: {device['server']}")
|
||||
print(f" Evidence:")
|
||||
for ev in device['evidence']:
|
||||
print(f" • {ev}")
|
||||
print(f" Response time: {device['elapsed']:.3f}s")
|
||||
print()
|
||||
else:
|
||||
print("❌ No Tasmota devices found")
|
||||
print(f"{'='*60}")
|
||||
57
scripts/tasmota-status.py
Executable file
57
scripts/tasmota-status.py
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Get summary of all Tasmota devices from inventory
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
CSV_FILE = Path.home() / ".openclaw/workspace/memory/tasmota-inventory.csv"
|
||||
|
||||
def get_device_status(ip):
|
||||
"""Get power status from device"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", Path.home() / ".openclaw/workspace/scripts/tasmota-control.py", ip, "status", "0"],
|
||||
capture_output=True,
|
||||
timeout=3
|
||||
)
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
if "StatusSTS" in data:
|
||||
return data["StatusSTS"].get("POWER", "UNKNOWN")
|
||||
if "Status" in data:
|
||||
return data["Status"].get("Power", "ON" if data["Status"].get("Power") == 1 else "OFF")
|
||||
return "UNKNOWN"
|
||||
except:
|
||||
return "TIMEOUT"
|
||||
|
||||
print("🔍 Tasmota Device Summary")
|
||||
print("="*80)
|
||||
|
||||
devices = []
|
||||
with open(CSV_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 3 and parts[0].strip().startswith('192'):
|
||||
ip = parts[0].strip()
|
||||
name = parts[1].strip().replace(' - Main Menu', '')
|
||||
version = parts[2].strip()
|
||||
devices.append((ip, name, version))
|
||||
|
||||
# Quick check of first few devices
|
||||
print(f"Found {len(devices)} devices\n")
|
||||
print(f"{'IP Address':<18} {'Device Name':<30} {'Version':<12} {'Power'}")
|
||||
print("-"*80)
|
||||
|
||||
for ip, name, version in devices:
|
||||
power = get_device_status(ip)
|
||||
print(f"{ip:<18} {name[:28]:<30} {version:<12} {power}")
|
||||
time.sleep(0.1) # Don't overwhelm the network
|
||||
|
||||
print("="*80)
|
||||
Reference in New Issue
Block a user