#!/usr/bin/env python3 """ Lightweight Smart Home Controller - Docker Edition A simple Flask app to control TP-Link switches and WebOS TVs """ from flask import Flask, render_template, request, jsonify import asyncio import json import socket import struct import websockets import ssl import requests from contextlib import asynccontextmanager import logging import consul import json from threading import Lock import time import os import sys # Configure logging for container logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(sys.stdout) ] ) app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', 'your-secret-key-change-this') logger = logging.getLogger(__name__) # Consul configuration CONSUL_HOST = 'consul.service.dc1.consul' CONSUL_PORT = 8500 CONSUL_BASE_KEY = 'MiniHass/' class ConsulConfigManager: """Manage configuration using Consul KV store""" def __init__(self, host=CONSUL_HOST, port=CONSUL_PORT, base_key=CONSUL_BASE_KEY): self.client = consul.Consul(host=host, port=port) self.base_key = base_key def get(self, key): """Get value from Consul""" _, data = self.client.kv.get(f"{self.base_key}{key}") return data['Value'].decode() if data else None def put(self, key, value): """Store value in Consul""" return self.client.kv.put(f"{self.base_key}{key}", value) def get_json(self, key): """Get JSON value from Consul""" value = self.get(key) return json.loads(value) if value else None def put_json(self, key, value): """Store JSON value in Consul""" return self.put(key, json.dumps(value)) # Initialize Consul config manager consul_config = ConsulConfigManager() # Load configuration from Consul config = consul_config.get_json('config') or { 'tplink_ip': os.environ.get('TPLINK_IP', '192.168.1.100'), 'tv_ip': os.environ.get('TV_IP', '192.168.1.101'), 'tv_mac': os.environ.get('TV_MAC', 'AA:BB:CC:DD:EE:FF') } # Config file path for persistence CONFIG_DIR = '/app/config' CONFIG_FILE = os.path.join(CONFIG_DIR, 'config.json') # Device states cache device_states = { 'tplink': False, 'tv': False, 'last_update': 0 } # Thread lock for state updates state_lock = Lock() class TPLinkDevice: """Control TP-Link Kasa devices using the local protocol""" @staticmethod def encrypt(string): """Encrypt command for TP-Link protocol""" key = 171 result = struct.pack('>I', len(string)) for char in string: a = key ^ ord(char) key = a result += bytes([a]) return result @staticmethod def decrypt(data): """Decrypt response from TP-Link protocol""" key = 171 result = "" for byte in data: a = key ^ byte key = byte result += chr(a) return result @staticmethod def send_command(ip, command, port=9999, timeout=5): """Send command to TP-Link device""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect((ip, port)) sock.send(TPLinkDevice.encrypt(json.dumps(command))) # Receive response data = sock.recv(2048) sock.close() # Skip the first 4 bytes (length header) and decrypt response = TPLinkDevice.decrypt(data[4:]) return json.loads(response) except Exception as e: logger.error(f"TP-Link command error for {ip}: {e}") return None @staticmethod def get_info(ip): """Get device information""" command = {"system": {"get_sysinfo": {}}} return TPLinkDevice.send_command(ip, command) @staticmethod def turn_on(ip): """Turn on the device""" command = {"system": {"set_relay_state": {"state": 1}}} return TPLinkDevice.send_command(ip, command) @staticmethod def turn_off(ip): """Turn off the device""" command = {"system": {"set_relay_state": {"state": 0}}} return TPLinkDevice.send_command(ip, command) class WebOSTV: """Control WebOS TV using aiowebostv library""" def __init__(self, ip): self.ip = ip self.consul_key = f'tv_credentials/{ip.replace(".", "_")}' def load_client_key(self): """Load client key from Consul""" return consul_config.get(self.consul_key) def save_client_key(self, key): """Save client key to Consul""" consul_config.put(self.consul_key, key) async def _execute_command(self, command): """Execute TV command with automatic connection handling""" from aiowebostv import WebOsClient try: client_key = self.load_client_key() async with WebOsClient(self.ip, client_key) as client: # If we have a new client key after connection, save it if client.client_key and client.client_key != client_key: self.save_client_key(client.client_key) if command == "turn_off": await client.turn_off() return True elif command == "turn_on": await client.turn_on() return True elif command == "get_power": return client.power_state == "on" except Exception as e: logger.error(f"WebOS TV error: {e}") return None async def turn_screen_off(self): """Turn off TV screen""" return await self._execute_command("turn_off") async def turn_screen_on(self): """Turn on TV screen""" return await self._execute_command("turn_on") async def get_power_state(self): """Get TV power state""" return await self._execute_command("get_power") def update_device_state(device, state): """Thread-safe device state update""" with state_lock: device_states[device] = state device_states['last_update'] = time.time() @app.route('/') def index(): """Serve the main interface""" return render_template('index.html') @app.route('/health') def health_check(): """Health check endpoint for container monitoring""" # Check Consul connectivity consul_ok = True try: # Try to get a key to verify connectivity consul_config.get('healthcheck') except Exception as e: logger.error(f"Consul connection error: {e}") consul_ok = False return jsonify({ 'status': 'healthy', 'timestamp': time.time(), 'config': {k: v for k, v in config.items() if k != 'tv_mac'}, # Don't expose MAC 'services': { 'consul_connected': consul_ok } }) @app.route('/debug') def debug_info(): """Debug information endpoint""" import platform import sys debug_data = { 'flask': { 'debug_mode': app.debug, 'testing': app.testing, 'version': flask.__version__ if 'flask' in globals() else 'unknown' }, 'system': { 'python_version': sys.version, 'platform': platform.platform(), 'hostname': socket.gethostname() }, 'app': { 'working_directory': os.getcwd(), 'template_dir_exists': os.path.exists('templates'), 'index_template_exists': os.path.exists('templates/index.html'), 'config_dir_exists': os.path.exists(CONFIG_DIR), 'config': config }, 'files': { 'templates': [f for f in os.listdir('templates')] if os.path.exists('templates') else [], 'app_dir': [f for f in os.listdir('.') if not f.startswith('.')] }, 'environment': { 'FLASK_DEBUG': os.environ.get('FLASK_DEBUG', 'not set'), 'FLASK_ENV': os.environ.get('FLASK_ENV', 'not set'), 'PYTHONUNBUFFERED': os.environ.get('PYTHONUNBUFFERED', 'not set') } } if request.args.get('format') == 'json': return jsonify(debug_data) else: # Return as HTML for easy browser viewing html = "
" + json.dumps(debug_data, indent=2, default=str) + "" return html @app.route('/api/config', methods=['GET', 'POST']) def handle_config(): """Handle configuration updates""" global config if request.method == 'POST': data = request.get_json() config.update(data) # Save to Consul consul_config.put_json('config', config) logger.info("Configuration saved to Consul") return jsonify({'status': 'success', 'message': 'Configuration updated and saved to Consul'}) return jsonify(config) @app.route('/api/status') def get_status(): """Get current device states""" return jsonify(device_states) @app.route('/api/tplink/