## Bug Fixes - Fix integer overflow potential in history bin allocation (web_server.cpp) Using uint64_t for intermediate multiplication prevents overflow with different constants - Prevent data loss during WiFi failures (main.cpp) Device now automatically attempts WiFi reconnection every 30 seconds when in AP mode Exits AP mode and resumes MQTT transmission as soon as WiFi becomes available Data collection and SD logging continue regardless of connectivity ## New Features - Add standalone MQTT data republisher for lost data recovery - Command-line tool (republish_mqtt.py) with interactive and scripting modes - GUI tool (republish_mqtt_gui.py) for user-friendly recovery - Rate-limited publishing (5 msg/sec default, configurable 1-100) - Manual time range selection or auto-detect missing data via InfluxDB - Cross-platform support (Windows, macOS, Linux) - Converts SD card CSV exports back to MQTT format ## Documentation - Add comprehensive code review (CODE_REVIEW.md) - 16 detailed security and quality assessments - Identifies critical HTTPS/auth gaps, medium-priority overflow issues - Confirms absence of buffer overflows and unsafe string functions - Grade: B+ with areas for improvement - Add republisher documentation (REPUBLISH_README.md, REPUBLISH_GUI_README.md) - Installation and usage instructions - Example commands and scenarios - Troubleshooting guide - Performance characteristics ## Dependencies - Add requirements_republish.txt - paho-mqtt>=1.6.1 - influxdb-client>=1.18.0 ## Impact - Eliminates data loss scenario where unreliable WiFi leaves device stuck in AP mode - Provides recovery mechanism for any historical data missed during outages - Improves code safety with explicit overflow-resistant arithmetic - Increases operational visibility with comprehensive code review
513 lines
21 KiB
Python
513 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
DD3 LoRa Bridge - MQTT Data Republisher GUI
|
|
Visual interface for recovering lost meter data from SD card
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox, scrolledtext
|
|
import threading
|
|
import json
|
|
import csv
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
import paho.mqtt.client as mqtt
|
|
|
|
# Optional: for auto-detection
|
|
try:
|
|
from influxdb_client import InfluxDBClient
|
|
HAS_INFLUXDB = True
|
|
except ImportError:
|
|
HAS_INFLUXDB = False
|
|
|
|
|
|
class MQTTRepublisherGUI:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("DD3 MQTT Data Republisher")
|
|
self.root.geometry("900x750")
|
|
self.root.resizable(True, True)
|
|
|
|
# Style
|
|
style = ttk.Style()
|
|
style.theme_use('clam')
|
|
|
|
self.publishing = False
|
|
self.mqtt_client = None
|
|
self.published_count = 0
|
|
self.skipped_count = 0
|
|
|
|
self.create_widgets()
|
|
|
|
def create_widgets(self):
|
|
"""Create GUI widgets"""
|
|
# Main notebook (tabs)
|
|
self.notebook = ttk.Notebook(self.root)
|
|
self.notebook.pack(fill='both', expand=True, padx=5, pady=5)
|
|
|
|
# Tab 1: Settings
|
|
settings_frame = ttk.Frame(self.notebook)
|
|
self.notebook.add(settings_frame, text='Settings')
|
|
self.create_settings_tab(settings_frame)
|
|
|
|
# Tab 2: Time Range
|
|
time_frame = ttk.Frame(self.notebook)
|
|
self.notebook.add(time_frame, text='Time Range')
|
|
self.create_time_tab(time_frame)
|
|
|
|
# Tab 3: Progress
|
|
progress_frame = ttk.Frame(self.notebook)
|
|
self.notebook.add(progress_frame, text='Progress')
|
|
self.create_progress_tab(progress_frame)
|
|
|
|
# Button bar at bottom
|
|
button_frame = ttk.Frame(self.root)
|
|
button_frame.pack(fill='x', padx=5, pady=5)
|
|
|
|
ttk.Button(button_frame, text='Start Publishing', command=self.start_publishing).pack(side='left', padx=2)
|
|
ttk.Button(button_frame, text='Stop', command=self.stop_publishing).pack(side='left', padx=2)
|
|
ttk.Button(button_frame, text='Exit', command=self.root.quit).pack(side='right', padx=2)
|
|
|
|
self.status_label = ttk.Label(button_frame, text='Ready', relief='sunken')
|
|
self.status_label.pack(side='right', fill='x', expand=True, padx=2)
|
|
|
|
def create_settings_tab(self, parent):
|
|
"""Create settings tab"""
|
|
main_frame = ttk.Frame(parent, padding=10)
|
|
main_frame.pack(fill='both', expand=True)
|
|
|
|
# CSV File Selection
|
|
ttk.Label(main_frame, text='CSV File:', font=('TkDefaultFont', 10, 'bold')).grid(row=0, column=0, sticky='w', pady=10)
|
|
frame = ttk.Frame(main_frame)
|
|
frame.grid(row=1, column=0, columnspan=2, sticky='ew', pady=(0, 20))
|
|
|
|
self.csv_file_var = tk.StringVar()
|
|
ttk.Entry(frame, textvariable=self.csv_file_var, width=50).pack(side='left', fill='x', expand=True)
|
|
ttk.Button(frame, text='Browse...', command=self.select_csv_file).pack(side='right', padx=5)
|
|
|
|
# Device ID
|
|
ttk.Label(main_frame, text='Device ID:', font=('TkDefaultFont', 10, 'bold')).grid(row=2, column=0, sticky='w', pady=5)
|
|
self.device_id_var = tk.StringVar(value='dd3-F19C')
|
|
ttk.Entry(main_frame, textvariable=self.device_id_var, width=30).grid(row=2, column=1, sticky='w', pady=5)
|
|
|
|
# MQTT Settings
|
|
ttk.Label(main_frame, text='MQTT Broker:', font=('TkDefaultFont', 10, 'bold')).grid(row=3, column=0, sticky='w', pady=5)
|
|
self.mqtt_broker_var = tk.StringVar(value='localhost')
|
|
ttk.Entry(main_frame, textvariable=self.mqtt_broker_var, width=30).grid(row=3, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(main_frame, text='Port:', font=('TkDefaultFont', 10)).grid(row=4, column=0, sticky='w', pady=5)
|
|
self.mqtt_port_var = tk.StringVar(value='1883')
|
|
ttk.Entry(main_frame, textvariable=self.mqtt_port_var, width=30).grid(row=4, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(main_frame, text='Username:', font=('TkDefaultFont', 10)).grid(row=5, column=0, sticky='w', pady=5)
|
|
self.mqtt_user_var = tk.StringVar()
|
|
ttk.Entry(main_frame, textvariable=self.mqtt_user_var, width=30).grid(row=5, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(main_frame, text='Password:', font=('TkDefaultFont', 10)).grid(row=6, column=0, sticky='w', pady=5)
|
|
self.mqtt_pass_var = tk.StringVar()
|
|
ttk.Entry(main_frame, textvariable=self.mqtt_pass_var, width=30, show='*').grid(row=6, column=1, sticky='w', pady=5)
|
|
|
|
# Publish Rate
|
|
ttk.Label(main_frame, text='Publish Rate (msg/sec):', font=('TkDefaultFont', 10)).grid(row=7, column=0, sticky='w', pady=5)
|
|
self.rate_var = tk.StringVar(value='5')
|
|
rate_spin = ttk.Spinbox(main_frame, from_=1, to=100, textvariable=self.rate_var, width=10)
|
|
rate_spin.grid(row=7, column=1, sticky='w', pady=5)
|
|
|
|
# Test Connection Button
|
|
ttk.Button(main_frame, text='Test MQTT Connection', command=self.test_connection).grid(row=8, column=0, columnspan=2, sticky='ew', pady=20)
|
|
|
|
# Configure grid weights
|
|
main_frame.columnconfigure(1, weight=1)
|
|
|
|
def create_time_tab(self, parent):
|
|
"""Create time range selection tab"""
|
|
main_frame = ttk.Frame(parent, padding=10)
|
|
main_frame.pack(fill='both', expand=True)
|
|
|
|
# Mode selection
|
|
ttk.Label(main_frame, text='Time Range Mode:', font=('TkDefaultFont', 10, 'bold')).pack(anchor='w', pady=10)
|
|
|
|
self.time_mode_var = tk.StringVar(value='manual')
|
|
|
|
ttk.Radiobutton(main_frame, text='Manual Selection', variable=self.time_mode_var,
|
|
value='manual', command=self.update_time_mode).pack(anchor='w', padx=20, pady=5)
|
|
|
|
if HAS_INFLUXDB:
|
|
ttk.Radiobutton(main_frame, text='Auto-Detect from InfluxDB', variable=self.time_mode_var,
|
|
value='influxdb', command=self.update_time_mode).pack(anchor='w', padx=20, pady=5)
|
|
|
|
# Manual time selection frame
|
|
self.manual_frame = ttk.LabelFrame(main_frame, text='Manual Time Range', padding=10)
|
|
self.manual_frame.pack(fill='x', padx=20, pady=10)
|
|
|
|
ttk.Label(self.manual_frame, text='Start Date (YYYY-MM-DD):').grid(row=0, column=0, sticky='w', pady=5)
|
|
self.from_date_var = tk.StringVar(value=(datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d'))
|
|
ttk.Entry(self.manual_frame, textvariable=self.from_date_var, width=30).grid(row=0, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(self.manual_frame, text='Start Time (HH:MM:SS):').grid(row=1, column=0, sticky='w', pady=5)
|
|
self.from_time_var = tk.StringVar(value='00:00:00')
|
|
ttk.Entry(self.manual_frame, textvariable=self.from_time_var, width=30).grid(row=1, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(self.manual_frame, text='End Date (YYYY-MM-DD):').grid(row=2, column=0, sticky='w', pady=5)
|
|
self.to_date_var = tk.StringVar(value=datetime.now().strftime('%Y-%m-%d'))
|
|
ttk.Entry(self.manual_frame, textvariable=self.to_date_var, width=30).grid(row=2, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(self.manual_frame, text='End Time (HH:MM:SS):').grid(row=3, column=0, sticky='w', pady=5)
|
|
self.to_time_var = tk.StringVar(value='23:59:59')
|
|
ttk.Entry(self.manual_frame, textvariable=self.to_time_var, width=30).grid(row=3, column=1, sticky='w', pady=5)
|
|
|
|
# InfluxDB frame
|
|
self.influxdb_frame = ttk.LabelFrame(main_frame, text='InfluxDB Settings', padding=10)
|
|
if self.time_mode_var.get() == 'influxdb':
|
|
self.influxdb_frame.pack(fill='x', padx=20, pady=10)
|
|
else:
|
|
self.influxdb_frame.pack_forget()
|
|
|
|
ttk.Label(self.influxdb_frame, text='InfluxDB URL:').grid(row=0, column=0, sticky='w', pady=5)
|
|
self.influx_url_var = tk.StringVar(value='http://localhost:8086')
|
|
ttk.Entry(self.influxdb_frame, textvariable=self.influx_url_var, width=30).grid(row=0, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(self.influxdb_frame, text='API Token:').grid(row=1, column=0, sticky='w', pady=5)
|
|
self.influx_token_var = tk.StringVar()
|
|
ttk.Entry(self.influxdb_frame, textvariable=self.influx_token_var, width=30, show='*').grid(row=1, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(self.influxdb_frame, text='Organization:').grid(row=2, column=0, sticky='w', pady=5)
|
|
self.influx_org_var = tk.StringVar()
|
|
ttk.Entry(self.influxdb_frame, textvariable=self.influx_org_var, width=30).grid(row=2, column=1, sticky='w', pady=5)
|
|
|
|
ttk.Label(self.influxdb_frame, text='Bucket:').grid(row=3, column=0, sticky='w', pady=5)
|
|
self.influx_bucket_var = tk.StringVar(value='smartmeter')
|
|
ttk.Entry(self.influxdb_frame, textvariable=self.influx_bucket_var, width=30).grid(row=3, column=1, sticky='w', pady=5)
|
|
|
|
# Info frame
|
|
info_frame = ttk.LabelFrame(main_frame, text='Info', padding=10)
|
|
info_frame.pack(fill='both', expand=True, padx=20, pady=10)
|
|
|
|
info_text = """Time format examples:
|
|
• 2026-03-01 (start of day)
|
|
• 2026-03-10 14:30:00 (specific time)
|
|
|
|
Manual mode: Select date range to republish
|
|
Auto-detect: Find gaps in InfluxDB automatically"""
|
|
|
|
ttk.Label(info_frame, text=info_text, justify='left').pack(anchor='w')
|
|
|
|
def create_progress_tab(self, parent):
|
|
"""Create progress tab"""
|
|
main_frame = ttk.Frame(parent, padding=10)
|
|
main_frame.pack(fill='both', expand=True)
|
|
|
|
# Progress bar
|
|
ttk.Label(main_frame, text='Publishing Progress:', font=('TkDefaultFont', 10, 'bold')).pack(anchor='w', pady=5)
|
|
self.progress_var = tk.DoubleVar()
|
|
self.progress_bar = ttk.Progressbar(main_frame, variable=self.progress_var, maximum=100)
|
|
self.progress_bar.pack(fill='x', pady=5)
|
|
|
|
# Stats frame
|
|
stats_frame = ttk.LabelFrame(main_frame, text='Statistics', padding=10)
|
|
stats_frame.pack(fill='x', pady=10)
|
|
|
|
self.stats_text = tk.StringVar(value='Published: 0\nSkipped: 0\nRate: 0 msg/sec')
|
|
ttk.Label(stats_frame, textvariable=self.stats_text, font=('TkDefaultFont', 10)).pack(anchor='w')
|
|
|
|
# Log output
|
|
ttk.Label(main_frame, text='Log Output:', font=('TkDefaultFont', 10, 'bold')).pack(anchor='w', pady=(10, 5))
|
|
self.log_text = scrolledtext.ScrolledText(main_frame, height=20, width=100, state='disabled')
|
|
self.log_text.pack(fill='both', expand=True)
|
|
|
|
def update_time_mode(self):
|
|
"""Update visibility of time selection frames"""
|
|
if self.time_mode_var.get() == 'manual':
|
|
self.manual_frame.pack(fill='x', padx=20, pady=10)
|
|
self.influxdb_frame.pack_forget()
|
|
else:
|
|
self.manual_frame.pack_forget()
|
|
self.influxdb_frame.pack(fill='x', padx=20, pady=10)
|
|
|
|
def select_csv_file(self):
|
|
"""Open file browser for CSV selection"""
|
|
filename = filedialog.askopenfilename(
|
|
title='Select CSV File',
|
|
filetypes=[('CSV files', '*.csv'), ('All files', '*.*')]
|
|
)
|
|
if filename:
|
|
self.csv_file_var.set(filename)
|
|
|
|
def log(self, message: str):
|
|
"""Add message to log"""
|
|
self.log_text.config(state='normal')
|
|
self.log_text.insert('end', message + '\n')
|
|
self.log_text.see('end')
|
|
self.log_text.config(state='disabled')
|
|
self.root.update()
|
|
|
|
def test_connection(self):
|
|
"""Test MQTT connection"""
|
|
broker = self.mqtt_broker_var.get()
|
|
port = int(self.mqtt_port_var.get())
|
|
user = self.mqtt_user_var.get() or None
|
|
password = self.mqtt_pass_var.get() or None
|
|
|
|
def test_thread():
|
|
self.status_label.config(text='Testing...')
|
|
self.log('Testing MQTT connection...')
|
|
|
|
client = mqtt.Client()
|
|
if user and password:
|
|
client.username_pw_set(user, password)
|
|
|
|
try:
|
|
client.connect(broker, port, keepalive=10)
|
|
client.loop_start()
|
|
time.sleep(2)
|
|
client.loop_stop()
|
|
client.disconnect()
|
|
self.log('✓ MQTT connection successful!')
|
|
self.status_label.config(text='Connection OK')
|
|
messagebox.showinfo('Success', 'MQTT connection test passed!')
|
|
except Exception as e:
|
|
self.log(f'✗ Connection failed: {e}')
|
|
self.status_label.config(text='Connection failed')
|
|
messagebox.showerror('Error', f'Connection failed:\n{e}')
|
|
|
|
thread = threading.Thread(target=test_thread, daemon=True)
|
|
thread.start()
|
|
|
|
def parse_time_input(self, date_str: str, time_str: str = '00:00:00') -> int:
|
|
"""Parse date/time input and return Unix timestamp"""
|
|
try:
|
|
dt_str = f"{date_str} {time_str}"
|
|
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
|
return int(dt.timestamp())
|
|
except ValueError as e:
|
|
raise ValueError(f'Invalid date/time format: {e}')
|
|
|
|
def get_time_range(self) -> Tuple[int, int]:
|
|
"""Get time range based on selected mode"""
|
|
if self.time_mode_var.get() == 'manual':
|
|
from_time = self.parse_time_input(self.from_date_var.get(), self.from_time_var.get())
|
|
to_time = self.parse_time_input(self.to_date_var.get(), self.to_time_var.get())
|
|
return from_time, to_time
|
|
else:
|
|
# InfluxDB mode
|
|
if not HAS_INFLUXDB:
|
|
raise RuntimeError('InfluxDB mode requires influxdb-client')
|
|
|
|
self.log('Connecting to InfluxDB...')
|
|
try:
|
|
client = InfluxDBClient(
|
|
url=self.influx_url_var.get(),
|
|
token=self.influx_token_var.get(),
|
|
org=self.influx_org_var.get()
|
|
)
|
|
query_api = client.query_api()
|
|
|
|
device_id = self.device_id_var.get()
|
|
bucket = self.influx_bucket_var.get()
|
|
|
|
# Query last 7 days
|
|
now = int(time.time())
|
|
from_time = now - (7 * 24 * 3600)
|
|
to_time = now
|
|
|
|
self.log(f'Searching for missing data from {datetime.fromtimestamp(from_time)} to {datetime.fromtimestamp(to_time)}')
|
|
|
|
query = f'''
|
|
from(bucket: "{bucket}")
|
|
|> range(start: {from_time}s, stop: {to_time}s)
|
|
|> filter(fn: (r) => r._measurement == "smartmeter" and r.device_id == "{device_id}")
|
|
|> keep(columns: ["_time"])
|
|
|> sort(columns: ["_time"])
|
|
'''
|
|
tables = query_api.query(query)
|
|
existing_times = []
|
|
|
|
for table in tables:
|
|
for record in table.records:
|
|
ts = int(record.values["_time"].timestamp())
|
|
existing_times.append(ts)
|
|
|
|
client.close()
|
|
|
|
if not existing_times:
|
|
self.log('No data in InfluxDB, will republish entire range')
|
|
return from_time, to_time
|
|
|
|
# Find first gap
|
|
existing_times = sorted(set(existing_times))
|
|
for i, ts in enumerate(existing_times):
|
|
if i > 0 and existing_times[i] - existing_times[i-1] > 60: # 60s gap
|
|
gap_start = existing_times[i-1]
|
|
gap_end = existing_times[i]
|
|
self.log(f'Found gap: {datetime.fromtimestamp(gap_start)} to {datetime.fromtimestamp(gap_end)}')
|
|
return gap_start, gap_end
|
|
|
|
self.log('No gaps found in InfluxDB')
|
|
return from_time, to_time
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f'InfluxDB error: {e}')
|
|
|
|
def republish_csv(self, csv_file: str, device_id: str, from_time: int, to_time: int):
|
|
"""Republish CSV data to MQTT"""
|
|
if not os.path.isfile(csv_file):
|
|
self.log(f'✗ File not found: {csv_file}')
|
|
return
|
|
|
|
count = 0
|
|
skipped = 0
|
|
start_time = time.time()
|
|
|
|
try:
|
|
with open(csv_file, 'r') as f:
|
|
reader = csv.DictReader(f)
|
|
if not reader.fieldnames:
|
|
self.log('✗ Invalid CSV: no header row')
|
|
return
|
|
|
|
for row in reader:
|
|
if not self.publishing:
|
|
self.log('Stopped by user')
|
|
break
|
|
|
|
try:
|
|
ts_utc = int(row['ts_utc'])
|
|
|
|
if ts_utc < from_time or ts_utc > to_time:
|
|
skipped += 1
|
|
continue
|
|
|
|
# Build payload
|
|
short_id = device_id[-4:].upper() if len(device_id) >= 4 else device_id.upper()
|
|
data = {'id': short_id, 'ts': ts_utc}
|
|
|
|
for key in ['e_kwh', 'p_w', 'p1_w', 'p2_w', 'p3_w', 'bat_v', 'bat_pct', 'rssi', 'snr']:
|
|
if key in row and row[key].strip():
|
|
try:
|
|
val = float(row[key]) if '.' in row[key] else int(row[key])
|
|
data[key] = val
|
|
except:
|
|
pass
|
|
|
|
# Publish
|
|
topic = f"smartmeter/{device_id}/state"
|
|
payload = json.dumps(data)
|
|
self.mqtt_client.publish(topic, payload)
|
|
|
|
count += 1
|
|
self.published_count = count
|
|
|
|
# Update UI
|
|
if count % 10 == 0:
|
|
elapsed = time.time() - start_time
|
|
rate = count / elapsed if elapsed > 0 else 0
|
|
self.stats_text.set(f'Published: {count}\nSkipped: {skipped}\nRate: {rate:.1f} msg/sec')
|
|
self.log(f'[{count:4d}] {ts_utc} {data.get("p_w", "?")}W')
|
|
|
|
# Rate limiting
|
|
time.sleep(1.0 / int(self.rate_var.get()))
|
|
|
|
except (ValueError, KeyError):
|
|
skipped += 1
|
|
continue
|
|
|
|
except Exception as e:
|
|
self.log(f'✗ Error: {e}')
|
|
|
|
elapsed = time.time() - start_time
|
|
self.log(f'✓ Completed! Published {count} samples in {elapsed:.1f}s')
|
|
self.published_count = count
|
|
|
|
def start_publishing(self):
|
|
"""Start republishing data"""
|
|
if not self.csv_file_var.get():
|
|
messagebox.showerror('Error', 'Please select a CSV file')
|
|
return
|
|
|
|
if not self.device_id_var.get():
|
|
messagebox.showerror('Error', 'Please enter device ID')
|
|
return
|
|
|
|
try:
|
|
port = int(self.mqtt_port_var.get())
|
|
except ValueError:
|
|
messagebox.showerror('Error', 'Invalid MQTT port')
|
|
return
|
|
|
|
try:
|
|
rate = int(self.rate_var.get())
|
|
if rate < 1 or rate > 100:
|
|
raise ValueError('Rate must be 1-100')
|
|
except ValueError:
|
|
messagebox.showerror('Error', 'Invalid publish rate')
|
|
return
|
|
|
|
self.publishing = True
|
|
self.log_text.config(state='normal')
|
|
self.log_text.delete('1.0', 'end')
|
|
self.log_text.config(state='disabled')
|
|
self.published_count = 0
|
|
|
|
def pub_thread():
|
|
try:
|
|
# Get time range
|
|
from_time, to_time = self.get_time_range()
|
|
self.log(f'Time range: {datetime.fromtimestamp(from_time)} to {datetime.fromtimestamp(to_time)}')
|
|
|
|
# Connect to MQTT
|
|
self.status_label.config(text='Connecting to MQTT...')
|
|
broker = self.mqtt_broker_var.get()
|
|
port = int(self.mqtt_port_var.get())
|
|
user = self.mqtt_user_var.get() or None
|
|
password = self.mqtt_pass_var.get() or None
|
|
|
|
self.mqtt_client = mqtt.Client()
|
|
if user and password:
|
|
self.mqtt_client.username_pw_set(user, password)
|
|
|
|
self.mqtt_client.connect(broker, port, keepalive=60)
|
|
self.mqtt_client.loop_start()
|
|
time.sleep(1)
|
|
|
|
self.log('✓ Connected to MQTT broker')
|
|
self.status_label.config(text='Publishing...')
|
|
|
|
# Republish
|
|
self.republish_csv(self.csv_file_var.get(), self.device_id_var.get(),
|
|
from_time, to_time)
|
|
|
|
self.mqtt_client.loop_stop()
|
|
self.mqtt_client.disconnect()
|
|
self.status_label.config(text='Done')
|
|
messagebox.showinfo('Success', f'Published {self.published_count} samples')
|
|
|
|
except Exception as e:
|
|
self.log(f'✗ Error: {e}')
|
|
self.status_label.config(text='Error')
|
|
messagebox.showerror('Error', str(e))
|
|
finally:
|
|
self.publishing = False
|
|
|
|
thread = threading.Thread(target=pub_thread, daemon=True)
|
|
thread.start()
|
|
|
|
def stop_publishing(self):
|
|
"""Stop publishing"""
|
|
self.publishing = False
|
|
self.status_label.config(text='Stopping...')
|
|
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
app = MQTTRepublisherGUI(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|