improve MQTT error bubble up and massage communitymqtt + debug etc. for version management. Closes #70

This commit is contained in:
Jack Kingsman
2026-03-17 20:32:57 -07:00
parent 020acbda02
commit e33bc553f5
22 changed files with 344 additions and 155 deletions

View File

@@ -15,8 +15,12 @@ RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
ARG COMMIT_HASH=unknown
WORKDIR /app
ENV COMMIT_HASH=${COMMIT_HASH}
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

View File

@@ -12,7 +12,6 @@ from __future__ import annotations
import asyncio
import base64
import hashlib
import importlib.metadata
import json
import logging
import ssl
@@ -25,12 +24,13 @@ import nacl.bindings
from app.fanout.mqtt_base import BaseMqttPublisher
from app.path_utils import parse_packet_envelope, split_path_hex
from app.version_info import get_app_build_info
logger = logging.getLogger(__name__)
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
_CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)"
_CLIENT_ID = "RemoteTerm"
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
@@ -261,11 +261,7 @@ def _build_radio_info() -> str:
def _get_client_version() -> str:
"""Return a client version string like ``'RemoteTerm 2.4.0'``."""
try:
version = importlib.metadata.version("remoteterm-meshcore")
return f"RemoteTerm {version}"
except Exception:
return "RemoteTerm unknown"
return f"RemoteTerm {get_app_build_info().version}"
class CommunityMqttPublisher(BaseMqttPublisher):

View File

@@ -54,6 +54,17 @@ class BaseMqttPublisher(ABC):
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
self.integration_name: str = ""
def set_integration_name(self, name: str) -> None:
"""Attach the configured fanout-module name for operator-facing logs."""
self.integration_name = name.strip()
def _integration_label(self) -> str:
"""Return a concise label for logs, including the configured module name."""
if self.integration_name:
return f"{self._log_prefix} [{self.integration_name}]"
return self._log_prefix
# ── Lifecycle ──────────────────────────────────────────────────────
@@ -90,8 +101,9 @@ class BaseMqttPublisher(ABC):
await self._client.publish(topic, json.dumps(payload), retain=retain)
except Exception as e:
logger.warning(
"%s publish failed on %s: %s",
self._log_prefix,
"%s publish failed on %s. This is usually transient network noise; "
"if it self-resolves and reconnects, it is generally not a concern: %s",
self._integration_label(),
topic,
e,
exc_info=True,
@@ -225,8 +237,10 @@ class BaseMqttPublisher(ABC):
broadcast_error(title, detail)
_broadcast_health()
logger.warning(
"%s connection error: %s (reconnecting in %ds)",
self._log_prefix,
"%s connection error. This is usually transient network noise; "
"if it self-resolves, it is generally not a concern: %s "
"(reconnecting in %ds)",
self._integration_label(),
e,
backoff,
exc_info=True,

View File

@@ -77,6 +77,7 @@ class MqttCommunityModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = CommunityMqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)

View File

@@ -32,6 +32,7 @@ class MqttPrivateModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = MqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)

View File

@@ -38,6 +38,7 @@ from app.routers import (
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
setup_logging()
logger = logging.getLogger(__name__)
@@ -102,22 +103,10 @@ async def lifespan(app: FastAPI):
await db.disconnect()
def _get_version() -> str:
"""Read version from pyproject.toml so it stays in sync automatically."""
try:
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
for line in pyproject.read_text().splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except Exception:
pass
return "0.0.0"
app = FastAPI(
title="RemoteTerm for MeshCore API",
description="API for interacting with MeshCore mesh radio networks",
version=_get_version(),
version=get_app_build_info().version,
lifespan=lifespan,
)

View File

@@ -1,11 +1,7 @@
import hashlib
import importlib.metadata
import json
import logging
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter
@@ -17,6 +13,7 @@ from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_chann
from app.repository import MessageRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
logger = logging.getLogger(__name__)
@@ -24,7 +21,6 @@ router = APIRouter(tags=["debug"])
LOG_COPY_BOUNDARY_MESSAGE = "STOP COPYING HERE IF YOU DO NOT WANT TO INCLUDE LOGS BELOW"
LOG_COPY_BOUNDARY_LINE = "-" * 64
RELEASE_BUILD_INFO_FILENAME = "build_info.json"
LOG_COPY_BOUNDARY_PREFIX = [
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
@@ -40,7 +36,9 @@ LOG_COPY_BOUNDARY_PREFIX = [
class DebugApplicationInfo(BaseModel):
version: str
version_source: str
commit_hash: str | None = None
commit_source: str | None = None
git_branch: str | None = None
git_dirty: bool | None = None
python_version: str
@@ -97,64 +95,15 @@ class DebugSnapshotResponse(BaseModel):
logs: list[str]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _get_app_version() -> str:
try:
return importlib.metadata.version("remoteterm-meshcore")
except Exception:
pyproject = _repo_root() / "pyproject.toml"
try:
for line in pyproject.read_text().splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except Exception:
pass
return "0.0.0"
def _git_output(*args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=_repo_root(),
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
output = result.stdout.strip()
return output or None
def _release_build_info() -> dict[str, Any] | None:
build_info_path = _repo_root() / RELEASE_BUILD_INFO_FILENAME
try:
data = json.loads(build_info_path.read_text())
except Exception:
return None
if isinstance(data, dict):
return data
return None
def _build_application_info() -> DebugApplicationInfo:
release_build_info = _release_build_info()
dirty_output = _git_output("status", "--porcelain")
commit_hash = _git_output("rev-parse", "HEAD")
if commit_hash is None and release_build_info is not None:
commit_hash_value = release_build_info.get("commit_hash")
if isinstance(commit_hash_value, str) and commit_hash_value.strip():
commit_hash = commit_hash_value.strip()
build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain")
return DebugApplicationInfo(
version=_get_app_version(),
commit_hash=commit_hash,
git_branch=_git_output("rev-parse", "--abbrev-ref", "HEAD"),
version=build_info.version,
version_source=build_info.version_source,
commit_hash=build_info.commit_hash,
commit_source=build_info.commit_source,
git_branch=git_output("rev-parse", "--abbrev-ref", "HEAD"),
git_dirty=(dirty_output is not None and dirty_output != ""),
python_version=sys.version.split()[0],
)

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel
from app.config import settings
from app.repository import RawPacketRepository
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
router = APIRouter(tags=["health"])
@@ -19,12 +20,18 @@ class RadioDeviceInfoResponse(BaseModel):
max_channels: int | None = None
class AppInfoResponse(BaseModel):
version: str
commit_hash: str | None = None
class HealthResponse(BaseModel):
status: str
radio_connected: bool
radio_initializing: bool = False
radio_state: str = "disconnected"
connection_info: str | None
app_info: AppInfoResponse | None = None
radio_device_info: RadioDeviceInfoResponse | None = None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
@@ -41,6 +48,7 @@ def _clean_optional_str(value: object) -> str | None:
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
app_build_info = get_app_build_info()
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
@@ -102,6 +110,10 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"radio_initializing": radio_initializing,
"radio_state": radio_state,
"connection_info": connection_info,
"app_info": {
"version": app_build_info.version,
"commit_hash": app_build_info.commit_hash,
},
"radio_device_info": radio_device_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,

149
app/version_info.py Normal file
View File

@@ -0,0 +1,149 @@
"""Unified application version/build metadata resolution.
Resolution order:
- version: installed package metadata, ``APP_VERSION`` env, ``build_info.json``, ``pyproject.toml``
- commit: local git, ``COMMIT_HASH``/``VITE_COMMIT_HASH`` env, ``build_info.json``
This keeps backend surfaces, release bundles, and Docker builds aligned.
"""
from __future__ import annotations
import importlib.metadata
import json
import os
import subprocess
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
import tomllib
RELEASE_BUILD_INFO_FILENAME = "build_info.json"
PROJECT_NAME = "remoteterm-meshcore"
@dataclass(frozen=True)
class AppBuildInfo:
version: str
version_source: str
commit_hash: str | None
commit_source: str | None
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _read_build_info(root: Path) -> dict[str, Any] | None:
build_info_path = root / RELEASE_BUILD_INFO_FILENAME
try:
data = json.loads(build_info_path.read_text())
except Exception:
return None
return data if isinstance(data, dict) else None
def _package_metadata_version() -> str | None:
try:
return importlib.metadata.version(PROJECT_NAME)
except Exception:
return None
def _env_version() -> str | None:
value = os.getenv("APP_VERSION")
return value.strip() if value and value.strip() else None
def _build_info_version(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("version")
return value.strip() if isinstance(value, str) and value.strip() else None
def _pyproject_version(root: Path) -> str | None:
try:
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
project = pyproject.get("project")
if isinstance(project, dict):
version = project.get("version")
if isinstance(version, str) and version.strip():
return version.strip()
except Exception:
return None
return None
def _git_output(root: Path, *args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=root,
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
output = result.stdout.strip()
return output or None
def _env_commit_hash() -> str | None:
for name in ("COMMIT_HASH", "VITE_COMMIT_HASH"):
value = os.getenv(name)
if value and value.strip():
return value.strip()[:8]
return None
def _build_info_commit_hash(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("commit_hash")
return value.strip()[:8] if isinstance(value, str) and value.strip() else None
@lru_cache(maxsize=1)
def get_app_build_info() -> AppBuildInfo:
root = repo_root()
build_info = _read_build_info(root)
version = _package_metadata_version()
version_source = "package_metadata"
if version is None:
version = _env_version()
version_source = "env"
if version is None:
version = _build_info_version(build_info)
version_source = "build_info"
if version is None:
version = _pyproject_version(root)
version_source = "pyproject"
if version is None:
version = "0.0.0"
version_source = "fallback"
commit_hash = _git_output(root, "rev-parse", "--short", "HEAD")
commit_source: str | None = "git" if commit_hash else None
if commit_hash is None:
commit_hash = _env_commit_hash()
commit_source = "env" if commit_hash else None
if commit_hash is None:
commit_hash = _build_info_commit_hash(build_info)
commit_source = "build_info" if commit_hash else None
return AppBuildInfo(
version=version,
version_source=version_source,
commit_hash=commit_hash,
commit_source=commit_source,
)
def git_output(*args: str) -> str | None:
"""Shared git helper for debug surfaces that still need live repo state."""
return _git_output(repo_root(), *args)

View File

@@ -129,8 +129,7 @@ frontend/src/
│ │ └── RepeaterConsolePane.tsx # CLI console with history
│ └── ui/ # shadcn/ui primitives
├── types/
── d3-force-3d.d.ts # Type declarations for d3-force-3d
│ └── globals.d.ts # Global type declarations (__APP_VERSION__, __COMMIT_HASH__)
── d3-force-3d.d.ts # Type declarations for d3-force-3d
└── test/
├── setup.ts
├── fixtures/websocket_events.json

View File

@@ -275,7 +275,9 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('about') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('about')}
{isSectionVisible('about') && <SettingsAboutSection className={sectionContentClass} />}
{isSectionVisible('about') && (
<SettingsAboutSection health={health} className={sectionContentClass} />
)}
</section>
)}
</div>

View File

@@ -1,10 +1,17 @@
import type { HealthStatus } from '../../types';
import { Separator } from '../ui/separator';
const GITHUB_URL = 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore';
export function SettingsAboutSection({ className }: { className?: string }) {
const version = __APP_VERSION__;
const commit = __COMMIT_HASH__;
export function SettingsAboutSection({
health,
className,
}: {
health?: HealthStatus | null;
className?: string;
}) {
const version = health?.app_info?.version ?? 'unknown';
const commit = health?.app_info?.commit_hash;
return (
<div className={className}>
@@ -14,8 +21,14 @@ export function SettingsAboutSection({ className }: { className?: string }) {
<h3 className="text-lg font-semibold">RemoteTerm for MeshCore</h3>
<div className="text-sm text-muted-foreground">
v{version}
{commit ? (
<>
<span className="mx-1.5">·</span>
<span className="font-mono text-xs">{commit}</span>
<span className="font-mono text-xs" title={commit}>
{commit}
</span>
</>
) : null}
</div>
</div>

View File

@@ -1,20 +1,28 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { SettingsAboutSection } from '../components/settings/SettingsAboutSection';
describe('SettingsAboutSection', () => {
beforeEach(() => {
vi.stubGlobal('__APP_VERSION__', '3.2.0-test');
vi.stubGlobal('__COMMIT_HASH__', 'deadbeef');
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders the debug support snapshot link', () => {
render(<SettingsAboutSection />);
render(
<SettingsAboutSection
health={{
status: 'ok',
radio_connected: true,
radio_initializing: false,
connection_info: 'Serial: /dev/ttyUSB0',
app_info: {
version: '3.2.0-test',
commit_hash: 'deadbeef',
},
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
}}
/>
);
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
expect(link).toHaveAttribute('href', '/api/debug');

View File

@@ -51,12 +51,18 @@ export interface FanoutStatusEntry {
status: string;
}
export interface AppInfo {
version: string;
commit_hash: string | null;
}
export interface HealthStatus {
status: string;
radio_connected: boolean;
radio_initializing: boolean;
radio_state?: 'connected' | 'initializing' | 'connecting' | 'disconnected' | 'paused';
connection_info: string | null;
app_info?: AppInfo | null;
radio_device_info?: {
model: string | null;
firmware_build: string | null;

View File

@@ -1,2 +0,0 @@
declare const __APP_VERSION__: string;
declare const __COMMIT_HASH__: string;

View File

@@ -1,24 +1,9 @@
import path from "path"
import { execSync } from "child_process"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
function getCommitHash(): string {
// Docker builds pass VITE_COMMIT_HASH as an env var
if (process.env.VITE_COMMIT_HASH) return process.env.VITE_COMMIT_HASH;
try {
return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version ?? 'unknown'),
__COMMIT_HASH__: JSON.stringify(getCommitHash()),
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

View File

@@ -186,7 +186,8 @@ mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
cp -R "$SCRIPT_DIR/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
{
"commit_hash": "$FULL_GIT_HASH",
"version": "$VERSION",
"commit_hash": "$GIT_HASH",
"build_source": "prebuilt-release"
}
EOF

View File

@@ -5,7 +5,6 @@ Uses httpx.AsyncClient or direct function calls with real in-memory SQLite.
"""
import hashlib
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -20,6 +19,7 @@ from app.repository import (
MessageRepository,
RawPacketRepository,
)
from app.version_info import AppBuildInfo
@pytest.fixture(autouse=True)
@@ -144,7 +144,9 @@ class TestDebugEndpoint:
"app.routers.debug._build_application_info",
return_value=DebugApplicationInfo(
version="3.2.0",
version_source="pyproject",
commit_hash="deadbeef",
commit_source="git",
git_branch="main",
git_dirty=False,
python_version="3.12.0",
@@ -186,24 +188,24 @@ class TestDebugApplicationInfo:
"""Release bundles should still surface commit metadata without a .git directory."""
from app.routers import debug as debug_router
(tmp_path / "build_info.json").write_text(
json.dumps(
{
"commit_hash": "cf1a55e25828ee62fb077d6202b174f69f6e6340",
"build_source": "prebuilt-release",
}
)
)
with (
patch("app.routers.debug._repo_root", return_value=tmp_path),
patch("app.routers.debug._get_app_version", return_value="3.4.0"),
patch("app.routers.debug._git_output", return_value=None),
patch(
"app.routers.debug.get_app_build_info",
return_value=AppBuildInfo(
version="3.4.0",
version_source="pyproject",
commit_hash="cf1a55e2",
commit_source="build_info",
),
),
patch("app.routers.debug.git_output", return_value=None),
):
info = debug_router._build_application_info()
assert info.version == "3.4.0"
assert info.commit_hash == "cf1a55e25828ee62fb077d6202b174f69f6e6340"
assert info.version_source == "pyproject"
assert info.commit_hash == "cf1a55e2"
assert info.commit_source == "build_info"
assert info.git_branch is None
assert info.git_dirty is False
@@ -211,17 +213,24 @@ class TestDebugApplicationInfo:
"""Malformed release metadata should not break the debug endpoint."""
from app.routers import debug as debug_router
(tmp_path / "build_info.json").write_text("{not-json")
with (
patch("app.routers.debug._repo_root", return_value=tmp_path),
patch("app.routers.debug._get_app_version", return_value="3.4.0"),
patch("app.routers.debug._git_output", return_value=None),
patch(
"app.routers.debug.get_app_build_info",
return_value=AppBuildInfo(
version="3.4.0",
version_source="pyproject",
commit_hash=None,
commit_source=None,
),
),
patch("app.routers.debug.git_output", return_value=None),
):
info = debug_router._build_application_info()
assert info.version == "3.4.0"
assert info.version_source == "pyproject"
assert info.commit_hash is None
assert info.commit_source is None
assert info.git_branch is None
assert info.git_dirty is False

View File

@@ -631,15 +631,18 @@ class TestCommunityMqttPublisher:
class TestPublishFailureSetsDisconnected:
@pytest.mark.asyncio
async def test_publish_error_sets_connected_false(self):
async def test_publish_error_sets_connected_false(self, caplog):
"""A publish error should set connected=False so the loop can detect it."""
pub = CommunityMqttPublisher()
pub.set_integration_name("LetsMesh West")
pub.connected = True
mock_client = MagicMock()
mock_client.publish = MagicMock(side_effect=Exception("broker gone"))
pub._client = mock_client
await pub.publish("topic", {"data": "test"})
assert pub.connected is False
assert "LetsMesh West" in caplog.text
assert "if it self-resolves" in caplog.text
class TestBuildStatusTopic:
@@ -1217,21 +1220,13 @@ class TestGetClientVersion:
result = _get_client_version()
assert result.startswith("RemoteTerm ")
def test_returns_version_from_metadata(self):
"""Should use importlib.metadata to get version."""
with patch("app.fanout.community_mqtt.importlib.metadata.version", return_value="1.2.3"):
def test_returns_version_from_build_helper(self):
"""Should use the shared backend build-info helper."""
with patch("app.fanout.community_mqtt.get_app_build_info") as mock_build_info:
mock_build_info.return_value.version = "1.2.3"
result = _get_client_version()
assert result == "RemoteTerm 1.2.3"
def test_fallback_on_error(self):
"""Should return 'RemoteTerm unknown' if metadata lookup fails."""
with patch(
"app.fanout.community_mqtt.importlib.metadata.version",
side_effect=Exception("not found"),
):
result = _get_client_version()
assert result == "RemoteTerm unknown"
class TestPublishStatus:
@pytest.mark.asyncio

View File

@@ -9,6 +9,7 @@ from unittest.mock import patch
import pytest
from app.routers.health import build_health_data
from app.version_info import AppBuildInfo
class TestHealthFanoutStatus:
@@ -48,6 +49,15 @@ class TestHealthFanoutStatus:
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
),
patch("app.routers.health.radio_manager") as mock_rm,
patch(
"app.routers.health.get_app_build_info",
return_value=AppBuildInfo(
version="3.4.1",
version_source="pyproject",
commit_hash="abcdef12",
commit_source="git",
),
),
):
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
@@ -58,6 +68,10 @@ class TestHealthFanoutStatus:
assert data["radio_initializing"] is False
assert data["radio_state"] == "connected"
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
assert data["app_info"] == {
"version": "3.4.1",
"commit_hash": "abcdef12",
}
@pytest.mark.asyncio
async def test_health_includes_cached_radio_device_info(self, test_db):

View File

@@ -132,8 +132,9 @@ class TestMqttPublisher:
assert call_args[1]["retain"] is False
@pytest.mark.asyncio
async def test_publish_handles_exception_gracefully(self):
async def test_publish_handles_exception_gracefully(self, caplog):
pub = MqttPublisher()
pub.set_integration_name("Primary MQTT")
pub.connected = True
mock_client = AsyncMock()
mock_client.publish.side_effect = Exception("Network error")
@@ -145,6 +146,8 @@ class TestMqttPublisher:
# After a publish failure, connected should be cleared to stop
# further attempts and reflect accurate status
assert pub.connected is False
assert "Primary MQTT" in caplog.text
assert "usually transient network noise" in caplog.text
@pytest.mark.asyncio
async def test_stop_resets_state(self):

View File

@@ -0,0 +1,41 @@
from app import version_info
class TestAppBuildInfo:
def setup_method(self):
version_info.get_app_build_info.cache_clear()
def teardown_method(self):
version_info.get_app_build_info.cache_clear()
def test_prefers_package_metadata_and_git(self, monkeypatch):
monkeypatch.setattr(version_info, "_package_metadata_version", lambda: "3.4.1")
monkeypatch.setattr(version_info, "_env_version", lambda: "3.4.0-env")
monkeypatch.setattr(version_info, "_build_info_version", lambda build_info: "3.3.0-build")
monkeypatch.setattr(version_info, "_pyproject_version", lambda root: "3.2.0-pyproject")
monkeypatch.setattr(version_info, "_git_output", lambda root, *args: "abcdef12")
monkeypatch.setattr(version_info, "_env_commit_hash", lambda: "fedcba0987654321")
monkeypatch.setattr(version_info, "_build_info_commit_hash", lambda build_info: "11223344")
info = version_info.get_app_build_info()
assert info.version == "3.4.1"
assert info.version_source == "package_metadata"
assert info.commit_hash == "abcdef12"
assert info.commit_source == "git"
def test_falls_back_to_pyproject_and_build_info(self, monkeypatch):
monkeypatch.setattr(version_info, "_package_metadata_version", lambda: None)
monkeypatch.setattr(version_info, "_env_version", lambda: None)
monkeypatch.setattr(version_info, "_build_info_version", lambda build_info: None)
monkeypatch.setattr(version_info, "_pyproject_version", lambda root: "3.2.0")
monkeypatch.setattr(version_info, "_git_output", lambda root, *args: None)
monkeypatch.setattr(version_info, "_env_commit_hash", lambda: None)
monkeypatch.setattr(version_info, "_build_info_commit_hash", lambda build_info: "cf1a55e2")
info = version_info.get_app_build_info()
assert info.version == "3.2.0"
assert info.version_source == "pyproject"
assert info.commit_hash == "cf1a55e2"
assert info.commit_source == "build_info"