diff --git a/Dockerfile b/Dockerfile
index bdd9d4a..dee636c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py
index b4ba38f..eae5c42 100644
--- a/app/fanout/community_mqtt.py
+++ b/app/fanout/community_mqtt.py
@@ -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):
diff --git a/app/fanout/mqtt_base.py b/app/fanout/mqtt_base.py
index db8ea6f..f9c725d 100644
--- a/app/fanout/mqtt_base.py
+++ b/app/fanout/mqtt_base.py
@@ -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,
diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py
index 0fea530..d17971b 100644
--- a/app/fanout/mqtt_community.py
+++ b/app/fanout/mqtt_community.py
@@ -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)
diff --git a/app/fanout/mqtt_private.py b/app/fanout/mqtt_private.py
index 2169589..19e49ae 100644
--- a/app/fanout/mqtt_private.py
+++ b/app/fanout/mqtt_private.py
@@ -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)
diff --git a/app/main.py b/app/main.py
index 2a03f5d..fa509fc 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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,
)
diff --git a/app/routers/debug.py b/app/routers/debug.py
index aa5f457..a290a94 100644
--- a/app/routers/debug.py
+++ b/app/routers/debug.py
@@ -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],
)
diff --git a/app/routers/health.py b/app/routers/health.py
index 226202a..2ceb39e 100644
--- a/app/routers/health.py
+++ b/app/routers/health.py
@@ -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,
diff --git a/app/version_info.py b/app/version_info.py
new file mode 100644
index 0000000..91c5ece
--- /dev/null
+++ b/app/version_info.py
@@ -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)
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 9b02bcb..f32c1b3 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -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
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 616b796..96cb9f5 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -275,7 +275,9 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('about') && (