440 lines
20 KiB
C++
440 lines
20 KiB
C++
#include <WiFi.h>
|
||
#include <PubSubClient.h>
|
||
#include <ArduinoJson.h> // 用于JSON操作
|
||
|
||
// ----------- 设备配置 (需要为您自己的设备和环境修改) -----------
|
||
// WiFi
|
||
const char *ssid = "WEIHANG3718"; // 请输入您的 Wi-Fi 名称
|
||
const char *password = "@05Tu146"; // 请输入您的 Wi-Fi 密码
|
||
|
||
// MQTT Broker
|
||
const char *mqtt_broker = "yuyun-hk1.stormrain.cn"; // 您的 MQTT Broker 地址
|
||
const char *mqtt_username = "emqx"; // 您的 MQTT 用户名 (如果需要)
|
||
const char *mqtt_password = "public"; // 您的 MQTT 密码 (如果需要)
|
||
const int mqtt_port = 1883; // 您的 MQTT 端口
|
||
|
||
// 设备唯一标识符 (非常重要, 必须与后端注册的一致)
|
||
const char *spotUid = "ESP32_SPOT_001"; // 例如: "SPOT001", "P005-A1" 等
|
||
|
||
// ----------- MQTT 主题定义 -----------
|
||
// 基于 application.yml 和后端服务实现
|
||
String topic_uplink_to_backend; // 上报给后端: yupi_mqtt_power_project/robot/status/{spotUid}
|
||
String topic_downlink_from_backend; // 从后端接收指令: yupi_mqtt_power_project/robot/command/{spotUid}
|
||
|
||
// ----------- 全局变量 -----------
|
||
WiFiClient espClient;
|
||
PubSubClient client(espClient);
|
||
|
||
// 模拟硬件状态 (实际项目中需要从传感器或硬件逻辑获取)
|
||
const char* currentDeviceStatus = "IDLE"; // 设备当前状态: IDLE, CHARGING, COMPLETED, FAULTED 等
|
||
float currentVoltage = 220.0;
|
||
float currentCurrent = 0.0;
|
||
float currentPower = 0.0;
|
||
float currentEnergyConsumed = 0.0;
|
||
int currentErrorCode = 0;
|
||
String currentSessionId = ""; // 当前充电会话ID
|
||
String currentSimulatedLocation = "BASE_STATION"; // 新增:模拟当前位置
|
||
int currentSimulatedBattery = 95; // 新增:模拟当前电量
|
||
String currentTargetSpot = ""; // 新增:用于移动状态的目标车位
|
||
String currentSpotId = ""; // 新增:当前所在车位 (用于充电等状态)
|
||
unsigned long chargeStartTimeMillis = 0; // 新增:用于计算充电时长
|
||
|
||
// 定时发送相关
|
||
unsigned long lastStatusUpdateTime = 0;
|
||
unsigned long lastHeartbeatTime = 0;
|
||
const long statusUpdateInterval = 30000; // 状态上报间隔 (例如: 30秒)
|
||
const long heartbeatInterval = 60000; // 心跳间隔 (例如: 60秒)
|
||
unsigned long lastBatteryUpdateTime = 0; // 新增:用于模拟电池电量变化的时间戳
|
||
const long batteryUpdateInterval = 5000; // 新增:电池状态更新间隔(例如5秒)
|
||
|
||
void setup_mqtt_topics() {
|
||
String backend_status_base = "yupi_mqtt_power_project/robot/status/";
|
||
String backend_command_base = "yupi_mqtt_power_project/robot/command/";
|
||
|
||
topic_uplink_to_backend = backend_status_base + String(spotUid);
|
||
topic_downlink_from_backend = backend_command_base + String(spotUid);
|
||
|
||
Serial.println("MQTT 主题初始化完成 (匹配后端实现):");
|
||
Serial.println(" 上行主题 (状态/心跳/ACK): " + topic_uplink_to_backend);
|
||
Serial.println(" 下行主题 (接收指令): " + topic_downlink_from_backend);
|
||
}
|
||
|
||
void connect_wifi() {
|
||
Serial.println("正在连接 Wi-Fi...");
|
||
WiFi.begin(ssid, password);
|
||
while (WiFi.status() != WL_CONNECTED) {
|
||
delay(500);
|
||
Serial.print(".");
|
||
}
|
||
Serial.println("\nWi-Fi 连接成功!");
|
||
Serial.print("IP 地址: ");
|
||
Serial.println(WiFi.localIP());
|
||
}
|
||
|
||
void reconnect_mqtt() {
|
||
while (!client.connected()) {
|
||
Serial.print("尝试连接 MQTT Broker: ");
|
||
Serial.println(mqtt_broker);
|
||
String client_id = "esp32-client-" + String(spotUid) + "-";
|
||
client_id += String(WiFi.macAddress());
|
||
Serial.printf("客户端 ID: %s \n", client_id.c_str());
|
||
|
||
if (client.connect(client_id.c_str(), mqtt_username, mqtt_password)) {
|
||
Serial.println("MQTT Broker 连接成功!");
|
||
// 订阅唯一下行指令主题
|
||
client.subscribe(topic_downlink_from_backend.c_str());
|
||
Serial.println("已订阅指令主题: " + topic_downlink_from_backend);
|
||
|
||
publish_status_update(false, nullptr, nullptr, nullptr, nullptr, nullptr); // 连接成功后发送一次常规状态更新
|
||
} else {
|
||
Serial.print("连接失败, rc=");
|
||
Serial.print(client.state());
|
||
Serial.println(" 2秒后重试...");
|
||
delay(2000);
|
||
}
|
||
}
|
||
}
|
||
|
||
void callback(char *topic, byte *payload, unsigned int length) {
|
||
Serial.println("-----------------------");
|
||
Serial.print("消息抵达, 主题: ");
|
||
Serial.println(topic);
|
||
|
||
char message[length + 1];
|
||
memcpy(message, payload, length);
|
||
message[length] = '\0';
|
||
Serial.print("消息内容: ");
|
||
Serial.println(message);
|
||
|
||
if (String(topic) != topic_downlink_from_backend) {
|
||
Serial.println("消息非来自预期的指令主题,忽略。");
|
||
return;
|
||
}
|
||
|
||
StaticJsonDocument<256> doc;
|
||
DeserializationError error = deserializeJson(doc, message);
|
||
|
||
if (error) {
|
||
Serial.print("JSON 解析失败: ");
|
||
Serial.println(error.f_str());
|
||
return;
|
||
}
|
||
|
||
const char* cmdType = nullptr;
|
||
if (doc.containsKey("commandType")) {
|
||
cmdType = doc["commandType"];
|
||
} else if (doc.containsKey("command")) {
|
||
cmdType = doc["command"];
|
||
}
|
||
const char* taskId = doc["taskId"];
|
||
|
||
if (cmdType == nullptr || taskId == nullptr) {
|
||
Serial.println("指令JSON缺少 commandType/command 或 taskId 字段。");
|
||
publish_ack_message(0, "指令解析失败", false, "Command JSON invalid (missing commandType/command or taskId)");
|
||
return;
|
||
}
|
||
|
||
const char* spotIdFromCommand = nullptr;
|
||
if (doc.containsKey("spotId")) {
|
||
spotIdFromCommand = doc["spotId"].as<const char*>();
|
||
} else if (doc.containsKey("target_spot_uid")) {
|
||
spotIdFromCommand = doc["target_spot_uid"].as<const char*>();
|
||
}
|
||
|
||
if (strcmp(cmdType, "MOVE_TO_SPOT") == 0 || strcmp(cmdType, "MOVE") == 0) {
|
||
Serial.println("接收到 [移动] 指令 (MOVE_TO_SPOT 或 MOVE)");
|
||
if (spotIdFromCommand) {
|
||
currentTargetSpot = String(spotIdFromCommand);
|
||
currentSpotId = "";
|
||
} else {
|
||
currentTargetSpot = "UNKNOWN_SPOT";
|
||
}
|
||
|
||
currentDeviceStatus = "MOVING";
|
||
currentSimulatedLocation = "EN_ROUTE_TO_" + currentTargetSpot;
|
||
Serial.println("状态更新: MOVING (前往目标车位: " + currentTargetSpot + ")");
|
||
publish_status_update(false, nullptr, nullptr, nullptr, nullptr, nullptr);
|
||
|
||
Serial.println("模拟: 机器人正在移动到目标车位 " + currentTargetSpot + "...");
|
||
// 模拟移动过程中的电量消耗,更细致的可以在 loop 中做
|
||
if (currentSimulatedBattery > 10) currentSimulatedBattery -= 5; // 假设移动固定消耗一些电
|
||
else currentSimulatedBattery = 5; // 最低电量
|
||
|
||
delay(3000);
|
||
Serial.println("模拟: 机器人已到达目标车位 " + currentTargetSpot + "。");
|
||
|
||
currentDeviceStatus = "CHARGING";
|
||
currentSpotId = currentTargetSpot;
|
||
currentTargetSpot = "";
|
||
currentSimulatedLocation = currentSpotId;
|
||
chargeStartTimeMillis = millis();
|
||
Serial.println("状态更新: CHARGING (已到达目标车位 " + currentSpotId + ",视为开始充电)");
|
||
|
||
publish_ack_message(taskId, "移动到指定点", true, "Robot arrived at spot and started charging (simulated)");
|
||
|
||
publish_status_update(false, nullptr, nullptr, nullptr, nullptr, nullptr);
|
||
|
||
} else if (strcmp(cmdType, "START_CHARGE") == 0) {
|
||
Serial.println("接收到 [启动充电] 指令");
|
||
if (strcmp(currentDeviceStatus, "CHARGING") != 0) { // 仅当未在充电时才响应
|
||
currentDeviceStatus = "CHARGING";
|
||
if (spotIdFromCommand) {
|
||
currentSpotId = String(spotIdFromCommand);
|
||
} else if (currentSpotId.length() == 0) {
|
||
currentSpotId = "DEFAULT_SPOT"; // 如果没有从指令获取且之前也没有,给个默认值
|
||
}
|
||
currentSimulatedLocation = currentSpotId;
|
||
chargeStartTimeMillis = millis();
|
||
Serial.println("状态更新: CHARGING (指令启动于 " + currentSpotId + ")");
|
||
} else {
|
||
Serial.println("设备已在充电中,忽略 START_CHARGE 指令。");
|
||
}
|
||
|
||
if (doc.containsKey("sessionId")) {
|
||
currentSessionId = String(doc["sessionId"].as<const char*>());
|
||
} else {
|
||
currentSessionId = "";
|
||
}
|
||
Serial.println("模拟: 充电已启动。会话ID: " + currentSessionId + ", 车位: " + currentSpotId);
|
||
publish_ack_message(taskId, "开始充电", true, "Charging started successfully");
|
||
publish_status_update(false, nullptr, nullptr, nullptr, nullptr, nullptr);
|
||
|
||
} else if (strcmp(cmdType, "STOP_CHARGE") == 0) {
|
||
Serial.println("接收到 [停止充电] 指令");
|
||
bool wasCharging = strcmp(currentDeviceStatus, "CHARGING") == 0;
|
||
currentDeviceStatus = "COMPLETED";
|
||
currentSimulatedLocation = currentSpotId;
|
||
unsigned long chargeDuration = 0;
|
||
if (chargeStartTimeMillis > 0 && wasCharging) {
|
||
chargeDuration = (millis() - chargeStartTimeMillis) / 1000;
|
||
}
|
||
chargeStartTimeMillis = 0;
|
||
Serial.println("模拟: 充电已停止。车位: " + currentSpotId + ", 本次充电时长约: " + String(chargeDuration) + "s");
|
||
String previousSessionId = currentSessionId;
|
||
currentSessionId = "";
|
||
// 在ACK中上报准确的充电时长,如果需要的话,可以通过修改 publish_ack_message 或在 message 字段中添加
|
||
// For now, the generic ACK is sent.
|
||
publish_ack_message(taskId, "停止充电", true, ("Charging stopped. Duration: " + String(chargeDuration) + "s").c_str(), currentEnergyConsumed, chargeDuration);
|
||
publish_status_update(false, nullptr, nullptr, nullptr, nullptr, nullptr);
|
||
}
|
||
// Add other commandType handling here if needed, e.g., "QUERY_STATUS"
|
||
// else if (strcmp(cmdType, "QUERY_STATUS") == 0) {
|
||
// Serial.println("接收到 [查询状态] 指令");
|
||
// publish_regular_status_update(); // 回复当前状态
|
||
// publish_ack_message(taskId, true, "Status reported", nullptr);
|
||
// }
|
||
else {
|
||
Serial.println("未知指令 commandType: " + String(cmdType));
|
||
publish_ack_message(taskId, "未知指令", false, ("Unknown commandType: " + String(cmdType)).c_str());
|
||
}
|
||
Serial.println("-----------------------");
|
||
}
|
||
|
||
void publish_message(const String& topic, const JsonDocument& doc, const char* message_type) {
|
||
String jsonBuffer;
|
||
serializeJson(doc, jsonBuffer);
|
||
Serial.print("发送 ");
|
||
Serial.print(message_type);
|
||
Serial.print(" 到主题 [");
|
||
Serial.print(topic);
|
||
Serial.print("]: ");
|
||
Serial.println(jsonBuffer);
|
||
|
||
if (client.publish(topic.c_str(), jsonBuffer.c_str())) {
|
||
Serial.println(String(message_type) + " 发送成功");
|
||
} else {
|
||
Serial.println(String(message_type) + " 发送失败");
|
||
}
|
||
}
|
||
|
||
// isAckOrTaskUpdate: true if this is an ACK or a task-specific update, false for general status/heartbeat
|
||
// ackTaskId: The taskId if this is an ACK for a command. Null otherwise.
|
||
void publish_status_update(bool isAckOrTaskUpdate, const char* ackTaskId, const char* ackStatus, const char* ackMessage, const char* ackErrorCode, const char* ackSessionId) {
|
||
StaticJsonDocument<512> doc;
|
||
doc["robotUid"] = spotUid;
|
||
|
||
if (isAckOrTaskUpdate) {
|
||
if (ackTaskId) doc["taskId"] = ackTaskId;
|
||
if (ackStatus) doc["status"] = ackStatus;
|
||
if (ackMessage) doc["message"] = ackMessage;
|
||
// 根据用户要求,ACK中不发送errorCode
|
||
// if (ackErrorCode) doc["errorCode"] = ackErrorCode;
|
||
|
||
doc["actualRobotStatus"] = currentDeviceStatus;
|
||
if (ackSessionId && strlen(ackSessionId) > 0) {
|
||
// For ACKs, if we have a sessionId or a spotId that's relevant for context, we can add it.
|
||
// Let's use "spotId" as suggested by requirements for charging related ACKs
|
||
if (strcmp(currentDeviceStatus, "CHARGING") == 0 || strcmp(currentDeviceStatus, "COMPLETED") == 0 || (ackMessage && strstr(ackMessage, "arrived"))) {
|
||
if(currentSpotId.length() > 0) doc["spotId"] = currentSpotId;
|
||
}
|
||
}
|
||
|
||
} else { // General status update / heartbeat
|
||
doc["actualRobotStatus"] = currentDeviceStatus;
|
||
// Add fields as per requirements.md
|
||
doc["location"] = currentSimulatedLocation;
|
||
doc["battery"] = currentSimulatedBattery; // Example value, should be updated by a battery sim function
|
||
// doc["errorCode"] = currentErrorCode; // 根据用户要求,常规状态下不发送errorCode
|
||
|
||
// Fields specific to certain statuses
|
||
if (strcmp(currentDeviceStatus, "MOVING") == 0) {
|
||
if (currentTargetSpot.length() > 0) doc["targetSpot"] = currentTargetSpot;
|
||
} else if (strcmp(currentDeviceStatus, "CHARGING") == 0) {
|
||
if (currentSpotId.length() > 0) doc["spotId"] = currentSpotId;
|
||
if (chargeStartTimeMillis > 0) {
|
||
doc["durationSeconds"] = (millis() - chargeStartTimeMillis) / 1000;
|
||
} else {
|
||
doc["durationSeconds"] = 0;
|
||
}
|
||
} else if (strcmp(currentDeviceStatus, "COMPLETED") == 0) {
|
||
if (currentSpotId.length() > 0) doc["spotId"] = currentSpotId;
|
||
// totalDurationSeconds would typically be set upon actual completion event,
|
||
// for now, a regular status update might not have final total, or we can omit.
|
||
// Let's assume for now that if status is COMPLETED, we send a placeholder or last known duration.
|
||
// A more robust solution is needed for totalDurationSeconds.
|
||
// For now, publish_ack_message for STOP_CHARGE should probably carry the final duration.
|
||
} else if (strcmp(currentDeviceStatus, "ERROR") == 0) {
|
||
// errorCode is already included. message for error could be added if available.
|
||
// doc["message"] = "Simulated error description"; // if we have one
|
||
}
|
||
}
|
||
// Common fields (timestamp can be added by backend or here if NTP is used)
|
||
// doc["timestamp"] = String(millis()); // Already using millis()
|
||
|
||
publish_message(topic_uplink_to_backend, doc, isAckOrTaskUpdate ? "ACK/TaskUpdate" : "StatusUpdate");
|
||
if (!isAckOrTaskUpdate) { // Only update lastStatusUpdateTime for general status updates, not for ACKs triggered by commands
|
||
lastStatusUpdateTime = millis();
|
||
}
|
||
}
|
||
|
||
void publish_regular_status_update() {
|
||
// This is a wrapper for general periodic status updates
|
||
publish_status_update(false, nullptr, nullptr, nullptr, nullptr, nullptr);
|
||
}
|
||
|
||
void publish_heartbeat() {
|
||
StaticJsonDocument<256> doc; // Heartbeat can be simpler
|
||
doc["robotUid"] = spotUid;
|
||
doc["actualRobotStatus"] = currentDeviceStatus; // Heartbeat includes current status
|
||
// Optionally, add a specific "messageType": "HEARTBEAT" if backend needs explicit differentiation
|
||
// beyond just a minimal status update. For now, relying on RobotStatusMessage structure.
|
||
|
||
publish_message(topic_uplink_to_backend, doc, "Heartbeat");
|
||
lastHeartbeatTime = millis();
|
||
}
|
||
|
||
// 新增ACK消息发布函数,以符合后端期望的格式
|
||
void publish_ack_message(long taskId, const char* commandAckStr, bool success, const char* message, float energyKwh = -1.0f, int durationSeconds = -1) {
|
||
StaticJsonDocument<256> doc; // 调整大小以适应所有字段
|
||
|
||
doc["robotUid"] = spotUid;
|
||
doc["command_ack"] = commandAckStr; // 指令的中文描述
|
||
doc["task_id"] = taskId; // 任务ID (数字类型)
|
||
doc["success"] = success; // 成功状态 (布尔类型)
|
||
|
||
if (message && strlen(message) > 0) {
|
||
doc["message"] = message;
|
||
}
|
||
|
||
// 针对 STOP_CHARGE 指令的额外字段
|
||
if (strcmp(commandAckStr, "停止充电") == 0) {
|
||
if (energyKwh >= 0) {
|
||
doc["energy_kwh"] = energyKwh;
|
||
}
|
||
if (durationSeconds >= 0) {
|
||
doc["duration_s"] = durationSeconds;
|
||
}
|
||
}
|
||
|
||
publish_message(topic_uplink_to_backend, doc, "ACK");
|
||
}
|
||
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
Serial.println("\nESP32 充电桩模拟客户端启动...");
|
||
|
||
setup_mqtt_topics(); // 初始化MQTT主题
|
||
connect_wifi(); // 连接Wi-Fi
|
||
|
||
client.setServer(mqtt_broker, mqtt_port);
|
||
client.setCallback(callback); // 设置消息回调函数
|
||
}
|
||
|
||
void loop() {
|
||
if (!client.connected()) {
|
||
reconnect_mqtt();
|
||
}
|
||
client.loop();
|
||
|
||
unsigned long currentTime = millis();
|
||
|
||
if (currentTime - lastStatusUpdateTime > statusUpdateInterval) {
|
||
publish_regular_status_update(); // Use the new wrapper
|
||
}
|
||
|
||
if (currentTime - lastHeartbeatTime > heartbeatInterval) {
|
||
publish_heartbeat(); // Uses the new heartbeat logic
|
||
}
|
||
|
||
// 模拟充电过程中的电量和功率变化 (仅为演示)
|
||
if (String(currentDeviceStatus) == "CHARGING") {
|
||
currentEnergyConsumed += 0.01; // 假设每秒消耗0.01kWh (不精确,仅为演示)
|
||
currentCurrent = 5.0; // 假设充电电流5A
|
||
currentPower = (currentVoltage * currentCurrent) / 1000.0; // kW
|
||
// 注意:实际项目中这些值应来自传感器或充电控制器
|
||
// 这里为了演示,每隔一段时间简单更新一下
|
||
} else {
|
||
currentCurrent = 0.0;
|
||
currentPower = 0.0;
|
||
}
|
||
delay(100); // 短暂延时,避免loop过于频繁,给其他任务一点时间 (可选)
|
||
|
||
// 模拟电量消耗和位置变化 (更符合需求文档)
|
||
if (strcmp(currentDeviceStatus, "CHARGING") == 0) {
|
||
if (currentSimulatedBattery > 0) { // 充电时电量可以缓慢增加,或保持不变,取决于模拟逻辑
|
||
// currentSimulatedBattery = min(100, currentSimulatedBattery + 1); // 简单模拟充电增加
|
||
}
|
||
// durationSeconds is calculated in publish_status_update
|
||
} else if (strcmp(currentDeviceStatus, "MOVING") == 0) {
|
||
if (currentSimulatedBattery > 5) { // 移动时消耗电量
|
||
// currentSimulatedBattery--; // 简单模拟电量消耗
|
||
}
|
||
} else { // IDLE, COMPLETED, ERROR
|
||
//电量可能不变或缓慢消耗
|
||
}
|
||
|
||
// 简单模拟位置更新 (可以更复杂)
|
||
// currentSimulatedLocation = ... ; // 可以在特定事件中更新
|
||
|
||
// --- 动态模拟数据更新 ---
|
||
if (currentTime - lastBatteryUpdateTime > batteryUpdateInterval) {
|
||
if (strcmp(currentDeviceStatus, "CHARGING") == 0) {
|
||
if (currentSimulatedBattery < 100) {
|
||
currentSimulatedBattery += 1; // 每 batteryUpdateInterval 增加 1% 电量
|
||
Serial.println("模拟: 电量增加至 " + String(currentSimulatedBattery) + "%");
|
||
} else {
|
||
currentSimulatedBattery = 100; // 防止超过100
|
||
}
|
||
} else if (strcmp(currentDeviceStatus, "MOVING") == 0) {
|
||
if (currentSimulatedBattery > 2) {
|
||
currentSimulatedBattery -= 2; // 每 batteryUpdateInterval 消耗 2% 电量
|
||
Serial.println("模拟: 电量消耗至 " + String(currentSimulatedBattery) + "%");
|
||
} else {
|
||
currentSimulatedBattery = 2; // 防止低于2,极端情况
|
||
}
|
||
} else { // IDLE, COMPLETED, FAULTED
|
||
if (currentSimulatedBattery > 1) {
|
||
// 非常缓慢的自然消耗,或者不消耗
|
||
// currentSimulatedBattery -= 1;
|
||
}
|
||
}
|
||
lastBatteryUpdateTime = currentTime;
|
||
}
|
||
|
||
// 位置模拟: 通常在状态转换时(callback中)已经更新了主要位置。
|
||
// loop中可以添加更细致的移动中的位置更新,但目前保持简单,依赖callback中的设定。
|
||
// 例如: if (strcmp(currentDeviceStatus, "MOVING") == 0) { /* update location based on time/progress */ }
|
||
|
||
delay(100);
|
||
}
|