Initial proof-of-concept commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.vscode/
|
||||||
|
.venv/
|
||||||
|
data/*
|
||||||
|
thermopro-cli/
|
||||||
|
notes/
|
||||||
+201
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="Non-WP Coding Style Checks">
|
||||||
|
<description>Non-WP Coding Style Checks</description>
|
||||||
|
<ini name="error_reporting" value="E_ALL & ~E_DEPRECATED" />
|
||||||
|
|
||||||
|
<arg value="sp"/>
|
||||||
|
<arg name="colors"/>
|
||||||
|
<arg name="extensions" value="php,html"/>
|
||||||
|
<arg name="parallel" value="2048"/>
|
||||||
|
|
||||||
|
<exclude-pattern>vendor/</exclude-pattern>
|
||||||
|
<exclude-pattern>node_modules/</exclude-pattern>
|
||||||
|
|
||||||
|
<rule ref="WordPress">
|
||||||
|
<severity>0</severity>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
|
||||||
|
<exclude name="Generic.Functions.CallTimePassByReference"/>
|
||||||
|
<exclude name="Universal.WhiteSpace.PrecisionAlignment.Found"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.MissingPackageTag"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.Missing"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.WrongStyle"/>
|
||||||
|
<exclude name="Squiz.Commenting.InlineComment.InvalidEndChar"/>
|
||||||
|
|
||||||
|
<rule ref="Internal.NoCodeFound">
|
||||||
|
<severity>0</severity>
|
||||||
|
</rule>
|
||||||
|
</ruleset>
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$token = getenv( 'THERMOPRO_BRIDGE_TOKEN' ) ?: 'dev-secret';
|
||||||
|
$dataPath = __DIR__ . '/data';
|
||||||
|
$latestPath = $dataPath . '/latest.json';
|
||||||
|
$logPath = $dataPath . '/readings.ndjson';
|
||||||
|
|
||||||
|
if ( ! is_dir( $dataPath ) ) {
|
||||||
|
mkdir( $dataPath, 0775, true );
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson( array $payload, int $statusCode = 200 ): void {
|
||||||
|
http_response_code( $statusCode );
|
||||||
|
header( 'Content-Type: application/json' );
|
||||||
|
|
||||||
|
echo json_encode(
|
||||||
|
$payload,
|
||||||
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||||
|
);
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestPath(): string {
|
||||||
|
return parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ) ?: '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(): array {
|
||||||
|
$body = file_get_contents( 'php://input' );
|
||||||
|
$payload = json_decode( $body ?: '', true );
|
||||||
|
|
||||||
|
if ( ! is_array( $payload ) ) {
|
||||||
|
sendJson(
|
||||||
|
array(
|
||||||
|
'ok' => 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' );
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ThermoPro Bridge POC</title>
|
||||||
|
<meta http-equiv="refresh" content="10">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
margin: 2rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #f4f4f4;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ThermoPro Bridge POC</h1>
|
||||||
|
|
||||||
|
<?php if ( $latest === null ) { ?>
|
||||||
|
<p>No readings received yet.</p>
|
||||||
|
<?php } else { ?>
|
||||||
|
<p>
|
||||||
|
Latest reading received at:
|
||||||
|
<strong><?php echo htmlspecialchars( $latest['serverReceivedAt'] ?? 'unknown', ENT_QUOTES, 'UTF-8' ); ?></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<pre><?php echo htmlspecialchars( json_encode( $latest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), ENT_QUOTES, 'UTF-8' ); ?></pre>
|
||||||
|
<?php } ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(
|
||||||
|
array(
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Not found',
|
||||||
|
),
|
||||||
|
404
|
||||||
|
);
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user