From ed3a9957847e7d110e0988a61a1ca98afe3e61c4 Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 14 Sep 2025 15:00:47 -0700 Subject: [PATCH] sync --- README.md | 53 ++++ app.py | 721 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 45 +++ dockerfile | 36 +++ requirements.txt | 6 + templates/index.html | 310 +++++++++++++++++++ 6 files changed, 1171 insertions(+) create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 dockerfile create mode 100644 requirements.txt create mode 100644 templates/index.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..eeec243 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# MiniHass - Smart Home Controller + +## Configuration Storage in Consul + +The application now uses Consul for centralized configuration management. All settings and TV credentials are stored in Consul's key-value store. + +### Key Path Structure +- App configuration: `MiniHass/config` +- TV credentials: `MiniHass/tv_credentials/` + +### Initial Setup +1. Set environment variables in docker-compose.yml: +```yaml +environment: + - CONSUL_HOST=consul.service.dc1.consul + - CONSUL_PORT=8500 + - TPLINK_IP=192.168.1.100 + - TV_IP=192.168.1.101 + - TV_MAC=AA:BB:CC:DD:EE:FF +``` + +2. On first run, the app will: + - Create initial configuration in Consul using environment variables + - Store TV pairing keys in Consul when devices are paired + +### Managing Configuration +- Update configuration via API: + ```bash + POST /api/config + { + "tplink_ip": "new_ip", + "tv_ip": "new_tv_ip" + } + ``` +- Or directly through Consul UI: http://consul.service.dc1.consul:8500/ui/dc1/kv/MiniHass/ + +### Health Monitoring +The health endpoint now includes Consul connectivity status: +```bash +GET /health + +{ + "status": "healthy", + "config": {...}, + "services": { + "consul_connected": true + } +} +``` + +### Docker Deployment +- Removed local volume for config storage +- Requires network access to Consul cluster \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..28470c2 --- /dev/null +++ b/app.py @@ -0,0 +1,721 @@ +#!/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 = "

Debug Information

" + 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/') +def control_tplink(action): + """Control TP-Link switch""" + ip = config.get('tplink_ip') + if not ip: + return jsonify({'error': 'TP-Link IP not configured'}), 400 + + try: + if action == 'on': + result = TPLinkDevice.turn_on(ip) + if result and 'system' in result: + update_device_state('tplink', True) + return jsonify({'status': 'success', 'state': True}) + elif action == 'off': + result = TPLinkDevice.turn_off(ip) + if result and 'system' in result: + update_device_state('tplink', False) + return jsonify({'status': 'success', 'state': False}) + elif action == 'status': + result = TPLinkDevice.get_info(ip) + if result and 'system' in result: + state = result['system']['get_sysinfo']['relay_state'] == 1 + update_device_state('tplink', state) + return jsonify({'status': 'success', 'state': state}) + + return jsonify({'error': 'Command failed'}), 500 + + except Exception as e: + logger.error(f"TP-Link control error: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/tv/') +def control_tv(action): + """Control WebOS TV""" + ip = config.get('tv_ip') + if not ip: + return jsonify({'error': 'TV IP not configured'}), 400 + + try: + tv = WebOSTV(ip) + + async def run_command(): + if action == 'screen_on': + result = await tv.turn_screen_on() + if result: + update_device_state('tv', True) + return {'status': 'success', 'state': True} + else: + return {'error': 'Failed to turn screen on'} + elif action == 'screen_off': + result = await tv.turn_screen_off() + if result: + update_device_state('tv', False) + return {'status': 'success', 'state': False} + else: + return {'error': 'Failed to turn screen off'} + elif action == 'status': + result = await tv.get_power_state() + if result is not None: + update_device_state('tv', result) + return {'status': 'success', 'state': result} + else: + return {'error': 'Failed to get power state'} + + return {'error': 'Invalid action'} + + # Run async function + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + result = loop.run_until_complete(run_command()) + loop.close() + + if 'error' in result: + return jsonify(result), 500 + return jsonify(result) + + except Exception as e: + logger.error(f"WebOS TV control error: {e}") + return jsonify({'error': str(e)}), 500 + + +# HTML template (same as before but stored in templates/index.html) +HTML_TEMPLATE = ''' + + + + + Smart Home Controller + + + +
+

šŸ  Smart Home

+ +
+
+ šŸ’” + TP-Link Switch +
+ + +
+ +
+
+ šŸ“ŗ + LG WebOS TV Screen +
+ +
OFF
+
+ +
+

Device Configuration

+ +
+ + +
+ +
+ + +
+ + +
+ +
+ 🐳 Running in Docker Container
+ Configuration persists in mounted volume +
+
+
+ + + +''' + + +def create_template_file(): + """Create the HTML template file""" + template_dir = 'templates' + if not os.path.exists(template_dir): + os.makedirs(template_dir) + + template_path = os.path.join(template_dir, 'index.html') + with open(template_path, 'w') as f: + f.write(HTML_TEMPLATE) + + +if __name__ == '__main__': + # Configuration already loaded from Consul during initialization + + # Create template file + create_template_file() + + print("šŸ  Smart Home Controller - Docker Edition") + print("=" * 50) + print(f"šŸ“” TP-Link IP: {config['tplink_ip']}") + print(f"šŸ“ŗ TV IP: {config['tv_ip']}") + print("🌐 Web interface: http://localhost:5000") + print("ā¤ļø Health check: http://localhost:5000/health") + print("\n🐳 Container Features:") + print(" • Persistent configuration and TV credentials storage") + print(" • Health checks for monitoring") + print(" • Host network access for device discovery") + print(" • Automatic container restart") + print("\nšŸ”§ First time setup:") + print(" 1. On first TV control, accept the pairing prompt on your TV") + print(" 2. TV credentials will be saved automatically for future use") + print("\nšŸš€ Starting Flask server...") + + # Enable debug mode based on environment + debug_mode = os.environ.get('FLASK_DEBUG', '0') == '1' + + print(f"šŸš€ Starting Flask server on 0.0.0.0:5000 (debug={debug_mode})") + app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55d4b2e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3.8' + +services: + smart-home: + build: . + container_name: smart-home-controller + restart: unless-stopped + ports: + - "8989:5000" + network_mode: host # Required for local device discovery + environment: + - FLASK_ENV=development + - FLASK_DEBUG=1 + - PYTHONUNBUFFERED=1 + - TPLINK_IP=192.168.4.52 + - TV_IP=192.168.4.51 + - TV_MAC=c0:d7:aa:1d:a6:7e + - CONSUL_HOST=consul.service.dc1.consul + - CONSUL_PORT=8500 + # Configuration now stored in Consul - remove local volume + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Optional: Add a reverse proxy for HTTPS and custom domain + # nginx: + # image: nginx:alpine + # container_name: smart-home-nginx + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./nginx.conf:/etc/nginx/nginx.conf:ro + # - ./ssl:/etc/nginx/ssl:ro + # depends_on: + # - smart-home diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..ab840eb --- /dev/null +++ b/dockerfile @@ -0,0 +1,36 @@ +# Dockerfile +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application files +COPY . . + +# Create templates directory and copy template +RUN mkdir -p templates + +# Expose port +EXPOSE 5000 + +# Create non-root user for security +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /app +USER app + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/ || exit 1 + +# Run the application +CMD ["python", "app.py"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..25d28e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask==2.3.3 +websockets==12.0 +requests==2.31.0 +gunicorn==21.2.0 +aiowebostv==0.8.0 +python-consul==1.1.0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..4287fc8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,310 @@ + + + + + + + Smart Home Controller + + + +
+

Smart Home

+ +
+
+ šŸ’” + TP-Link Switch +
+ + +
+ +
+
+ šŸ“ŗ + LG WebOS TV Screen +
+ +
OFF
+
+ +
+

Device Configuration

+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + + +