Mastodon Politics, Power, and Science: Universal TV Remote Control - Architecture Guide

Sunday, January 11, 2026

Universal TV Remote Control - Architecture Guide

 J. Rogers, SE Ohio

Overview

This guide describes how to build a plugin-based universal TV remote that automatically discovers and supports new TV brands by simply adding controller files to a directory.

Architecture Principles

  1. Plugin-based: Each TV brand is a separate controller module
  2. Auto-discovery: New controllers are automatically detected and loaded
  3. Standardized interface: All controllers implement the same command set
  4. Dynamic routing: Flask routes adapt to available controllers
  5. Zero-configuration expansion: Drop in a new controller file → it just works

Directory Structure

universal_remote/
├── app.py                      # Main Flask application
├── config.json                 # TV configuration file
├── controllers/                # TV controller plugins (auto-discovered)
│   ├── __init__.py            # Controller loader
│   ├── base.py                # Base controller interface
│   ├── vizio.py               # Vizio implementation
│   ├── samsung.py             # Samsung implementation
│   ├── lg.py                  # LG implementation (future)
│   └── sony.py                # Sony implementation (future)
├── templates/
│   └── remote.html            # Web interface
└── requirements.txt

Step 1: Base Controller Interface

File: controllers/base.py

This defines the contract that ALL controllers must implement.

from abc import ABC, abstractmethod

class TVController(ABC):
    """
    Base class for all TV controllers.
    All TV brands must implement these methods.
    """
    
    def __init__(self, config):
        """
        Initialize controller with TV configuration
        
        Args:
            config (dict): TV configuration containing:
                - ip: TV IP address
                - mac: MAC address (optional, for WoL)
                - brand-specific keys (auth_token, api_key, etc.)
        """
        self.config = config
        self.ip = config['ip']
        self.mac = config.get('mac')
    
    # ============ POWER COMMANDS ============
    @abstractmethod
    def power_on(self):
        """Turn TV on. Returns True if successful."""
        pass
    
    @abstractmethod
    def power_off(self):
        """Turn TV off. Returns True if successful."""
        pass
    
    @abstractmethod
    def power_toggle(self):
        """Toggle power state. Returns True if successful."""
        pass
    
    # ============ VOLUME COMMANDS ============
    @abstractmethod
    def volume_up(self):
        """Increase volume. Returns True if successful."""
        pass
    
    @abstractmethod
    def volume_down(self):
        """Decrease volume. Returns True if successful."""
        pass
    
    @abstractmethod
    def mute(self):
        """Toggle mute. Returns True if successful."""
        pass
    
    @abstractmethod
    def set_volume(self, level):
        """
        Set volume to specific level (0-100)
        Returns True if successful.
        """
        pass
    
    # ============ CHANNEL COMMANDS ============
    @abstractmethod
    def channel_up(self):
        """Next channel. Returns True if successful."""
        pass
    
    @abstractmethod
    def channel_down(self):
        """Previous channel. Returns True if successful."""
        pass
    
    # ============ NAVIGATION COMMANDS ============
    @abstractmethod
    def key_up(self):
        """Navigate up. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_down(self):
        """Navigate down. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_left(self):
        """Navigate left. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_right(self):
        """Navigate right. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_ok(self):
        """OK/Select button. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_back(self):
        """Back button. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_home(self):
        """Home button. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_menu(self):
        """Menu button. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_exit(self):
        """Exit button. Returns True if successful."""
        pass
    
    @abstractmethod
    def key_info(self):
        """Info button. Returns True if successful."""
        pass
    
    # ============ INPUT/APP COMMANDS ============
    @abstractmethod
    def get_inputs_list(self):
        """
        Get list of available inputs.
        Returns list of dicts: [{"name": "HDMI1", "id": "hdmi1"}, ...]
        """
        pass
    
    @abstractmethod
    def set_input(self, input_name):
        """
        Switch to input by name.
        Returns True if successful.
        """
        pass
    
    @abstractmethod
    def get_apps_list(self):
        """
        Get list of available apps.
        Returns list of app names: ["Netflix", "YouTube", ...]
        """
        pass
    
    @abstractmethod
    def launch_app(self, app_name):
        """
        Launch app by name.
        Returns True if successful.
        """
        pass
    
    # ============ OPTIONAL: BRAND-SPECIFIC COMMANDS ============
    def get_capabilities(self):
        """
        Return dict of supported features.
        Override to indicate unsupported features.
        """
        return {
            "power": True,
            "volume": True,
            "channels": True,
            "navigation": True,
            "inputs": True,
            "apps": True,
        }

Step 2: Auto-Discovery Controller Loader

File: controllers/__init__.py

This automatically finds and loads all controller modules.

import os
import importlib
import inspect
from .base import TVController

def discover_controllers():
    """
    Automatically discover all TV controller classes in this directory.
    Returns dict: {"vizio": VizioController, "samsung": SamsungController, ...}
    """
    controllers = {}
    
    # Get directory of this file
    controllers_dir = os.path.dirname(__file__)
    
    # Find all .py files except __init__ and base
    for filename in os.listdir(controllers_dir):
        if not filename.endswith('.py'):
            continue
        if filename in ['__init__.py', 'base.py']:
            continue
        
        # Module name is filename without .py
        module_name = filename[:-3]
        
        try:
            # Import the module
            module = importlib.import_module(f'.{module_name}', package='controllers')
            
            # Find all classes in the module that inherit from TVController
            for name, obj in inspect.getmembers(module, inspect.isclass):
                if issubclass(obj, TVController) and obj is not TVController:
                    # Use module name as brand identifier
                    controllers[module_name] = obj
                    print(f"✓ Loaded controller: {module_name} ({name})")
                    
        except Exception as e:
            print(f"✗ Failed to load controller {module_name}: {e}")
    
    return controllers

# Auto-load all controllers when this module is imported
CONTROLLERS = discover_controllers()

def get_controller(brand, config):
    """
    Get controller instance for a specific TV brand.
    
    Args:
        brand (str): TV brand name (e.g., "vizio", "samsung")
        config (dict): TV configuration
    
    Returns:
        TVController instance
    
    Raises:
        ValueError if brand not supported
    """
    if brand not in CONTROLLERS:
        available = ', '.join(CONTROLLERS.keys())
        raise ValueError(f"Unsupported brand '{brand}'. Available: {available}")
    
    controller_class = CONTROLLERS[brand]
    return controller_class(config)

def get_supported_brands():
    """Return list of supported TV brands."""
    return list(CONTROLLERS.keys())

Step 3: Implement Vizio Controller

File: controllers/vizio.py

from .base import TVController
from vizio_control import VizioTV

class VizioController(TVController):
    """Vizio TV implementation using SmartCast API"""
    
    def __init__(self, config):
        super().__init__(config)
        
        if 'auth_token' not in config:
            raise ValueError("Vizio requires 'auth_token' in config")
        
        self.tv = VizioTV(
            config['ip'],
            config['auth_token'],
            config.get('mac')
        )
    
    def power_on(self):
        return self.tv.power_on()
    
    def power_off(self):
        return self.tv.power_off()
    
    def power_toggle(self):
        return self.tv.power_toggle()
    
    def volume_up(self):
        return self.tv.volume_up()
    
    def volume_down(self):
        return self.tv.volume_down()
    
    def mute(self):
        return self.tv.mute()
    
    def set_volume(self, level):
        # Vizio doesn't support direct volume set
        return False
    
    def channel_up(self):
        return self.tv.channel_up()
    
    def channel_down(self):
        return self.tv.channel_down()
    
    def key_up(self):
        return self.tv.key_up()
    
    def key_down(self):
        return self.tv.key_down()
    
    def key_left(self):
        return self.tv.key_left()
    
    def key_right(self):
        return self.tv.key_right()
    
    def key_ok(self):
        return self.tv.key_ok()
    
    def key_back(self):
        return self.tv.key_back()
    
    def key_home(self):
        return self.tv.key_home()
    
    def key_menu(self):
        return self.tv.key_menu()
    
    def key_exit(self):
        return self.tv.key_exit()
    
    def key_info(self):
        return self.tv.key_info()
    
    def get_inputs_list(self):
        inputs = self.tv.get_inputs_list()
        if not inputs:
            return []
        
        return [
            {
                "name": inp.get('CNAME', ''),
                "id": inp.get('CNAME', '').lower()
            }
            for inp in inputs
        ]
    
    def set_input(self, input_name):
        return self.tv.set_input(input_name)
    
    def get_apps_list(self):
        return self.tv.list_available_apps()
    
    def launch_app(self, app_name):
        return self.tv.launch_app(app_name)
    
    def get_capabilities(self):
        return {
            "power": True,
            "volume": True,
            "channels": True,
            "navigation": True,
            "inputs": True,
            "apps": True,
            "volume_set": False,  # Vizio doesn't support direct volume
        }

Step 4: Implement Samsung Controller

File: controllers/samsung.py

Samsung TVs use two different protocols depending on age:

Option A: Legacy Samsung (2014-2015) - Port 55000

from .base import TVController
import socket
import base64
import time

class SamsungController(TVController):
    """Samsung TV implementation (Legacy protocol, pre-2016)"""
    
    # Samsung key codes
    KEYS = {
        'power': 'KEY_POWER',
        'vol_up': 'KEY_VOLUP',
        'vol_down': 'KEY_VOLDOWN',
        'mute': 'KEY_MUTE',
        'ch_up': 'KEY_CHUP',
        'ch_down': 'KEY_CHDOWN',
        'up': 'KEY_UP',
        'down': 'KEY_DOWN',
        'left': 'KEY_LEFT',
        'right': 'KEY_RIGHT',
        'ok': 'KEY_ENTER',
        'back': 'KEY_RETURN',
        'home': 'KEY_HOME',
        'menu': 'KEY_MENU',
        'exit': 'KEY_EXIT',
        'info': 'KEY_INFO',
    }
    
    def __init__(self, config):
        super().__init__(config)
        self.port = config.get('port', 55000)
        self.remote_name = config.get('remote_name', 'PythonRemote')
    
    def _send_key(self, key):
        """Send key command to Samsung TV"""
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(3)
            sock.connect((self.ip, self.port))
            
            # Encode remote name
            remote_b64 = base64.b64encode(self.remote_name.encode()).decode()
            
            # Authentication message
            auth_msg = (
                f"\x00\x13\x00{chr(len(remote_b64))}\x00{remote_b64}"
                f"\x10\x00\x07\x00localhost"
            )
            sock.send(auth_msg.encode('latin1'))
            
            # Key command message
            key_b64 = base64.b64encode(key.encode()).decode()
            key_msg = (
                f"\x00\x13\x00{chr(len(remote_b64))}\x00{remote_b64}"
                f"\x00\x00\x00{chr(len(key_b64))}\x00{key_b64}"
            )
            sock.send(key_msg.encode('latin1'))
            
            sock.close()
            return True
            
        except Exception as e:
            print(f"Samsung key send failed: {e}")
            return False
    
    def power_on(self):
        # Samsung legacy doesn't support network power on
        # Use Wake-on-LAN if MAC is available
        if self.mac:
            return self._send_wol()
        return False
    
    def power_off(self):
        return self._send_key(self.KEYS['power'])
    
    def power_toggle(self):
        return self._send_key(self.KEYS['power'])
    
    def volume_up(self):
        return self._send_key(self.KEYS['vol_up'])
    
    def volume_down(self):
        return self._send_key(self.KEYS['vol_down'])
    
    def mute(self):
        return self._send_key(self.KEYS['mute'])
    
    def set_volume(self, level):
        # Not supported on legacy Samsung
        return False
    
    def channel_up(self):
        return self._send_key(self.KEYS['ch_up'])
    
    def channel_down(self):
        return self._send_key(self.KEYS['ch_down'])
    
    def key_up(self):
        return self._send_key(self.KEYS['up'])
    
    def key_down(self):
        return self._send_key(self.KEYS['down'])
    
    def key_left(self):
        return self._send_key(self.KEYS['left'])
    
    def key_right(self):
        return self._send_key(self.KEYS['right'])
    
    def key_ok(self):
        return self._send_key(self.KEYS['ok'])
    
    def key_back(self):
        return self._send_key(self.KEYS['back'])
    
    def key_home(self):
        return self._send_key(self.KEYS['home'])
    
    def key_menu(self):
        return self._send_key(self.KEYS['menu'])
    
    def key_exit(self):
        return self._send_key(self.KEYS['exit'])
    
    def key_info(self):
        return self._send_key(self.KEYS['info'])
    
    def get_inputs_list(self):
        # Legacy Samsung doesn't provide input list via API
        return [
            {"name": "HDMI1", "id": "hdmi1"},
            {"name": "HDMI2", "id": "hdmi2"},
            {"name": "HDMI3", "id": "hdmi3"},
        ]
    
    def set_input(self, input_name):
        # Send HDMI key codes
        input_map = {
            'hdmi1': 'KEY_HDMI1',
            'hdmi2': 'KEY_HDMI2',
            'hdmi3': 'KEY_HDMI3',
        }
        key = input_map.get(input_name.lower())
        if key:
            return self._send_key(key)
        return False
    
    def get_apps_list(self):
        # Legacy Samsung doesn't support app listing
        return []
    
    def launch_app(self, app_name):
        # Not supported
        return False
    
    def _send_wol(self):
        """Send Wake-on-LAN magic packet"""
        if not self.mac:
            return False
        
        try:
            mac = self.mac.replace(':', '').replace('-', '')
            data = 'FF' * 6 + mac * 16
            packet = bytes.fromhex(data)
            
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
            sock.sendto(packet, ('255.255.255.255', 9))
            sock.close()
            
            time.sleep(2)  # Wait for TV to wake
            return True
        except:
            return False
    
    def get_capabilities(self):
        return {
            "power": True,
            "volume": True,
            "channels": True,
            "navigation": True,
            "inputs": True,
            "apps": False,
            "volume_set": False,
        }

Option B: Modern Samsung (2016+) - WebSocket API

For modern Samsung TVs, you would use the samsungtvws library:

# pip install samsungtvws

from .base import TVController
from samsungtvws import SamsungTVWS

class SamsungController(TVController):
    """Samsung TV implementation (2016+ WebSocket API)"""
    
    def __init__(self, config):
        super().__init__(config)
        
        # Modern Samsung uses WebSocket on port 8002
        self.tv = SamsungTVWS(
            host=self.ip,
            port=config.get('port', 8002),
            token_file=config.get('token_file', '/tmp/samsung_token.txt'),
            name=config.get('remote_name', 'PythonRemote')
        )
    
    def power_on(self):
        # Modern Samsung supports WoL
        if self.mac:
            return self._send_wol()
        return False
    
    def power_off(self):
        self.tv.send_key('KEY_POWER')
        return True
    
    def power_toggle(self):
        self.tv.send_key('KEY_POWER')
        return True
    
    def volume_up(self):
        self.tv.send_key('KEY_VOLUP')
        return True
    
    # ... implement all other methods similarly
    
    def get_apps_list(self):
        try:
            apps = self.tv.app_list()
            return [app['name'] for app in apps]
        except:
            return []
    
    def launch_app(self, app_name):
        try:
            apps = self.tv.app_list()
            for app in apps:
                if app['name'].lower() == app_name.lower():
                    self.tv.run_app(app['appId'])
                    return True
        except:
            pass
        return False

Step 5: Configuration File

File: config.json

{
  "tvs": [
    {
      "id": "living_room",
      "name": "Living Room TV",
      "brand": "vizio",
      "ip": "192.168.1.100",
      "auth_token": "Zjkxxxxxxx",
      "mac": "e8:38:a0:26:74:51"
    },
    {
      "id": "bedroom",
      "name": "Bedroom TV",
      "brand": "samsung",
      "ip": "192.168.1.101",
      "mac": "aa:bb:cc:dd:ee:ff",
      "port": 55000,
      "remote_name": "MyRemote"
    },
    {
      "id": "basement",
      "name": "Basement TV",
      "brand": "lg",
      "ip": "192.168.1.102",
      "mac": "11:22:33:44:55:66"
    }
  ]
}

Step 6: Flask Application with Auto-Discovery

File: app.py

from flask import Flask, render_template, jsonify, request
import json
from controllers import get_controller, get_supported_brands

app = Flask(__name__)

# Load configuration
def load_config():
    with open('config.json', 'r') as f:
        return json.load(f)

config = load_config()
tvs = {tv['id']: tv for tv in config['tvs']}

# Create controller instances
controllers = {}
for tv_id, tv_config in tvs.items():
    try:
        controllers[tv_id] = get_controller(tv_config['brand'], tv_config)
        print(f"✓ Initialized {tv_config['name']} ({tv_config['brand']})")
    except Exception as e:
        print(f"✗ Failed to initialize {tv_id}: {e}")

@app.route('/')
def index():
    return render_template('remote.html', tvs=tvs)

@app.route('/api/tvs', methods=['GET'])
def get_tvs():
    """List all configured TVs"""
    tv_list = [
        {
            "id": tv_id,
            "name": tv_config['name'],
            "brand": tv_config['brand']
        }
        for tv_id, tv_config in tvs.items()
    ]
    return jsonify({"success": True, "tvs": tv_list})

@app.route('/api/brands', methods=['GET'])
def get_brands():
    """List all supported TV brands"""
    return jsonify({
        "success": True,
        "brands": get_supported_brands()
    })

@app.route('/api/<tv_id>/command/<command>', methods=['POST'])
def execute_command(tv_id, command):
    """Execute a command on a specific TV"""
    if tv_id not in controllers:
        return jsonify({"success": False, "message": f"TV '{tv_id}' not found"})
    
    controller = controllers[tv_id]
    
    try:
        # Map command to controller method
        command_map = {
            'toggle': controller.power_toggle,
            'on': controller.power_on,
            'off': controller.power_off,
            'vol_up': controller.volume_up,
            'vol_down': controller.volume_down,
            'mute': controller.mute,
            'ch_up': controller.channel_up,
            'ch_down': controller.channel_down,
            'up': controller.key_up,
            'down': controller.key_down,
            'left': controller.key_left,
            'right': controller.key_right,
            'ok': controller.key_ok,
            'back': controller.key_back,
            'exit': controller.key_exit,
            'menu': controller.key_menu,
            'home': controller.key_home,
            'info': controller.key_info,
        }
        
        if command not in command_map:
            return jsonify({"success": False, "message": f"Unknown command: {command}"})
        
        success = command_map[command]()
        
        if success:
            return jsonify({"success": True, "message": f"{command.upper()}"})
        else:
            return jsonify({"success": False, "message": f"Failed: {command}"})
            
    except Exception as e:
        return jsonify({"success": False, "message": str(e)})

@app.route('/api/<tv_id>/inputs', methods=['GET'])
def get_inputs(tv_id):
    """Get available inputs for a TV"""
    if tv_id not in controllers:
        return jsonify({"success": False, "message": f"TV '{tv_id}' not found"})
    
    try:
        inputs = controllers[tv_id].get_inputs_list()
        return jsonify({"success": True, "inputs": inputs})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)})

@app.route('/api/<tv_id>/apps', methods=['GET'])
def get_apps(tv_id):
    """Get available apps for a TV"""
    if tv_id not in controllers:
        return jsonify({"success": False, "message": f"TV '{tv_id}' not found"})
    
    try:
        apps = controllers[tv_id].get_apps_list()
        return jsonify({"success": True, "apps": apps})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)})

@app.route('/api/<tv_id>/input/<input_name>', methods=['POST'])
def set_input(tv_id, input_name):
    """Set input on a TV"""
    if tv_id not in controllers:
        return jsonify({"success": False, "message": f"TV '{tv_id}' not found"})
    
    try:
        success = controllers[tv_id].set_input(input_name)
        if success:
            return jsonify({"success": True, "message": f"Input: {input_name}"})
        return jsonify({"success": False, "message": "Failed to change input"})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)})

@app.route('/api/<tv_id>/app/<app_name>', methods=['POST'])
def launch_app(tv_id, app_name):
    """Launch app on a TV"""
    if tv_id not in controllers:
        return jsonify({"success": False, "message": f"TV '{tv_id}' not found"})
    
    try:
        success = controllers[tv_id].launch_app(app_name)
        if success:
            return jsonify({"success": True, "message": f"Launched: {app_name}"})
        return jsonify({"success": False, "message": f"Failed to launch {app_name}"})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)})

@app.route('/api/<tv_id>/capabilities', methods=['GET'])
def get_capabilities(tv_id):
    """Get capabilities of a specific TV"""
    if tv_id not in controllers:
        return jsonify({"success": False, "message": f"TV '{tv_id}' not found"})
    
    try:
        caps = controllers[tv_id].get_capabilities()
        return jsonify({"success": True, "capabilities": caps})
    except Exception as e:
        return jsonify({"success": False, "message": str(e)})

if __name__ == '__main__':
    import warnings
    warnings.filterwarnings('ignore', message='Unverified HTTPS request')
    
    print(f"\n🖥️  Universal TV Remote Control Server")
    print(f"📺 Configured TVs:")
    for tv_id, tv_config in tvs.items():
        status = "✓" if tv_id in controllers else "✗"
        print(f"   {status} {tv_config['name']} ({tv_config['brand']}) - {tv_config['ip']}")
    print(f"\n🎯 Supported brands: {', '.join(get_supported_brands())}")
    print(f"\n🌐 Access from any device:")
    print(f"   http://localhost:5000")
    print(f"   http://<your-ip>:5000")
    print(f"\n Press Ctrl+C to stop\n")
    
    app.run(host='0.0.0.0', port=5000, debug=False)

Step 7: Update HTML for Multi-TV Support

Add TV selector to the remote interface:

<!-- Add this after the <h1> tag -->
<div class="tv-selector">
    <label for="tv-select">Select TV:</label>
    <select id="tv-select" onchange="selectTV()">
        <!-- Populated by JavaScript -->
    </select>
</div>

<script>
let currentTV = null;

// Load TV list on page load
async function loadTVs() {
    try {
        const response = await fetch('/api/tvs');
        const data = await response.json();
        
        if (data.success) {
            const select = document.getElementById('tv-select');
            data.tvs.forEach(tv => {
                const option = document.createElement('option');
                option.value = tv.id;
                option.textContent = `${tv.name} (${tv.brand})`;
                select.appendChild(option);
            });
            
            // Select first TV by default
            if (data.tvs.length > 0) {
                currentTV = data.tvs[0].id;
            }
        }
    } catch (error) {
        showStatus('Error loading TVs: ' + error.message, 'error');
    }
}

function selectTV() {
    currentTV = document.getElementById('tv-select').value;
    showStatus(`Controlling ${currentTV}`, 'info');
}

// Update sendCommand to use currentTV
async function sendCommand(command) {
    if (!currentTV) {
        showStatus('Please select a TV', 'error');
        return;
    }
    
    try {
        const response = await fetch(`/api/${currentTV}/command/${command}`, {
            method: 'POST'
        });
        const data = await response.json();
        
        if (data.success) {
            showStatus('✓ ' + data.message, 'success');
        } else {
            showStatus('✗ ' + data.message, 'error');
        }
    } catch (error) {
        showStatus('Error: ' + error.message, 'error');
    }
}

// Load TVs when page loads
document.addEventListener('DOMContentLoaded', loadTVs);
</script>

Adding New TV Brands - Step by Step

Example: Adding LG WebOS Support

1. Install library:

pip install pylgtv

2. Create controller file: controllers/lg.py

from .base import TVController
from pylgtv import WebOsClient

class LGController(TVController):
    """LG WebOS TV implementation"""
    
    def __init__(self, config):
        super().__init__(config)
        
        store = config.get('token_file', '/tmp/lg_token.txt')
        self.client = WebOsClient(self.ip, key_file_path=store)
        self.client.connect()
    
    def power_on(self):
        # LG supports WoL
        if self.mac:
            return self._send_wol()
        return False
    
    def power_off(self):
        self.client.power_off()
        return True
    
    def power_toggle(self):
        # Check current state and toggle
        return self.power_off()
    
    def volume_up(self):
        self.client.volume_up()
        return True
    
    def volume_down(self):
        self.client.volume_down()
        return True
    
    def mute(self):
        self.client.set_mute(True)
        return True
    
    def set_volume(self, level):
        self.client.set_volume(level)
        return True
    
    # ... implement remaining methods ...
    
    def get_apps_list(self):
        apps = self.client.get_apps()
        return [app['title'] for app in apps]
    
    def launch_app(self, app_name):
        apps = self.client.get_apps()
        for app in apps:
            if app['title'].lower() == app_name.lower():
                self.client.launch_app(app['id'])
                return True
        return False
    
    def _send_wol(self):
        """Standard Wake-on-LAN implementation"""
        # Same as other controllers
        pass

3. Done! The controller is automatically discovered and available.

4. Add to config.json:

{
  "id": "basement",
  "name": "Basement TV",
  "brand": "lg",
  "ip": "192.168.1.102",
  "mac": "11:22:33:44:55:66",
  "token_file": "/tmp/lg_basement_token.txt"
}

5. Restart server - LG support is now live.


Testing New Controllers

File: test_controller.py

#!/usr/bin/env python3
"""Test a TV controller implementation"""
import sys
import json
from controllers import get_controller

def test_controller(brand, ip):
    """Test basic functionality of a controller"""
    
    print(f"\n=== Testing {brand.upper()} Controller ===\n")
    
    config = {
        "ip": ip,
        "brand": brand,
        # Add any brand-specific config here
    }
    
    try:
        # Create controller
        print(f"Creating {brand} controller...")
        controller = get_controller(brand, config)
        print("✓ Controller created")
        
        # Test capabilities
        print("\nCapabilities:")
        caps = controller.get_capabilities()
        for feature, supported in caps.items():
            status = "✓" if supported else "✗"
            print(f"  {status} {feature}")
        
        # Test basic commands
        print("\nTesting commands:")
        
        tests = [
            ("Volume Up", controller.volume_up),
            ("Volume Down", controller.volume_down),
            ("Channel Up", controller.channel_up),
            ("Get Inputs", controller.get_inputs_list),
            ("Get Apps", controller.get_apps_list),
        ]
        
        for name, func in tests:
            try:
                result = func()
                print(f"  ✓ {name}: {result}")
            except Exception as e:
                print(f"  ✗ {name}: {e}")
        
        print("\n=== Test Complete ===\n")
        
    except Exception as e:
        print(f"✗ Failed: {e}")
        sys.exit(1)

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: python test_controller.py <brand> <ip>")
        print("Example: python test_controller.py samsung 192.168.1.101")
        sys.exit(1)
    
    brand = sys.argv[1]
    ip = sys.argv[2]
    test_controller(brand, ip)

Common TV Brand Resources

Samsung

  • Legacy (pre-2016): Port 55000, custom protocol
  • Modern (2016+): Port 8002, WebSocket
  • Library: samsungtvws (modern), custom implementation (legacy)
  • Docs: https://github.com/jaruba/samsung-remote

LG WebOS

  • Port: 3000 (WebSocket)
  • Library: pylgtv
  • Docs: https://github.com/TheRealLink/pylgtv

Sony Bravia

  • Protocol: REST API + IRCC
  • Library: pybravia
  • Docs: https://github.com/aparraga/braviaproapi

Roku

  • Protocol: HTTP REST API (simple!)
  • Port: 8060
  • Library: roku or custom implementation
  • Docs: https://developer.roku.com/docs/developer-program/debugging/external-control-api.md

Philips (Android TV)

  • Protocol: JointSpace API
  • Port: 1926
  • Library: Custom implementation
  • Docs: https://github.com/eslavnov/pylips

Deployment Checklist

  • [ ] All TVs configured in config.json
  • [ ] All required libraries installed (pip install -r requirements.txt)
  • [ ] Controllers tested individually with test_controller.py
  • [ ] Flask app runs without errors
  • [ ] Web interface accessible from all devices
  • [ ] Multi-TV selector works correctly
  • [ ] All buttons functional for each TV brand
  • [ ] Status messages display correctly

Benefits of This Architecture

  1. Zero-configuration expansion: Drop a new .py file in controllers/ and it's automatically available
  2. Standardized interface: All TVs work the same way from the app's perspective
  3. Easy testing: Test each controller independently
  4. Graceful degradation: If one TV controller fails, others still work
  5. Clear separation: Brand-specific code isolated in individual files
  6. Easy debugging: Each brand's implementation is self-contained
  7. Community contributions: Others can add brands without touching core code

Future Enhancements

  • Auto-discovery: Scan network for TVs and auto-configure
  • Authentication helpers: Wizard for pairing with each brand
  • Macros: Define sequences of commands ("Movie Mode" = dim lights + switch input + adjust volume)
  • Scheduling: Time-based automation
  • MCP integration: Let AI control TVs
  • Mobile app: Native iOS/Android instead of web interface
  • Voice control: Integration with speech recognition

No comments:

Post a Comment

The Architecture of Unity: Why Physics is the Language of Measurement

 J. Rogers, SE Ohio 1. The Ultimate Starting Point: 1               Unity X/X = 1    Pick one point on unity  X = X In the study of theoreti...