commit 122358286d2bd8b7817751c6ccfe1c4d15dfb9c7 Author: Keith Solomon Date: Sat Jun 6 10:23:49 2026 -0500 Initial proof-of-concept commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f16f4ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vscode/ +.venv/ +data/* +thermopro-cli/ +notes/ diff --git a/bridge_poc.py b/bridge_poc.py new file mode 100644 index 0000000..1776259 --- /dev/null +++ b/bridge_poc.py @@ -0,0 +1,201 @@ +import argparse +import json +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from urllib import request, error + + +def getUtcTimestamp(): + return datetime.now(timezone.utc).isoformat() + + +def extractJson(output): + start = output.find("{") + end = output.rfind("}") + + if start == -1 or end == -1 or end <= start: + raise ValueError(f"No JSON object found in CLI output:\n{output}") + + return json.loads(output[start:end + 1]) + + +def runThermoproCli(cliPath): + command = [ + sys.executable, + str(cliPath), + "temps", + "--json", + ] + + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + + combinedOutput = "\n".join( + part for part in [result.stdout, result.stderr] if part.strip() + ) + + if result.returncode != 0: + raise RuntimeError( + f"thermopro_cli.py exited with code {result.returncode}:\n{combinedOutput}" + ) + + return extractJson(combinedOutput) + + +def normalizeReading(rawReading, deviceId, macAddress): + temperatures = rawReading.get("temperatures", []) + probeNames = rawReading.get("probe_names", []) + probeCount = rawReading.get("probe_count", len(temperatures)) + + probes = [] + + for index in range(probeCount): + rawTemperature = temperatures[index] if index < len(temperatures) else -999.0 + name = probeNames[index] if index < len(probeNames) else f"Probe {index + 1}" + isConnected = rawTemperature != -999.0 + + probes.append({ + "id": index + 1, + "name": name, + "temperature": rawTemperature if isConnected else None, + "connected": isConnected, + }) + + return { + "deviceId": deviceId, + "device": "ThermoPro TP930", + "mac": macAddress, + "connected": bool(rawReading.get("connected", False)), + "battery": rawReading.get("battery"), + "unit": rawReading.get("unit"), + "probeCount": probeCount, + "probes": probes, + "readingTime": rawReading.get("last_update"), + "bridgeTime": getUtcTimestamp(), + "source": "thermopro_cli", + } + + +def postReading(endpoint, token, payload): + body = json.dumps(payload).encode("utf-8") + + headers = { + "Content-Type": "application/json", + "User-Agent": "thermopro-bridge-poc/0.1", + } + + if token: + headers["X-Bridge-Token"] = token + + req = request.Request( + endpoint, + data=body, + headers=headers, + method="POST", + ) + + try: + with request.urlopen(req, timeout=10) as response: + responseBody = response.read().decode("utf-8") + return response.status, responseBody + except error.HTTPError as e: + responseBody = e.read().decode("utf-8") + raise RuntimeError(f"POST failed with HTTP {e.code}: {responseBody}") from e + except error.URLError as e: + raise RuntimeError(f"POST failed: {e}") from e + + +def main(): + parser = argparse.ArgumentParser( + description="ThermoPro TP930 proof-of-concept bridge" + ) + + parser.add_argument( + "--cli", + default="thermopro_cli.py", + help="Path to thermopro_cli.py", + ) + + parser.add_argument( + "--endpoint", + default="http://127.0.0.1:8000/api/thermopro/readings", + help="API endpoint to POST readings to", + ) + + parser.add_argument( + "--token", + default="dev-secret", + help="Shared token sent as X-Bridge-Token", + ) + + parser.add_argument( + "--device-id", + default="tp930-smoker", + help="Stable ID for this thermometer/bridge", + ) + + parser.add_argument( + "--mac", + default="C9:48:1D:B1:E1:E7", + help="ThermoPro BLE MAC address", + ) + + parser.add_argument( + "--interval", + type=int, + default=15, + help="Polling interval in seconds", + ) + + parser.add_argument( + "--once", + action="store_true", + help="Read and POST once, then exit", + ) + + args = parser.parse_args() + cliPath = Path(args.cli) + + if not cliPath.exists(): + print(f"Could not find {cliPath}", file=sys.stderr) + sys.exit(1) + + while True: + try: + rawReading = runThermoproCli(cliPath) + payload = normalizeReading(rawReading, args.device_id, args.mac) + status, responseBody = postReading(args.endpoint, args.token, payload) + + connectedProbes = [ + probe for probe in payload["probes"] if probe["connected"] + ] + + print( + f"[{payload['bridgeTime']}] POST {status} - " + f"battery={payload['battery']}%, " + f"connected_probes={len(connectedProbes)}" + ) + + print(json.dumps(payload, indent=2)) + + if responseBody.strip(): + print(responseBody) + + except Exception as e: + print(f"[{getUtcTimestamp()}] ERROR: {e}", file=sys.stderr) + + if args.once: + break + + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/phpcs-results.txt b/phpcs-results.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/phpcs-results.txt @@ -0,0 +1 @@ + diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..2d98d16 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,29 @@ + + + Non-WP Coding Style Checks + + + + + + + + vendor/ + node_modules/ + + + 0 + + + + + + + + + + + + 0 + + diff --git a/receiver.php b/receiver.php new file mode 100644 index 0000000..904e5fd --- /dev/null +++ b/receiver.php @@ -0,0 +1,193 @@ + false, + 'error' => 'Invalid JSON body', + ), + 400 + ); + } + + return $payload; +} + +function requireBridgeToken( string $expectedToken ): void { + $providedToken = $_SERVER['HTTP_X_BRIDGE_TOKEN'] ?? ''; + + if ( ! hash_equals( $expectedToken, $providedToken ) ) { + sendJson( + array( + 'ok' => false, + 'error' => 'Invalid bridge token', + ), + 401 + ); + } +} + +function validateReading( array $payload ): void { + $requiredFields = array( + 'deviceId', + 'connected', + 'battery', + 'unit', + 'probes', + 'readingTime', + ); + + foreach ( $requiredFields as $field ) { + if ( ! array_key_exists( $field, $payload ) ) { + sendJson( + array( + 'ok' => false, + 'error' => 'Missing required field: ' . $field, + ), + 422 + ); + } + } + + if ( ! is_array( $payload['probes'] ) ) { + sendJson( + array( + 'ok' => false, + 'error' => 'The probes field must be an array', + ), + 422 + ); + } +} + +$path = getRequestPath(); +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +if ( $method === 'POST' && $path === '/api/thermopro/readings' ) { + requireBridgeToken( $token ); + + $payload = readJsonBody(); + validateReading( $payload ); + + $payload['serverReceivedAt'] = gmdate( DATE_ATOM ); + + file_put_contents( + $latestPath, + json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) + ); + + file_put_contents( + $logPath, + json_encode( $payload, JSON_UNESCAPED_SLASHES ) . PHP_EOL, + FILE_APPEND | LOCK_EX + ); + + sendJson( + array( + 'ok' => true, + 'receivedAt' => $payload['serverReceivedAt'], + ) + ); +} + +if ( $method === 'GET' && $path === '/api/thermopro/latest' ) { + if ( ! file_exists( $latestPath ) ) { + sendJson( + array( + 'ok' => false, + 'error' => 'No readings received yet', + ), + 404 + ); + } + + header( 'Content-Type: application/json' ); + readfile( $latestPath ); + exit; +} + +if ( $method === 'GET' && ( $path === '/' || $path === '/status' ) ) { + $latest = null; + + if ( file_exists( $latestPath ) ) { + $latest = json_decode( file_get_contents( $latestPath ), true ); + } + + header( 'Content-Type: text/html; charset=utf-8' ); + ?> + + + + + ThermoPro Bridge POC + + + + +

ThermoPro Bridge POC

+ + +

No readings received yet.

+ +

+ Latest reading received at: + +

+ +
+ + + + false, + 'error' => 'Not found', + ), + 404 +); diff --git a/thermopro_cli.py b/thermopro_cli.py new file mode 100644 index 0000000..9d56100 --- /dev/null +++ b/thermopro_cli.py @@ -0,0 +1,991 @@ +#!/usr/bin/env python3 +""" +ThermoPro Bluetooth Thermometer CLI +Reverse-engineered protocol implementation for Linux + +Usage: + thermopro_cli.py [--debug] scan + thermopro_cli.py [--debug] connect [--addr ADDRESS] [--probe-names NAMES] + thermopro_cli.py [--debug] temps [--addr ADDRESS] [--json] [--unit F|C] [--probe-names NAMES] + thermopro_cli.py [--debug] monitor [--addr ADDRESS] [--interval 1] [--json] [--unit F|C] [--probe-names NAMES] + thermopro_cli.py [--debug] mqtt --addr ADDRESS [--interval 5] [--unit F|C] [--broker HOST] [--probe-names NAMES] + + If --addr is omitted (except for mqtt), the tool auto-scans for ThermoPro + devices and prompts for selection if multiple are found. + + --probe-names accepts comma-separated labels (e.g. 'Brisket,Ambient,,Ribs'). + Empty slots keep the default 'Probe N' naming. + +Environment variables for MQTT (also read from .env file in script directory): + MQTT_BROKER - MQTT broker address + MQTT_PORT - MQTT broker port (default: 1883) + MQTT_USERNAME - MQTT username + MQTT_PASSWORD - MQTT password +""" + +import asyncio +import argparse +import json +import logging +import re +import signal +import sys +import time +import os +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, List + +try: + from bleak import BleakClient, BleakScanner +except ImportError: + print("Error: bleak library not installed. Run: pip install bleak") + sys.exit(1) + +try: + import paho.mqtt.client as mqtt + + try: + from paho.mqtt.enums import CallbackAPIVersion + + MQTT_V2 = True + except ImportError: + MQTT_V2 = False + + MQTT_AVAILABLE = True +except ImportError: + MQTT_AVAILABLE = False + +# BLE UUIDs +SERVICE_UUID = "1086FFF0-3343-4817-8BB2-B32206336CE8" +NOTIFY_UUID = "1086FFF2-3343-4817-8BB2-B32206336CE8" +WRITE_UUID = "1086FFF1-3343-4817-8BB2-B32206336CE8" + +# Sentinel values from decode_temperature() that are not real readings +TEMP_SENTINELS = {-999.0, -100.0, 666.0} +MAX_PROBES = 4 + +log = logging.getLogger("thermopro") + + +def is_valid_temp(t: float) -> bool: + return t not in TEMP_SENTINELS + + +def parse_probe_names( + raw: Optional[str], max_probes: int = MAX_PROBES +) -> List[Optional[str]]: + """Parse comma-separated probe names. Empty slots become None.""" + if not raw: + return [None] * max_probes + parts = raw.split(",") + if len(parts) > max_probes: + print( + f"Warning: {len(parts)} names provided but max is {max_probes}, " + f"ignoring extras", + file=sys.stderr, + ) + parts = parts[:max_probes] + names = [(p.strip() or None) for p in parts] + names.extend([None] * (max_probes - len(names))) + return names + + +def get_probe_label( + probe_names: List[Optional[str]], index: int, compact: bool = False +) -> str: + """Return display label for a probe — custom name or default.""" + name = probe_names[index] if index < len(probe_names) else None + if name: + return name[:8] if compact else name + return f"P{index+1}" if compact else f"Probe {index+1}" + + +@dataclass +class RunConfig: + address: Optional[str] = None + unit: str = "F" + interval: int = 1 + as_json: bool = False + probe_names: List[Optional[str]] = field( + default_factory=lambda: [None] * MAX_PROBES + ) + broker: Optional[str] = None + port: int = 1883 + username: Optional[str] = None + password: Optional[str] = None + device_name: str = "thermopro" + + @classmethod + def from_args(cls, args) -> "RunConfig": + return cls( + address=getattr(args, "addr", None), + unit=(getattr(args, "unit", None) or "F").upper(), + interval=getattr(args, "interval", 1), + as_json=getattr(args, "json", False), + probe_names=parse_probe_names(getattr(args, "probe_names", None)), + broker=getattr(args, "broker", None) or os.environ.get("MQTT_BROKER"), + port=getattr(args, "port", 1883), + username=getattr(args, "username", None) or os.environ.get("MQTT_USERNAME"), + password=getattr(args, "password", None) or os.environ.get("MQTT_PASSWORD"), + device_name=getattr(args, "device_name", "thermopro"), + ) + + +class ThermoproState: + def __init__(self): + self.battery = 0 + self.device_unit = "C" + self.probe_count = 4 + self.temperatures = [-999.0] * MAX_PROBES + self.last_update = None + self.connected = False + + def get_temperatures(self, unit: str) -> list: + """Return temperatures converted to the requested display unit.""" + if self.device_unit == unit: + return self.temperatures[:] + temps = [] + for t in self.temperatures: + if not is_valid_temp(t): + temps.append(t) + elif self.device_unit == "C" and unit == "F": + temps.append(t * 9.0 / 5.0 + 32.0) + elif self.device_unit == "F" and unit == "C": + temps.append((t - 32.0) * 5.0 / 9.0) + else: + temps.append(t) + return temps + + def to_dict(self, unit: str): + temps = self.get_temperatures(unit) + return { + "battery": self.battery, + "unit": unit, + "probe_count": self.probe_count, + "temperatures": [round(t, 1) if is_valid_temp(t) else t for t in temps], + "last_update": self.last_update.isoformat() if self.last_update else None, + "connected": self.connected, + } + + +def decode_temperature(byte1: int, byte2: int) -> float: + """ + Decode temperature from 2-byte BCD format. + Based on q/c.java:88-99 + """ + if byte1 == 0xFF and byte2 == 0xFF: + return -999.0 + if byte1 == 0xDD and byte2 == 0xDD: + return -100.0 + if byte1 == 0xEE and byte2 == 0xEE: + return 666.0 + + is_negative = (byte1 & 0x80) != 0 + + hundreds = ((byte1 & 0x70) // 16) * 100 + tens = (byte1 & 0x0F) * 10 + ones = (byte2 & 0xF0) // 16 + decimal = (byte2 & 0x0F) * 0.1 + + temp = hundreds + tens + ones + decimal + + if is_negative: + temp = -temp + + return temp + + +def calculate_checksum(data: List[int]) -> int: + """Calculate checksum for command (sum of all bytes & 0xFF)""" + return sum(data) & 0xFF + + +def create_handshake_command() -> bytes: + """ + Create handshake/init command (0x01). + Based on p/b.java:124-163 + + For TP25W, using a known-good handshake from Android app capture. + The exact random bytes don't seem critical for the protocol. + """ + return bytes.fromhex("01098a7a13b73ed68b67c2a0") + + +def create_timestamp_sync_command() -> bytes: + """ + Create timestamp sync command (0x28). + Based on p/b.java:165-169 + """ + timestamp = int(time.time()) - 1577808000 + cmd = [ + 0x28, + 0x04, + (timestamp >> 24) & 0xFF, + (timestamp >> 16) & 0xFF, + (timestamp >> 8) & 0xFF, + timestamp & 0xFF, + ] + checksum = calculate_checksum(cmd) + return bytes(cmd + [checksum]) + + +class ThermoproClient: + def __init__(self, address: str): + self.address = address + self.client: Optional[BleakClient] = None + self.state = ThermoproState() + self.notification_event = asyncio.Event() + + def parse_temperature_packet(self, data: bytearray): + """ + Parse temperature data from command 0x30. + Handles both TP920 and TP25W formats. + """ + if len(data) < 6: + return + + self.state.battery = data[2] + self.state.device_unit = "C" + + log.debug("0x30 raw: %s", " ".join(f"{b:02X}" for b in data)) + log.debug("byte[3] (unit?): 0x%02X", data[3]) + + if data[4] == 0x00: + self.state.probe_count = 4 + probe_offset = 5 + else: + self.state.probe_count = data[4] + probe_offset = 5 + + temps = [] + for i in range(min(self.state.probe_count, MAX_PROBES)): + offset = probe_offset + i * 2 + if offset + 1 < len(data): + t1 = data[offset] + t2 = data[offset + 1] + temp = decode_temperature(t1, t2) + temps.append(temp) + + while len(temps) < MAX_PROBES: + temps.append(-999.0) + + self.state.temperatures = temps[:MAX_PROBES] + self.state.last_update = datetime.now() + self.notification_event.set() + + async def notification_handler(self, sender, data: bytearray): + """ + Handle notifications from the device. + Based on q/c.java protocol parsing + """ + if len(data) < 2: + return + + cmd = data[0] + + if cmd == 0x30: + self.parse_temperature_packet(data) + elif cmd == 0x01: + if len(data) >= 4: + model_code = data[3] if len(data) > 3 else 0 + log.debug("Device model code: 0x%02X", model_code) + self.notification_event.set() + elif cmd == 0x41: + if len(data) >= 3: + version_byte = data[2] + version = (version_byte // 16) + ((version_byte % 16) / 10.0) + log.debug("Device version: %s", version) + self.notification_event.set() + elif cmd == 0xE0: + pass + else: + self.notification_event.set() + + async def connect(self) -> bool: + """Connect to the thermometer and initialize.""" + try: + log.debug("Connecting to %s...", self.address) + self.client = BleakClient(self.address) + await self.client.connect() + self.state.connected = True + log.debug("Connected!") + + log.debug("Enabling notifications...") + await self.client.start_notify(NOTIFY_UUID, self.notification_handler) + await asyncio.sleep(0.5) + + log.debug("Sending handshake...") + handshake = create_handshake_command() + await self.client.write_gatt_char(WRITE_UUID, handshake) + + log.debug("Waiting for temperature data...") + await asyncio.sleep(2.0) + + log.debug("Initialization complete!") + return True + + except Exception as e: + print(f"Connection failed: {e}", file=sys.stderr) + self.state.connected = False + return False + + async def disconnect(self): + """Disconnect from the thermometer""" + if self.client and self.client.is_connected: + await self.client.disconnect() + self.state.connected = False + + async def wait_for_update(self, timeout: float = 5.0) -> bool: + """Wait for a temperature update via notification""" + self.notification_event.clear() + try: + await asyncio.wait_for(self.notification_event.wait(), timeout) + return True + except asyncio.TimeoutError: + return False + + +def is_thermopro_device(name: str) -> bool: + return "thermo" in name.lower() or bool(re.match(r"^TP\d", name)) + + +async def scan_thermopro_devices(timeout: float = 3.0) -> List: + """Scan for ThermoPro BLE devices, deduplicated by address.""" + print("Scanning for ThermoPro devices...", file=sys.stderr) + try: + devices = await BleakScanner.discover(timeout=timeout) + except Exception as e: + print( + f"Error: BLE scan failed. Is Bluetooth enabled?\nDetails: {e}", + file=sys.stderr, + ) + sys.exit(1) + + seen = {} + for d in devices: + if is_thermopro_device(d.name or "") and d.address not in seen: + seen[d.address] = d + return list(seen.values()) + + +async def resolve_address(addr: Optional[str]) -> str: + """Resolve device address: use provided value or auto-discover via scan.""" + if addr: + return addr + + thermopro_devices = await scan_thermopro_devices() + + if not thermopro_devices: + print( + "Error: No ThermoPro devices found. Specify --addr manually.", + file=sys.stderr, + ) + sys.exit(1) + + if len(thermopro_devices) == 1: + device = thermopro_devices[0] + print(f"Auto-selected: {device.name} ({device.address})", file=sys.stderr) + return device.address + + if not sys.stdin.isatty(): + print( + "Error: Multiple ThermoPro devices found but stdin is not interactive.\n" + "Specify --addr to select a device:", + file=sys.stderr, + ) + for d in thermopro_devices: + print(f" {d.name} ({d.address})", file=sys.stderr) + sys.exit(1) + + print(f"\nFound {len(thermopro_devices)} ThermoPro devices:\n", file=sys.stderr) + for i, device in enumerate(thermopro_devices, 1): + print(f" [{i}] {device.name} ({device.address})", file=sys.stderr) + print(file=sys.stderr) + + while True: + try: + sys.stderr.write(f"Select device [1-{len(thermopro_devices)}]: ") + sys.stderr.flush() + choice = input() + except (EOFError, KeyboardInterrupt): + print("\nAborted.", file=sys.stderr) + sys.exit(1) + + try: + index = int(choice) - 1 + if 0 <= index < len(thermopro_devices): + selected = thermopro_devices[index] + print( + f"Selected: {selected.name} ({selected.address})", + file=sys.stderr, + ) + return selected.address + except ValueError: + pass + + print( + f"Invalid choice. Enter a number between 1 and {len(thermopro_devices)}.", + file=sys.stderr, + ) + + +def format_connect_output( + state: ThermoproState, probe_names: List[Optional[str]], unit: str +) -> str: + """Multi-line diagnostic output for 'connect' command.""" + lines = [ + "\nReceived temperature update!", + f"Battery: {state.battery}%", + f"Unit: {unit}", + f"Probes: {state.probe_count}", + ] + temps = state.get_temperatures(unit) + for i, temp in enumerate(temps[: state.probe_count]): + label = get_probe_label(probe_names, i) + if is_valid_temp(temp): + lines.append(f"{label}: {temp:.1f}°{unit} (connected)") + else: + lines.append(f"{label}: not connected") + return "\n".join(lines) + + +def format_temps_output( + state: ThermoproState, probe_names: List[Optional[str]], unit: str +) -> str: + """Multi-line output for 'temps' command.""" + lines = [ + f"Battery: {state.battery}%", + f"Unit: {unit}", + ] + temps = state.get_temperatures(unit) + for i, temp in enumerate(temps[: state.probe_count]): + label = get_probe_label(probe_names, i) + if is_valid_temp(temp): + lines.append(f"{label}: {temp:.1f}°{unit}") + else: + lines.append(f"{label}: not connected") + return "\n".join(lines) + + +def format_monitor_line( + state: ThermoproState, probe_names: List[Optional[str]], unit: str +) -> str: + """Compact one-liner for monitor command.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + temps_str = [] + temps = state.get_temperatures(unit) + for i, temp in enumerate(temps[: state.probe_count]): + label = get_probe_label(probe_names, i, compact=True) + if is_valid_temp(temp): + temps_str.append(f"{label}:{temp:.1f}°{unit}") + else: + temps_str.append(f"{label}:---") + return f"[{timestamp}] Battery:{state.battery:3d}% | {' | '.join(temps_str)}" + + +def format_json( + state: ThermoproState, + probe_names: List[Optional[str]], + unit: str, + extra: dict = None, + compact: bool = False, +) -> str: + """JSON output with probe_names included.""" + output = state.to_dict(unit) + output["probe_names"] = [ + get_probe_label(probe_names, i) for i in range(state.probe_count) + ] + if extra: + output.update(extra) + return json.dumps(output) if compact else json.dumps(output, indent=2) + + +async def cmd_scan(cfg: RunConfig) -> int: + """Scan for ThermoPro devices""" + print("Scanning for Bluetooth devices...", file=sys.stderr) + try: + devices = await BleakScanner.discover(timeout=5.0) + except Exception as e: + print( + f"Error: BLE scan failed. Is Bluetooth enabled?\nDetails: {e}", + file=sys.stderr, + ) + return 1 + + seen = {} + for d in devices: + if is_thermopro_device(d.name or "") and d.address not in seen: + seen[d.address] = d + thermopro_devices = list(seen.values()) + + for device in thermopro_devices: + print(f"Found: {device.name} ({device.address})") + + if not thermopro_devices: + print("\nNo ThermoPro devices found.", file=sys.stderr) + print("Showing all devices:", file=sys.stderr) + for device in devices: + print(f" {device.name or 'Unknown'} ({device.address})") + + return 0 if thermopro_devices else 1 + + +async def cmd_connect(cfg: RunConfig) -> int: + """Test connection to a device""" + address = await resolve_address(cfg.address) + client = ThermoproClient(address) + + try: + if not await client.connect(): + return 1 + + if await client.wait_for_update(timeout=10.0): + print(format_connect_output(client.state, cfg.probe_names, cfg.unit)) + return 0 + else: + print("Timeout waiting for temperature data", file=sys.stderr) + return 1 + finally: + await client.disconnect() + + +async def cmd_temps(cfg: RunConfig) -> int: + """Get current temperatures""" + address = await resolve_address(cfg.address) + client = ThermoproClient(address) + + try: + if not await client.connect(): + return 1 + + if await client.wait_for_update(timeout=10.0): + if cfg.as_json: + print(format_json(client.state, cfg.probe_names, cfg.unit)) + else: + print(format_temps_output(client.state, cfg.probe_names, cfg.unit)) + return 0 + else: + print("Error: Timeout waiting for temperature data", file=sys.stderr) + return 1 + finally: + await client.disconnect() + + +async def connection_loop(address: str): + """Yields connected ThermoproClient instances. Reconnects on failure with backoff.""" + max_retry = 300 + delay = 5 + failures = 0 + while True: + client = ThermoproClient(address) + try: + if not await client.connect(): + failures += 1 + delay = min(5 * (2 ** (failures - 1)), max_retry) + log.warning("Connection failed, retry in %ds", delay) + await asyncio.sleep(delay) + continue + failures = 0 + delay = 5 + yield client + except Exception as e: + failures += 1 + delay = min(5 * (2 ** (failures - 1)), max_retry) + log.warning("%s, reconnecting in %ds", e, delay) + await asyncio.sleep(delay) + finally: + try: + await client.disconnect() + except Exception: + pass + + +async def cmd_monitor(cfg: RunConfig) -> int: + """Monitor temperatures continuously""" + address = await resolve_address(cfg.address) + + if not cfg.as_json: + print("Monitoring temperatures (Ctrl+C to stop)...\n", file=sys.stderr) + + gen = connection_loop(address) + try: + async for client in gen: + while True: + if await client.wait_for_update(timeout=10.0): + if cfg.as_json: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print( + format_json( + client.state, + cfg.probe_names, + cfg.unit, + extra={"timestamp": timestamp}, + compact=True, + ) + ) + else: + print( + format_monitor_line(client.state, cfg.probe_names, cfg.unit) + ) + sys.stdout.flush() + else: + log.warning("No update received, reconnecting...") + break + await asyncio.sleep(cfg.interval) + except (KeyboardInterrupt, asyncio.CancelledError): + pass + finally: + await gen.aclose() + + return 0 + + +class MQTTPublisher: + """MQTT publisher for Home Assistant integration""" + + def __init__( + self, + broker: str, + port: int = 1883, + username: Optional[str] = None, + password: Optional[str] = None, + device_name: str = "thermopro", + discovery_prefix: str = "homeassistant", + ): + self.broker = broker + self.port = port + self.username = username + self.password = password + self.device_name = device_name + self.discovery_prefix = discovery_prefix + if MQTT_V2: + self.client = mqtt.Client(CallbackAPIVersion.VERSION2) + else: + self.client = mqtt.Client() + self.connected = False + + if username and password: + self.client.username_pw_set(username, password) + + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + + def _on_connect(self, client, userdata, flags, reason_code, properties=None): + if reason_code == 0: + self.connected = True + print("Connected to MQTT broker", file=sys.stderr) + else: + print(f"Failed to connect to MQTT broker: {reason_code}", file=sys.stderr) + + def _on_disconnect(self, client, userdata, *args): + # v1: (client, userdata, rc) — rc is args[0] + # v2: (client, userdata, flags, reason_code, properties) — rc is args[1] + rc = args[1] if len(args) > 1 else args[0] if args else 0 + self.connected = False + if rc != 0: + print("Disconnected from MQTT broker", file=sys.stderr) + + def connect(self): + """Connect to MQTT broker""" + try: + self.client.connect(self.broker, self.port, 60) + self.client.loop_start() + for _ in range(50): + if self.connected: + return True + time.sleep(0.1) + return False + except Exception as e: + print(f"MQTT connection error: {e}", file=sys.stderr) + return False + + def disconnect(self): + """Disconnect from MQTT broker""" + self.client.loop_stop() + self.client.disconnect() + + def publish_discovery( + self, + probe_num: int, + mac_address: str, + unit: str = "F", + probe_name: Optional[str] = None, + ): + """Publish Home Assistant MQTT discovery config for a probe""" + device_id = mac_address.replace(":", "").lower() + probe_id = f"{self.device_name}_probe{probe_num}" + display_name = probe_name or f"ThermoPro Probe {probe_num}" + + device_info = { + "identifiers": [device_id], + "name": f"ThermoPro {self.device_name.upper()}", + "model": "TP25W", + "manufacturer": "ThermoPro", + } + + temp_config = { + "name": display_name, + "unique_id": f"{device_id}_probe{probe_num}_temp", + "state_topic": f"{self.discovery_prefix}/sensor/{probe_id}/state", + "unit_of_measurement": f"°{unit}", + "device_class": "temperature", + "value_template": "{{ value_json.temperature }}", + "device": device_info, + } + + config_topic = f"{self.discovery_prefix}/sensor/{probe_id}/config" + self.client.publish(config_topic, json.dumps(temp_config), retain=True) + + def publish_battery_discovery(self, mac_address: str): + """Publish Home Assistant MQTT discovery config for battery""" + device_id = mac_address.replace(":", "").lower() + sensor_id = f"{self.device_name}_battery" + + device_info = { + "identifiers": [device_id], + "name": f"ThermoPro {self.device_name.upper()}", + "model": "TP25W", + "manufacturer": "ThermoPro", + } + + battery_config = { + "name": f"ThermoPro Battery", + "unique_id": f"{device_id}_battery", + "state_topic": f"{self.discovery_prefix}/sensor/{sensor_id}/state", + "unit_of_measurement": "%", + "device_class": "battery", + "value_template": "{{ value_json.battery }}", + "device": device_info, + } + + config_topic = f"{self.discovery_prefix}/sensor/{sensor_id}/config" + self.client.publish(config_topic, json.dumps(battery_config), retain=True) + + def publish_state(self, probe_num: int, temperature: float, connected: bool = True): + """Publish temperature state for a probe""" + probe_id = f"{self.device_name}_probe{probe_num}" + state_topic = f"{self.discovery_prefix}/sensor/{probe_id}/state" + + if not connected: + self.client.publish(state_topic, json.dumps({"temperature": None})) + else: + state = { + "temperature": round(temperature, 1), + } + self.client.publish(state_topic, json.dumps(state)) + + def publish_battery_state(self, battery_percent: int): + """Publish battery state""" + sensor_id = f"{self.device_name}_battery" + state_topic = f"{self.discovery_prefix}/sensor/{sensor_id}/state" + + state = { + "battery": battery_percent, + } + self.client.publish(state_topic, json.dumps(state)) + + +async def cmd_mqtt(cfg: RunConfig) -> int: + """Monitor temperatures and publish to MQTT for Home Assistant with automatic reconnection""" + if not MQTT_AVAILABLE: + print( + "Error: paho-mqtt library not installed. Run: pip install paho-mqtt", + file=sys.stderr, + ) + return 1 + + if not cfg.broker: + print( + "Error: MQTT broker not specified. Use --broker or set MQTT_BROKER environment variable", + file=sys.stderr, + ) + return 1 + + mqtt_pub = MQTTPublisher( + cfg.broker, cfg.port, cfg.username, cfg.password, cfg.device_name + ) + if not mqtt_pub.connect(): + print("Error: Failed to connect to MQTT broker", file=sys.stderr) + return 1 + + gen = connection_loop(cfg.address) + try: + print( + f"Publishing to MQTT broker {cfg.broker}:{cfg.port} (Ctrl+C to stop)...\n", + file=sys.stderr, + ) + + async for client in gen: + log.debug("Publishing Home Assistant discovery configs...") + for probe_num in range(1, MAX_PROBES + 1): + name = cfg.probe_names[probe_num - 1] + mqtt_pub.publish_discovery( + probe_num, cfg.address, cfg.unit, probe_name=name + ) + mqtt_pub.publish_battery_discovery(cfg.address) + print("Connected to thermometer successfully!", file=sys.stderr) + + while True: + if await client.wait_for_update(timeout=15.0): + temps = client.state.get_temperatures(cfg.unit) + for i, temp in enumerate(temps[:MAX_PROBES]): + probe_num = i + 1 + connected = is_valid_temp(temp) + mqtt_pub.publish_state(probe_num, temp, connected) + log.debug( + "Published Probe %d: %s", + probe_num, + f"{temp:.1f}°{cfg.unit}" if connected else "disconnected", + ) + + mqtt_pub.publish_battery_state(client.state.battery) + log.debug("Published Battery: %d%%", client.state.battery) + + print( + format_monitor_line(client.state, cfg.probe_names, cfg.unit), + file=sys.stderr, + ) + else: + log.warning("No update from device, reconnecting...") + break + + await asyncio.sleep(cfg.interval) + + except (KeyboardInterrupt, asyncio.CancelledError): + pass + finally: + await gen.aclose() + mqtt_pub.disconnect() + + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="ThermoPro Bluetooth Thermometer CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument("--debug", action="store_true", help="Enable debug output") + + subparsers = parser.add_subparsers(dest="command", help="Command to execute") + + # Scan + p = subparsers.add_parser("scan", help="Scan for ThermoPro devices") + p.set_defaults(func=cmd_scan) + + # Connect + p = subparsers.add_parser("connect", help="Test connection to device") + p.add_argument("--addr", help="Bluetooth address (auto-scans if not provided)") + p.add_argument( + "--probe-names", + help="Comma-separated probe names (e.g. 'Brisket,Ambient,,Ribs')", + ) + p.set_defaults(func=cmd_connect) + + # Temps + p = subparsers.add_parser("temps", help="Get current temperatures") + p.add_argument("--addr", help="Bluetooth address (auto-scans if not provided)") + p.add_argument("--json", action="store_true", help="Output as JSON") + p.add_argument("--unit", choices=["C", "F"], help="Temperature unit") + p.add_argument( + "--probe-names", + help="Comma-separated probe names (e.g. 'Brisket,Ambient,,Ribs')", + ) + p.set_defaults(func=cmd_temps) + + # Monitor + p = subparsers.add_parser("monitor", help="Monitor temperatures continuously") + p.add_argument("--addr", help="Bluetooth address (auto-scans if not provided)") + p.add_argument("--interval", type=int, default=1, help="Update interval in seconds") + p.add_argument("--json", action="store_true", help="Output as JSON") + p.add_argument("--unit", choices=["C", "F"], help="Temperature unit") + p.add_argument( + "--probe-names", + help="Comma-separated probe names (e.g. 'Brisket,Ambient,,Ribs')", + ) + p.set_defaults(func=cmd_monitor) + + # MQTT + p = subparsers.add_parser( + "mqtt", help="Publish temperatures to MQTT for Home Assistant" + ) + p.add_argument("--addr", required=True, help="Bluetooth address") + p.add_argument( + "--interval", + type=int, + default=5, + help="Update interval in seconds (default: 5)", + ) + p.add_argument("--unit", choices=["C", "F"], help="Temperature unit") + p.add_argument("--broker", help="MQTT broker address (or set MQTT_BROKER env var)") + p.add_argument( + "--port", type=int, default=1883, help="MQTT broker port (default: 1883)" + ) + p.add_argument("--username", help="MQTT username (or set MQTT_USERNAME env var)") + p.add_argument("--password", help="MQTT password (or set MQTT_PASSWORD env var)") + p.add_argument( + "--device-name", + default="thermopro", + help="Device name for MQTT topics (default: thermopro)", + ) + p.add_argument( + "--probe-names", + help="Comma-separated probe names (e.g. 'Brisket,Ambient,,Ribs')", + ) + p.set_defaults(func=cmd_mqtt) + + return parser + + +def load_dotenv(): + """Load .env file from the script's directory into os.environ (no override).""" + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + try: + with open(env_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + except FileNotFoundError: + pass + + +def main(): + load_dotenv() + parser = build_parser() + args = parser.parse_args() + + if not args.command: + parser.print_help() + return 1 + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.WARNING, + format="[%(levelname)s] %(message)s", + stream=sys.stderr, + ) + + cfg = RunConfig.from_args(args) + + def _sigint_handler(sig, frame): + print("\nStopping...", file=sys.stderr) + signal.signal(signal.SIGINT, signal.SIG_DFL) + raise KeyboardInterrupt + + signal.signal(signal.SIGINT, _sigint_handler) + + try: + return asyncio.run(args.func(cfg)) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + sys.exit(main())