feat: v0.2.0 - Config YAML, theme toggle, hop map, channel names

- Central config.yaml with live-reload file watcher
- Configurable command prefix (default: /)
- Light/dark theme toggle with localStorage persistence
- Map: hop-based node coloring with legend, hover tooltips
- Dashboard: node long names, hop column, channel names in messages
- Load existing messages on WebSocket connect
- Weather fallback to Dresden center
- Remove version.py and pre-commit hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-15 14:07:51 +01:00
parent a6c09b19eb
commit 9e880a1f36
9 changed files with 206 additions and 58 deletions

View file

@ -1,5 +1,26 @@
# Changelog
## [0.2.0] - 2026-02-15
### Added
- Zentrale config.yaml mit Live-Reload (File-Watcher)
- Konfigurierbarer Command-Prefix (Standard: /)
- Kanalnamen in der Nachrichtenliste
- Hops-Spalte in der Nodes-Tabelle
- Karte: Farbcodierung der Nodes nach Hop-Anzahl mit Legende
- Karte: Tooltip mit Node-Infos beim Hover
- Hell/Dunkel Theme-Umschalter im Dashboard
- Node-Namen (LongName/ShortName) werden korrekt angezeigt
- Nachrichten werden beim Connect aus der DB geladen
- Wetter-Fallback auf Dresden Zentrum bei fehlender Position
### Changed
- Konfiguration von Environment-Variablen auf config.yaml umgestellt
- Version wird in config.yaml statt version.py verwaltet
### Removed
- Git pre-commit Hook (manuelle Versionierung)
- version.py (ersetzt durch config.yaml)
## [0.1.2] - 2026-02-15
### Changed
- Auto-commit update

View file

@ -1,7 +1,8 @@
version: "0.1.2"
version: "0.2.0"
bot:
name: "MeshDD-Bot"
command_prefix: "/"
meshtastic:
host: "192.168.11.11"

View file

@ -31,7 +31,7 @@ async def main():
bot.ws_manager = ws_manager
# Webserver
webserver = WebServer(db, ws_manager)
webserver = WebServer(db, ws_manager, bot)
runner = await webserver.start(config.get("web.host", "0.0.0.0"), config.get("web.port", 8080))
# Connect Meshtastic in a thread (blocking call)

View file

@ -35,8 +35,20 @@ class MeshBot:
if self.interface:
self.interface.close()
def get_channels(self) -> dict:
channels = {}
if self.interface and self.interface.localNode:
for ch in self.interface.localNode.channels:
if ch.role:
name = ch.settings.name if ch.settings.name else ("Primary" if ch.index == 0 else f"Ch {ch.index}")
channels[ch.index] = name
return channels
def _on_connection(self, interface, topic=pub.AUTO_TOPIC):
logger.info("Meshtastic connection established")
if hasattr(interface, 'nodes') and interface.nodes:
for node in interface.nodes.values():
self.loop.call_soon_threadsafe(asyncio.ensure_future, self._handle_node_update(node))
def _on_node_updated(self, node, topic=pub.AUTO_TOPIC):
self.loop.call_soon_threadsafe(asyncio.ensure_future, self._handle_node_update(node))
@ -46,7 +58,11 @@ class MeshBot:
async def _handle_node_update(self, node):
try:
node_id = node.get("user", {}).get("id") or node.get("num")
logger.debug("Node update raw: %s", node)
node_id = node.get("user", {}).get("id")
node_num = node.get("num")
if not node_id and node_num:
node_id = f"!{node_num:08x}"
if not node_id:
return
node_id = str(node_id)
@ -57,7 +73,7 @@ class MeshBot:
snr = node.get("snr")
data = {
"node_num": node.get("num"),
"node_num": node_num,
"long_name": user.get("longName"),
"short_name": user.get("shortName"),
"hw_model": user.get("hwModel"),
@ -91,6 +107,18 @@ class MeshBot:
if from_id:
await self.db.upsert_node(str(from_id), **{k: v for k, v in node_data.items() if v is not None})
# Handle nodeinfo
if portnum == "NODEINFO_APP":
user = packet.get("decoded", {}).get("user", {})
if user and from_id:
await self.db.upsert_node(str(from_id),
long_name=user.get("longName"),
short_name=user.get("shortName"),
hw_model=user.get("hwModel"))
node = await self.db.get_node(str(from_id))
if node and self.ws_manager:
await self.ws_manager.broadcast("node_update", node)
# Handle position updates
if portnum == "POSITION_APP":
pos = packet.get("decoded", {}).get("position", {})
@ -125,43 +153,45 @@ class MeshBot:
await self.ws_manager.broadcast("stats_update", stats)
# Process commands
if text.startswith("!"):
prefix = config.get("bot.command_prefix", "!")
if text.startswith(prefix):
await self._handle_command(text.strip(), channel, str(from_id))
except Exception:
logger.exception("Error handling packet")
async def _handle_command(self, text: str, channel: int, from_id: str):
prefix = config.get("bot.command_prefix", "!")
cmd = text.split()[0].lower()
response = None
if cmd == "!ping":
if cmd == f"{prefix}ping":
response = "🏓 Pong!"
elif cmd == "!nodes":
elif cmd == f"{prefix}nodes":
nodes = await self.db.get_all_nodes()
response = f"📡 Bekannte Nodes: {len(nodes)}"
elif cmd == "!info":
elif cmd == f"{prefix}info":
uptime = self._format_uptime()
response = f" {config.get('bot.name', 'MeshDD-Bot')} v{config.get('version', '0.0.0')}\nUptime: {uptime}"
elif cmd == "!help":
elif cmd == f"{prefix}help":
response = (
"📋 Kommandos:\n"
"!ping - Pong\n"
"!nodes - Anzahl Nodes\n"
"!info - Bot-Info\n"
"!stats - Statistiken\n"
"!uptime - Laufzeit\n"
"!weather - Wetter\n"
"!help - Diese Hilfe"
f"📋 Kommandos:\n"
f"{prefix}ping - Pong\n"
f"{prefix}nodes - Anzahl Nodes\n"
f"{prefix}info - Bot-Info\n"
f"{prefix}stats - Statistiken\n"
f"{prefix}uptime - Laufzeit\n"
f"{prefix}weather - Wetter\n"
f"{prefix}help - Diese Hilfe"
)
elif cmd == "!weather":
elif cmd == f"{prefix}weather":
response = await self._get_weather(from_id)
elif cmd == "!stats":
elif cmd == f"{prefix}stats":
stats = await self.db.get_stats()
response = (
f"📊 Statistiken:\n"
@ -171,7 +201,7 @@ class MeshBot:
f"Textnachrichten: {stats['text_messages']}"
)
elif cmd == "!uptime":
elif cmd == f"{prefix}uptime":
response = f"⏱️ Uptime: {self._format_uptime()}"
if response:
@ -201,10 +231,12 @@ class MeshBot:
async def _get_weather(self, from_id: str) -> str:
node = await self.db.get_node(from_id)
if not node or not node.get("lat") or not node.get("lon"):
return "❌ Keine Position für diesen Node bekannt."
lat, lon = node["lat"], node["lon"]
fallback = False
if node and node.get("lat") and node.get("lon"):
lat, lon = node["lat"], node["lon"]
else:
lat, lon = 51.0504, 13.7373 # Dresden Zentrum
fallback = True
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={lat}&longitude={lon}"
@ -221,8 +253,9 @@ class MeshBot:
code = current.get("weather_code", 0)
weather_icon = self._weather_code_to_icon(code)
location = " (Dresden)" if fallback else ""
return (
f"{weather_icon} Wetter:\n"
f"{weather_icon} Wetter{location}:\n"
f"Temp: {temp}°C\n"
f"Feuchte: {humidity}%\n"
f"Wind: {wind} km/h"

View file

@ -28,9 +28,10 @@ class WebSocketManager:
class WebServer:
def __init__(self, db: Database, ws_manager: WebSocketManager):
def __init__(self, db: Database, ws_manager: WebSocketManager, bot=None):
self.db = db
self.ws_manager = ws_manager
self.bot = bot
self.app = web.Application()
self._setup_routes()
@ -57,6 +58,13 @@ class WebServer:
stats = await self.db.get_stats()
await ws.send_str(json.dumps({"type": "stats_update", "data": stats}))
messages = await self.db.get_recent_messages(50)
await ws.send_str(json.dumps({"type": "initial_messages", "data": messages}))
if self.bot:
channels = self.bot.get_channels()
await ws.send_str(json.dumps({"type": "channels", "data": channels}))
async for msg in ws:
pass # We only send, not receive
finally:

View file

@ -19,6 +19,9 @@
<a href="/map" target="_blank" class="btn btn-outline-info btn-sm">
<i class="bi bi-map me-1"></i>Karte
</a>
<button class="btn btn-outline-secondary btn-sm" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon"></i>
</button>
<span class="badge d-flex align-items-center gap-2" id="statusBadge">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Verbinde...</span>
@ -79,10 +82,10 @@
<thead class="table-dark sticky-top">
<tr>
<th>Name</th>
<th>ID</th>
<th>Hardware</th>
<th class="text-center">SNR</th>
<th class="text-center">Batterie</th>
<th class="text-center">Hops</th>
<th class="text-end">Zuletzt gesehen</th>
</tr>
</thead>

View file

@ -5,6 +5,7 @@ const statusText = document.getElementById('statusText');
const nodeCountBadge = document.getElementById('nodeCountBadge');
let nodes = {};
let channels = {};
let ws;
function connectWebSocket() {
@ -37,6 +38,12 @@ function connectWebSocket() {
nodes[msg.data.node_id] = msg.data;
renderNodes();
break;
case 'initial_messages':
msg.data.reverse().forEach(m => addMessage(m));
break;
case 'channels':
channels = msg.data;
break;
case 'new_message':
addMessage(msg.data);
break;
@ -51,19 +58,26 @@ function renderNodes() {
const sorted = Object.values(nodes).sort((a, b) => (b.last_seen || 0) - (a.last_seen || 0));
nodeCountBadge.textContent = sorted.length;
nodesTable.innerHTML = sorted.map(node => {
const name = node.long_name || node.short_name || node.node_id;
const shortId = node.node_id ? node.node_id.slice(-4) : '?';
let name = node.node_id;
if (node.long_name && node.short_name) {
name = `${node.long_name} (${node.short_name})`;
} else if (node.long_name) {
name = node.long_name;
} else if (node.short_name) {
name = node.short_name;
}
const hw = node.hw_model || '-';
const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-';
const battery = renderBattery(node.battery);
const hops = node.hop_count != null ? node.hop_count : '-';
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : '-';
const onlineClass = isOnline(node.last_seen) ? 'text-success' : '';
return `<tr>
<td class="${onlineClass}">${escapeHtml(name)}</td>
<td><code>${escapeHtml(shortId)}</code></td>
<td class="text-body-secondary">${escapeHtml(hw)}</td>
<td class="text-center">${snr}</td>
<td class="text-center">${battery}</td>
<td class="text-center">${hops}</td>
<td class="text-end text-body-secondary">${lastSeen}</td>
</tr>`;
}).join('');
@ -84,11 +98,14 @@ function addMessage(msg) {
const item = document.createElement('div');
item.className = 'list-group-item list-group-item-action py-2 px-3';
const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : '';
const from = msg.from_node ? msg.from_node.slice(-4) : '?';
const fromNode = nodes[msg.from_node];
const from = (fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?');
const chIdx = msg.channel != null ? msg.channel : '?';
const chName = channels[chIdx] || `Ch ${chIdx}`;
item.innerHTML = `
<div class="d-flex justify-content-between">
<small class="text-body-secondary"><i class="bi bi-person-fill me-1"></i>${escapeHtml(from)}</small>
<small class="text-body-secondary">${time}</small>
<small class="text-body-secondary"><span class="badge bg-secondary me-2">${escapeHtml(chName)}</span>${time}</small>
</div>
<div class="mt-1">${escapeHtml(msg.payload || '')}</div>`;
messagesList.prepend(item);
@ -123,4 +140,22 @@ function escapeHtml(str) {
return div.innerHTML;
}
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
localStorage.setItem('theme', theme);
}
// Load saved theme
applyTheme(localStorage.getItem('theme') || 'dark');
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-bs-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
connectWebSocket();

View file

@ -1,4 +1,4 @@
const map = L.map('map').setView([51.1657, 10.4515], 6); // Germany center
const map = L.map('map').setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
@ -6,11 +6,28 @@ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
}).addTo(map);
const markers = {};
const nodeData = {};
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const nodeCount = document.getElementById('nodeCount');
let ws;
const hopColors = {
0: '#2196F3', // Direkt - Blau
1: '#4CAF50', // 1 Hop - Grün
2: '#FF9800', // 2 Hops - Orange
3: '#F44336', // 3 Hops - Rot
4: '#9C27B0', // 4 Hops - Lila
5: '#795548', // 5 Hops - Braun
};
const hopColorDefault = '#9E9E9E'; // Unbekannt - Grau
function getHopColor(hopCount) {
if (hopCount == null) return hopColorDefault;
if (hopCount >= 6) return hopColorDefault;
return hopColors[hopCount] || hopColorDefault;
}
function createIcon(color) {
return L.divIcon({
className: '',
@ -19,31 +36,26 @@ function createIcon(color) {
</svg>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
popupAnchor: [0, -14],
tooltipAnchor: [14, 0]
});
}
function getNodeColor(node) {
const age = Date.now() / 1000 - (node.last_seen || 0);
if (age < 900) return '#00e676'; // < 15 min: green
if (age < 3600) return '#ff9100'; // < 1h: orange
return '#ff5252'; // > 1h: red
}
function nodePopup(node) {
function nodeTooltip(node) {
const name = node.long_name || node.short_name || node.node_id;
const lastSeen = node.last_seen ? new Date(node.last_seen * 1000).toLocaleString('de-DE') : '-';
const hops = node.hop_count != null ? node.hop_count : '?';
const bat = node.battery != null ? `${node.battery}%` : '-';
const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-';
const alt = node.alt != null ? `${Math.round(node.alt)} m` : '-';
const hw = node.hw_model || '-';
const alt = node.alt != null ? `${Math.round(node.alt)} m` : '-';
const lastSeen = node.last_seen ? new Date(node.last_seen * 1000).toLocaleString('de-DE') : '-';
return `<div class="node-popup">
return `<div class="node-tooltip">
<strong>${escapeHtml(name)}</strong><br>
<span class="label">ID:</span> ${escapeHtml(node.node_id)}<br>
<span class="label">Hardware:</span> ${escapeHtml(hw)}<br>
<span class="label">Batterie:</span> ${bat}<br>
<span class="label">Hops:</span> ${hops}<br>
<span class="label">SNR:</span> ${snr}<br>
<span class="label">Batterie:</span> ${bat}<br>
<span class="label">Höhe:</span> ${alt}<br>
<span class="label">Zuletzt:</span> ${lastSeen}
</div>`;
@ -53,16 +65,17 @@ function updateMarker(node) {
if (!node.lat || !node.lon) return;
const id = node.node_id;
const icon = createIcon(getNodeColor(node));
nodeData[id] = node;
const icon = createIcon(getHopColor(node.hop_count));
if (markers[id]) {
markers[id].setLatLng([node.lat, node.lon]);
markers[id].setIcon(icon);
markers[id].setPopupContent(nodePopup(node));
markers[id].setTooltipContent(nodeTooltip(node));
} else {
markers[id] = L.marker([node.lat, node.lon], { icon })
.addTo(map)
.bindPopup(nodePopup(node));
.bindTooltip(nodeTooltip(node), { direction: 'right', className: 'node-tooltip-wrap' });
}
}
@ -113,11 +126,26 @@ function escapeHtml(str) {
return div.innerHTML;
}
// Refresh marker colors every 60s
setInterval(() => {
Object.values(markers).forEach(marker => {
// Markers will be refreshed on next update
// Legend
const legend = L.control({ position: 'bottomright' });
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'legend');
div.innerHTML = '<strong>Hops</strong>';
const entries = [
[0, 'Direkt'],
[1, '1 Hop'],
[2, '2 Hops'],
[3, '3 Hops'],
[4, '4 Hops'],
[5, '5+ Hops'],
[null, 'Unbekannt'],
];
entries.forEach(([hop, label]) => {
const color = hop != null ? (hopColors[hop] || hopColorDefault) : hopColorDefault;
div.innerHTML += `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`;
});
}, 60000);
return div;
};
legend.addTo(map);
connectWebSocket();

View file

@ -9,9 +9,28 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; }
#map { width: 100vw; height: 100vh; }
.node-popup { font-size: 13px; line-height: 1.6; }
.node-popup strong { color: #0f3460; }
.node-popup .label { color: #666; }
.node-tooltip { font-size: 13px; line-height: 1.6; }
.node-tooltip strong { color: #0f3460; }
.node-tooltip .label { color: #666; }
.node-tooltip-wrap { padding: 0; }
.node-tooltip-wrap .leaflet-tooltip-content { margin: 0; }
.legend {
background: rgba(255, 255, 255, 0.95);
padding: 10px 14px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
font-size: 13px;
line-height: 1.8;
}
.legend strong { display: block; margin-bottom: 4px; color: #333; }
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.2);
flex-shrink: 0;
}
.status-bar {
position: fixed;
top: 10px;