|
|
|
|
@ -1,422 +0,0 @@
|
|
|
|
|
#!/usr/bin/env python3 |
|
|
|
|
""" |
|
|
|
|
Tuya Cloud MQTT Bridge (General Purpose) |
|
|
|
|
Publishes status and handles commands for ALL Tuya devices via MQTT. |
|
|
|
|
""" |
|
|
|
|
import paho.mqtt.client as mqtt |
|
|
|
|
import tinytuya |
|
|
|
|
import json |
|
|
|
|
import time |
|
|
|
|
import logging |
|
|
|
|
import re |
|
|
|
|
import threading |
|
|
|
|
from datetime import datetime |
|
|
|
|
|
|
|
|
|
# Configuration |
|
|
|
|
MQTT_BROKER = "mqtt" |
|
|
|
|
MQTT_PORT = 1883 |
|
|
|
|
MQTT_CLIENT_ID = "tuya_bridge_general" |
|
|
|
|
MQTT_TOPIC_PREFIX = "tuya" |
|
|
|
|
|
|
|
|
|
# Tuya Cloud Credentials |
|
|
|
|
TUYA_REGION = "eu" |
|
|
|
|
TUYA_KEY = "3vkr7nafauo7ro69yeps" |
|
|
|
|
TUYA_SECRET = "75c2e0ab536c4029bb1912f89cb5c6d9" |
|
|
|
|
TUYA_DEVICE_ID = "bf6a2ef9072cf7b319qgh1" # Any valid device ID from the account |
|
|
|
|
|
|
|
|
|
# Door/Curtain Configuration |
|
|
|
|
DOOR_DEVICE_ID = "bfd206260a90dcb6ec8uah" |
|
|
|
|
URI_TRIGGER_OPEN = 'cloud/scene/rule/4vxIR8nrcLzri9QW/actions/trigger' |
|
|
|
|
URI_TRIGGER_CLOSE = 'cloud/scene/rule/OY6pWbCSciVLe6lV/actions/trigger' |
|
|
|
|
|
|
|
|
|
# Update Interval (seconds) |
|
|
|
|
STATUS_INTERVAL = 60 |
|
|
|
|
|
|
|
|
|
# Logging |
|
|
|
|
logging.basicConfig( |
|
|
|
|
level=logging.INFO, |
|
|
|
|
format='%(asctime)s - %(levelname)s - %(message)s' |
|
|
|
|
) |
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
|
def slugify(text): |
|
|
|
|
"""Converts text to a valid MQTT topic slug""" |
|
|
|
|
text = text.lower().strip() |
|
|
|
|
text = re.sub(r'[^a-z0-9]+', '_', text) |
|
|
|
|
return text.strip('_') |
|
|
|
|
|
|
|
|
|
class TuyaGeneralBridge: |
|
|
|
|
def __init__(self): |
|
|
|
|
self.cloud = tinytuya.Cloud( |
|
|
|
|
apiRegion=TUYA_REGION, |
|
|
|
|
apiKey=TUYA_KEY, |
|
|
|
|
apiSecret=TUYA_SECRET, |
|
|
|
|
apiDeviceID=TUYA_DEVICE_ID |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
self.mqtt = mqtt.Client(MQTT_CLIENT_ID) |
|
|
|
|
self.mqtt.on_connect = self.on_connect |
|
|
|
|
self.mqtt.on_message = self.on_message |
|
|
|
|
self.mqtt.on_disconnect = self.on_disconnect |
|
|
|
|
|
|
|
|
|
self.devices = {} # id -> device_info |
|
|
|
|
self.slug_map = {} # slug -> id |
|
|
|
|
self.ac_states = {} # id -> {power, mode, temp, wind} |
|
|
|
|
self.running = True |
|
|
|
|
|
|
|
|
|
def fetch_devices(self): |
|
|
|
|
"""Fetches list of devices from Tuya Cloud""" |
|
|
|
|
logger.info("🔄 Fetching devices from Tuya Cloud...") |
|
|
|
|
try: |
|
|
|
|
devices = self.cloud.getdevices() |
|
|
|
|
if not devices: |
|
|
|
|
logger.warning("⚠️ No devices found!") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
# Pass 1: Map Local Keys to Parent IDs (Gateways/Blasters) |
|
|
|
|
key_map = {} |
|
|
|
|
for dev in devices: |
|
|
|
|
if not dev.get('sub', False) or dev['category'] in ['qt', 'wnykq', 'smart_ir']: |
|
|
|
|
if 'key' in dev: |
|
|
|
|
key_map[dev['key']] = dev['id'] |
|
|
|
|
|
|
|
|
|
logger.info(f"🔑 Found {len(key_map)} potential parent devices (by key)") |
|
|
|
|
|
|
|
|
|
# Pass 2: Process all devices |
|
|
|
|
for dev in devices: |
|
|
|
|
dev_id = dev['id'] |
|
|
|
|
name = dev['name'] |
|
|
|
|
category = dev['category'] |
|
|
|
|
slug = slugify(name) |
|
|
|
|
|
|
|
|
|
# Handle duplicate slugs |
|
|
|
|
original_slug = slug |
|
|
|
|
counter = 1 |
|
|
|
|
while slug in self.slug_map and self.slug_map[slug] != dev_id: |
|
|
|
|
slug = f"{original_slug}_{counter}" |
|
|
|
|
counter += 1 |
|
|
|
|
|
|
|
|
|
parent_id = None |
|
|
|
|
if category == 'infrared_ac': |
|
|
|
|
# Try to find parent by key |
|
|
|
|
if 'key' in dev and dev['key'] in key_map: |
|
|
|
|
parent_id = key_map[dev['key']] |
|
|
|
|
logger.info(f"🔗 Linked AC {name} to Parent {parent_id}") |
|
|
|
|
# Initialize virtual state |
|
|
|
|
self.ac_states[dev_id] = { |
|
|
|
|
'power': 0, |
|
|
|
|
'mode': 2, # Auto |
|
|
|
|
'temp': 24, |
|
|
|
|
'wind': 0 # Auto |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
self.devices[dev_id] = { |
|
|
|
|
'name': name, |
|
|
|
|
'slug': slug, |
|
|
|
|
'category': category, |
|
|
|
|
'data': dev, |
|
|
|
|
'parent_id': parent_id |
|
|
|
|
} |
|
|
|
|
self.slug_map[slug] = dev_id |
|
|
|
|
logger.info(f"📱 Discovered: {name} ({category}) -> tuya/{slug}") |
|
|
|
|
|
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"❌ Error fetching devices: {e}") |
|
|
|
|
|
|
|
|
|
def on_connect(self, client, userdata, flags, rc): |
|
|
|
|
if rc == 0: |
|
|
|
|
logger.info(f"✅ Connected to MQTT: {MQTT_BROKER}") |
|
|
|
|
# Subscribe to all command topics |
|
|
|
|
topic = f"{MQTT_TOPIC_PREFIX}/+/command" |
|
|
|
|
client.subscribe(topic) |
|
|
|
|
logger.info(f"📥 Subscribed to: {topic}") |
|
|
|
|
|
|
|
|
|
# Publish bridge status |
|
|
|
|
client.publish(f"{MQTT_TOPIC_PREFIX}/bridge/status", "online", retain=True) |
|
|
|
|
|
|
|
|
|
# Publish discovery info |
|
|
|
|
self.publish_discovery() |
|
|
|
|
else: |
|
|
|
|
logger.error(f"❌ MQTT Connection failed: {rc}") |
|
|
|
|
|
|
|
|
|
def on_disconnect(self, client, userdata, rc): |
|
|
|
|
logger.warning(f"⚠️ Disconnected from MQTT (rc: {rc})") |
|
|
|
|
|
|
|
|
|
def on_message(self, client, userdata, msg): |
|
|
|
|
try: |
|
|
|
|
topic_parts = msg.topic.split('/') |
|
|
|
|
if len(topic_parts) != 3 or topic_parts[2] != 'command': |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
slug = topic_parts[1] |
|
|
|
|
payload = msg.payload.decode('utf-8').strip() |
|
|
|
|
|
|
|
|
|
if slug not in self.slug_map: |
|
|
|
|
logger.warning(f"⚠️ Unknown device slug: {slug}") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
dev_id = self.slug_map[slug] |
|
|
|
|
self.handle_command(dev_id, payload) |
|
|
|
|
|
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"❌ Error handling message: {e}") |
|
|
|
|
|
|
|
|
|
def handle_command(self, dev_id, payload): |
|
|
|
|
"""Process command for a device""" |
|
|
|
|
dev_info = self.devices[dev_id] |
|
|
|
|
dev_name = dev_info['name'] |
|
|
|
|
category = dev_info['category'] |
|
|
|
|
logger.info(f"📨 Command for {dev_name} (ID: {dev_id}): {payload}") |
|
|
|
|
logger.info(f"🔍 Checking against DOOR_DEVICE_ID: {DOOR_DEVICE_ID}") |
|
|
|
|
|
|
|
|
|
# Special Handling for Door/Curtain (Scene Trigger) |
|
|
|
|
if dev_id == DOOR_DEVICE_ID: |
|
|
|
|
logger.info("🚪 Match found! Handling door command...") |
|
|
|
|
self.handle_door_command(payload) |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
# Special Handling for Infrared AC |
|
|
|
|
if category == 'infrared_ac' and dev_info.get('parent_id'): |
|
|
|
|
self.handle_ac_command(dev_id, payload) |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
commands = {} |
|
|
|
|
|
|
|
|
|
# Try parsing JSON |
|
|
|
|
try: |
|
|
|
|
data = json.loads(payload) |
|
|
|
|
if 'commands' in data: |
|
|
|
|
commands = data # Already in Tuya format |
|
|
|
|
elif 'code' in data and 'value' in data: |
|
|
|
|
commands = {'commands': [data]} |
|
|
|
|
else: |
|
|
|
|
# Assume simple key-value pairs |
|
|
|
|
cmds = [] |
|
|
|
|
for k, v in data.items(): |
|
|
|
|
cmds.append({'code': k, 'value': v}) |
|
|
|
|
commands = {'commands': cmds} |
|
|
|
|
except json.JSONDecodeError: |
|
|
|
|
# Handle simple string commands (shortcuts) |
|
|
|
|
cmd_str = payload.lower() |
|
|
|
|
category = self.devices[dev_id]['category'] |
|
|
|
|
|
|
|
|
|
# Robot Vacuum Shortcuts |
|
|
|
|
if category == 'sd': |
|
|
|
|
if cmd_str in ['start', 'clean']: |
|
|
|
|
commands = {'commands': [{'code': 'power_go', 'value': True}]} |
|
|
|
|
elif cmd_str in ['stop', 'pause']: |
|
|
|
|
commands = {'commands': [{'code': 'power_go', 'value': False}]} # Or pause |
|
|
|
|
# Note: Some robots use 'pause' code, others power_go=False |
|
|
|
|
# Based on previous OKP analysis: 'pause' code exists |
|
|
|
|
if cmd_str == 'pause': |
|
|
|
|
commands = {'commands': [{'code': 'pause', 'value': True}]} |
|
|
|
|
elif cmd_str in ['home', 'charge']: |
|
|
|
|
commands = {'commands': [{'code': 'switch_charge', 'value': True}]} |
|
|
|
|
elif cmd_str == 'seek': |
|
|
|
|
commands = {'commands': [{'code': 'seek', 'value': True}]} |
|
|
|
|
|
|
|
|
|
# Light Shortcuts |
|
|
|
|
elif category == 'dj': |
|
|
|
|
if cmd_str == 'on': |
|
|
|
|
commands = {'commands': [{'code': 'switch_led', 'value': True}]} |
|
|
|
|
elif cmd_str == 'off': |
|
|
|
|
commands = {'commands': [{'code': 'switch_led', 'value': False}]} |
|
|
|
|
|
|
|
|
|
# Switch/Socket Shortcuts |
|
|
|
|
elif category in ['cz', 'kg']: |
|
|
|
|
if cmd_str == 'on': |
|
|
|
|
commands = {'commands': [{'code': 'switch_1', 'value': True}]} |
|
|
|
|
elif cmd_str == 'off': |
|
|
|
|
commands = {'commands': [{'code': 'switch_1', 'value': False}]} |
|
|
|
|
|
|
|
|
|
if not commands: |
|
|
|
|
logger.warning(f"⚠️ Could not parse command for {dev_name}: {payload}") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
# Send to Tuya |
|
|
|
|
try: |
|
|
|
|
logger.info(f"🚀 Sending to Tuya: {commands}") |
|
|
|
|
res = self.cloud.sendcommand(dev_id, commands) |
|
|
|
|
if res and res.get('success'): |
|
|
|
|
logger.info(f"✅ Command executed for {dev_name}") |
|
|
|
|
# Force immediate status update |
|
|
|
|
time.sleep(1) |
|
|
|
|
self.update_device_status(dev_id) |
|
|
|
|
else: |
|
|
|
|
logger.error(f"❌ Command failed: {res}") |
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"❌ Error sending command: {e}") |
|
|
|
|
|
|
|
|
|
def handle_door_command(self, payload): |
|
|
|
|
"""Handle commands for Door/Curtain using Scene Triggers""" |
|
|
|
|
cmd = payload.lower().strip() |
|
|
|
|
uri = None |
|
|
|
|
|
|
|
|
|
# Parse JSON if needed |
|
|
|
|
try: |
|
|
|
|
data = json.loads(payload) |
|
|
|
|
if 'commands' in data: |
|
|
|
|
# Check for scene codes if passed as JSON |
|
|
|
|
for c in data['commands']: |
|
|
|
|
if c.get('code') == 'scene_1': # Assuming scene_1 maps to Open |
|
|
|
|
uri = URI_TRIGGER_OPEN |
|
|
|
|
elif c.get('code') == 'scene_2': # Assuming scene_2 maps to Close |
|
|
|
|
uri = URI_TRIGGER_CLOSE |
|
|
|
|
elif 'open' in data: # Simple JSON {"open": true} |
|
|
|
|
uri = URI_TRIGGER_OPEN if data['open'] else URI_TRIGGER_CLOSE |
|
|
|
|
except: |
|
|
|
|
pass |
|
|
|
|
|
|
|
|
|
# Simple String Commands |
|
|
|
|
if cmd in ['open', 'on', 'true']: |
|
|
|
|
uri = URI_TRIGGER_OPEN |
|
|
|
|
elif cmd in ['close', 'off', 'false']: |
|
|
|
|
uri = URI_TRIGGER_CLOSE |
|
|
|
|
|
|
|
|
|
if uri: |
|
|
|
|
logger.info(f"🚪 Triggering Door Scene: {uri}") |
|
|
|
|
try: |
|
|
|
|
self.cloud._tuyaplatform(uri=uri, ver="v2.0", action="POST") |
|
|
|
|
logger.info("✅ Door Scene Triggered Successfully") |
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"❌ Error triggering door scene: {e}") |
|
|
|
|
else: |
|
|
|
|
logger.warning(f"⚠️ Unknown door command: {payload}") |
|
|
|
|
|
|
|
|
|
def handle_ac_command(self, dev_id, payload): |
|
|
|
|
"""Handle commands for IR AC using v2.0 API""" |
|
|
|
|
parent_id = self.devices[dev_id]['parent_id'] |
|
|
|
|
state = self.ac_states.get(dev_id, {'power': 0, 'mode': 2, 'temp': 24, 'wind': 0}) |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
data = json.loads(payload) |
|
|
|
|
except: |
|
|
|
|
logger.error(f"❌ Invalid JSON for AC command: {payload}") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
# Update Virtual State |
|
|
|
|
# Handle standard Tuya codes if present |
|
|
|
|
if 'PowerOn' in data: |
|
|
|
|
state['power'] = 1 |
|
|
|
|
elif 'PowerOff' in data: |
|
|
|
|
state['power'] = 0 |
|
|
|
|
elif 'T' in data: |
|
|
|
|
state['temp'] = int(data['T']) |
|
|
|
|
elif 'M' in data: |
|
|
|
|
state['mode'] = int(data['M']) |
|
|
|
|
elif 'F' in data: |
|
|
|
|
state['wind'] = int(data['F']) |
|
|
|
|
|
|
|
|
|
# Handle direct keys if sent (e.g. from Node-RED direct injection) |
|
|
|
|
if 'power' in data: state['power'] = int(data['power']) |
|
|
|
|
if 'temp' in data: state['temp'] = int(data['temp']) |
|
|
|
|
if 'mode' in data: state['mode'] = int(data['mode']) |
|
|
|
|
if 'wind' in data: state['wind'] = int(data['wind']) |
|
|
|
|
|
|
|
|
|
# Save state |
|
|
|
|
self.ac_states[dev_id] = state |
|
|
|
|
|
|
|
|
|
# Construct API Call |
|
|
|
|
url = f"/v2.0/infrareds/{parent_id}/air-conditioners/{dev_id}/scenes/command" |
|
|
|
|
logger.info(f"🚀 Sending AC State to {url}: {state}") |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
res = self.cloud.cloudrequest(url, post=state) |
|
|
|
|
if res and res.get('success'): |
|
|
|
|
logger.info(f"✅ AC Command executed for {self.devices[dev_id]['name']}") |
|
|
|
|
# Publish updated status back to MQTT so UI stays in sync |
|
|
|
|
self.update_device_status(dev_id) |
|
|
|
|
else: |
|
|
|
|
logger.error(f"❌ AC Command failed: {res}") |
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"❌ Error sending AC command: {e}") |
|
|
|
|
|
|
|
|
|
def update_device_status(self, dev_id): |
|
|
|
|
"""Fetch and publish status for a single device""" |
|
|
|
|
slug = self.devices[dev_id]['slug'] |
|
|
|
|
|
|
|
|
|
# For ACs, publish virtual state as data |
|
|
|
|
if self.devices[dev_id]['category'] == 'infrared_ac': |
|
|
|
|
state = self.ac_states.get(dev_id, {}) |
|
|
|
|
payload = { |
|
|
|
|
'timestamp': datetime.now().isoformat(), |
|
|
|
|
'online': True, |
|
|
|
|
'data': state |
|
|
|
|
} |
|
|
|
|
topic = f"{MQTT_TOPIC_PREFIX}/{slug}/status" |
|
|
|
|
self.mqtt.publish(topic, json.dumps(payload), retain=True) |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
res = self.cloud.getstatus(dev_id) |
|
|
|
|
if res and 'result' in res: |
|
|
|
|
status = {} |
|
|
|
|
for item in res['result']: |
|
|
|
|
status[item['code']] = item['value'] |
|
|
|
|
|
|
|
|
|
# Add metadata |
|
|
|
|
payload = { |
|
|
|
|
'timestamp': datetime.now().isoformat(), |
|
|
|
|
'online': True, # If we got status, it's likely online (cloud-wise) |
|
|
|
|
'data': status |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
topic = f"{MQTT_TOPIC_PREFIX}/{slug}/status" |
|
|
|
|
self.mqtt.publish(topic, json.dumps(payload), retain=True) |
|
|
|
|
# logger.debug(f"📤 Updated {slug}") |
|
|
|
|
else: |
|
|
|
|
# Offline or error |
|
|
|
|
payload = { |
|
|
|
|
'timestamp': datetime.now().isoformat(), |
|
|
|
|
'online': False, |
|
|
|
|
'error': 'No result from cloud' |
|
|
|
|
} |
|
|
|
|
self.mqtt.publish(f"{MQTT_TOPIC_PREFIX}/{slug}/status", json.dumps(payload), retain=True) |
|
|
|
|
|
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"❌ Error updating {slug}: {e}") |
|
|
|
|
|
|
|
|
|
def publish_discovery(self): |
|
|
|
|
"""Publish list of devices""" |
|
|
|
|
discovery_data = [] |
|
|
|
|
for dev_id, info in self.devices.items(): |
|
|
|
|
discovery_data.append({ |
|
|
|
|
'name': info['name'], |
|
|
|
|
'slug': info['slug'], |
|
|
|
|
'category': info['category'], |
|
|
|
|
'id': dev_id |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
self.mqtt.publish(f"{MQTT_TOPIC_PREFIX}/discovery", json.dumps(discovery_data), retain=True) |
|
|
|
|
|
|
|
|
|
def run(self): |
|
|
|
|
logger.info("🚀 Starting Tuya General Bridge...") |
|
|
|
|
self.fetch_devices() |
|
|
|
|
|
|
|
|
|
# Connect MQTT |
|
|
|
|
try: |
|
|
|
|
self.mqtt.connect(MQTT_BROKER, MQTT_PORT, 60) |
|
|
|
|
self.mqtt.loop_start() |
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"❌ MQTT Connection Error: {e}") |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
# Main Loop |
|
|
|
|
try: |
|
|
|
|
while self.running: |
|
|
|
|
logger.info("🔄 Updating all devices...") |
|
|
|
|
for dev_id in self.devices: |
|
|
|
|
self.update_device_status(dev_id) |
|
|
|
|
time.sleep(0.5) # Avoid rate limits |
|
|
|
|
|
|
|
|
|
time.sleep(STATUS_INTERVAL) |
|
|
|
|
|
|
|
|
|
except KeyboardInterrupt: |
|
|
|
|
logger.info("👋 Stopping...") |
|
|
|
|
finally: |
|
|
|
|
self.mqtt.loop_stop() |
|
|
|
|
self.mqtt.disconnect() |
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
|
bridge = TuyaGeneralBridge() |
|
|
|
|
bridge.run() |