v1.2.0: Firmware refactor to service-oriented architecture + WiFi fixes

Firmware:
- Fixed WiFi reconnection bug after power cycle by adding WiFi.persistent(false)
  and disabling auto-connect/auto-reconnect
- Fixed AP mode not broadcasting by properly handling WIFI_AP_STA mode transitions
- Refactored to service-oriented architecture:
  - device_state: Central state management with event callbacks
  - config_service: NVS persistence (renamed from config_manager)
  - wifi_service: WiFi STA/AP management (extracted from main.cpp)
  - mqtt_service: MQTT client and message routing (extracted from main.cpp)
  - ble_service: BLE GATT services (renamed from ble_services)
  - web_service: HTTP configuration portal (renamed from web_portal)
- main.cpp reduced from ~470 lines to ~60 lines (thin orchestrator)
- Each service is self-contained with setup()/loop() pattern

Backend:
- Added heart rate variation endpoint
- UI improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 16:49:54 -05:00
parent 9b740ebdd0
commit c95cd33343
22 changed files with 1402 additions and 956 deletions

View File

@@ -0,0 +1,203 @@
#include "services/mqtt_service.h"
#include "services/config_service.h"
#include "services/wifi_service.h"
#include "services/ble_service.h"
#include "device_state.h"
#include "config.h"
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
// MQTT client (needs WiFiClient)
static WiFiClient wifiClient;
static PubSubClient mqtt(wifiClient);
MqttService& MqttService::getInstance() {
static MqttService instance;
return instance;
}
void MqttService::messageCallback(char* topic, byte* payload, unsigned int length) {
getInstance().handleMessage(topic, payload, length);
}
void MqttService::setup() {
mqtt.setCallback(messageCallback);
mqtt.setBufferSize(512);
}
void MqttService::loop() {
connectToMQTT();
mqtt.loop();
// Periodic status report
if (mqttConnected && millis() - lastStatusReport >= STATUS_REPORT_INTERVAL) {
lastStatusReport = millis();
publishStatus();
}
}
void MqttService::connectToMQTT() {
if (!configService.isConfigured() || !wifiService.isConnected()) {
return;
}
if (mqtt.connected()) {
if (!mqttConnected) {
mqttConnected = true;
deviceState.setMqttConnected(true);
}
return;
}
// Don't spam connection attempts
if (millis() - lastMqttAttempt < MQTT_RECONNECT_INTERVAL) {
return;
}
lastMqttAttempt = millis();
mqttConnected = false;
deviceState.setMqttConnected(false);
mqtt.setServer(
configService.getMqttHost().c_str(),
configService.getMqttPort()
);
Serial.print("Connecting to MQTT at ");
Serial.print(configService.getMqttHost());
Serial.print(":");
Serial.println(configService.getMqttPort());
String clientId = String("esp32-") + String(random(0xffff), HEX);
// LWT for disconnect detection
String statusTopic = String("ble-sim/") + configService.getDeviceId() + "/status";
String willMessage = "{\"online\":false}";
if (mqtt.connect(clientId.c_str(), statusTopic.c_str(), 1, true, willMessage.c_str())) {
mqttConnected = true;
deviceState.setMqttConnected(true);
Serial.println("MQTT connected with LWT!");
// Subscribe to control topics
String configTopic = String("ble-sim/") + configService.getDeviceId() + "/config";
String setTopic = String("ble-sim/") + configService.getDeviceId() + "/set";
mqtt.subscribe(configTopic.c_str());
mqtt.subscribe(setTopic.c_str());
Serial.print("Subscribed to: ");
Serial.println(configTopic);
publishStatus();
} else {
Serial.print("MQTT connection failed, rc=");
Serial.println(mqtt.state());
}
}
void MqttService::handleMessage(char* topic, byte* payload, unsigned int length) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload, length);
if (error) {
Serial.print("JSON parse error: ");
Serial.println(error.c_str());
return;
}
String topicStr = String(topic);
String baseTopic = String("ble-sim/") + configService.getDeviceId();
// Handle device type configuration
if (topicStr == baseTopic + "/config") {
String type = doc["type"] | "";
Serial.print("Configuring as: ");
Serial.println(type);
if (type == "heart_rate") {
deviceState.setDeviceType(DeviceType::HEART_RATE);
bleService.setupHeartRate();
} else if (type == "treadmill") {
deviceState.setDeviceType(DeviceType::TREADMILL);
bleService.setupTreadmill();
} else {
deviceState.setDeviceType(DeviceType::NONE);
bleService.stop();
}
publishStatus();
}
// Handle value updates
else if (topicStr == baseTopic + "/set") {
if (doc["heart_rate"].is<int>()) {
deviceState.setHeartRate(doc["heart_rate"]);
Serial.print("Heart rate set to: ");
Serial.println(deviceState.getValues().heartRate);
}
if (doc["battery"].is<int>()) {
deviceState.setBatteryLevel(doc["battery"]);
bleService.updateBattery(deviceState.getValues().batteryLevel);
Serial.print("Battery level set to: ");
Serial.println(deviceState.getValues().batteryLevel);
}
if (doc["speed"].is<float>()) {
deviceState.setTreadmillSpeed(doc["speed"]);
Serial.print("Speed set to: ");
Serial.println(doc["speed"].as<float>());
}
if (doc["incline"].is<float>()) {
deviceState.setTreadmillIncline(doc["incline"]);
Serial.print("Incline set to: ");
Serial.println(doc["incline"].as<float>());
}
if (doc["distance"].is<int>()) {
deviceState.setTreadmillDistance(doc["distance"]);
Serial.print("Distance set to: ");
Serial.println(deviceState.getValues().treadmillDistance);
}
publishValues();
}
}
void MqttService::publishStatus() {
if (!mqtt.connected()) return;
JsonDocument doc;
doc["online"] = true;
doc["firmware_version"] = FIRMWARE_VERSION;
doc["type"] = deviceState.getDeviceTypeString();
doc["ble_started"] = deviceState.isBleStarted();
doc["ip"] = wifiService.getIP();
String payload;
serializeJson(doc, payload);
String topic = String("ble-sim/") + configService.getDeviceId() + "/status";
mqtt.publish(topic.c_str(), payload.c_str(), true);
}
void MqttService::publishValues() {
if (!mqtt.connected()) return;
JsonDocument doc;
const auto& values = deviceState.getValues();
if (deviceState.getDeviceType() == DeviceType::HEART_RATE) {
doc["heart_rate"] = values.heartRate;
doc["battery"] = values.batteryLevel;
} else if (deviceState.getDeviceType() == DeviceType::TREADMILL) {
doc["speed"] = values.treadmillSpeed / 100.0;
doc["incline"] = values.treadmillIncline / 10.0;
doc["distance"] = values.treadmillDistance;
}
String payload;
serializeJson(doc, payload);
String topic = String("ble-sim/") + configService.getDeviceId() + "/values";
mqtt.publish(topic.c_str(), payload.c_str());
}