diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java new file mode 100644 index 00000000..1730cc54 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java @@ -0,0 +1,74 @@ +package xiaozhi.modules.device.controller; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import xiaozhi.common.utils.Result; +import xiaozhi.modules.device.dto.DeviceReportReqDTO; +import xiaozhi.modules.device.dto.DeviceReportRespDTO; +import xiaozhi.modules.device.service.DeviceService; +import xiaozhi.modules.device.utils.NetworkUtil; + +import java.nio.charset.StandardCharsets; + +@Tag(name = "设备管理", description = "OTA 相关接口") +@RestController +@RequiredArgsConstructor +@RequestMapping("/ota") +public class OTAController +{ + private final DeviceService deviceService; + + @Operation(summary = "检查 OTA 版本和设备激活状态") + @PostMapping + public ResponseEntity checkOTAVersion( + @RequestBody DeviceReportReqDTO deviceReportReqDTO, + + @Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) + @RequestHeader("Device-Id") + String deviceId, + + @Parameter(name = "Client-Id", description = "客户端标识", required = true, in = ParameterIn.HEADER) + @RequestHeader("Client-Id") + String clientId + ) { + if (StringUtils.isAnyBlank(deviceId, clientId)) { + return createResponse(DeviceReportRespDTO.createError("Device ID is required")); + } + String macAddress = deviceReportReqDTO.getMacAddress(); + boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress); + // 设备Id和Mac地址应是一致的, 并且必须需要application字段 + if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) { + return createResponse(DeviceReportRespDTO.createError("Invalid OTA request")); + } + return createResponse(deviceService.checkDeviceActive(macAddress, deviceId, clientId, deviceReportReqDTO)); + } + + @Operation(summary = "设备激活") + @GetMapping("/activation") + public Result deviceActivation(@RequestParam String code) { + return new Result().ok(deviceService.deviceActivation(code)); + } + + @SneakyThrows + private ResponseEntity createResponse(DeviceReportRespDTO deviceReportRespDTO) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + String json = objectMapper.writeValueAsString(deviceReportRespDTO); + byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); + return ResponseEntity + .ok() + .contentType(MediaType.APPLICATION_JSON) + .contentLength(jsonBytes.length) + .body(json); + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportReqDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportReqDTO.java new file mode 100644 index 00000000..a44f8a77 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportReqDTO.java @@ -0,0 +1,149 @@ +package xiaozhi.modules.device.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; +import java.util.List; + + +@Setter +@Getter +@Schema(description = "设备固件信息上报求请求体") +public class DeviceReportReqDTO implements Serializable { + private static final long serialVersionUID = 1L; + // region 实体属性 + @Schema(description = "板子固件版本号") + private Integer version; + + @Schema(description = "闪存大小(单位:字节)") + @JsonProperty("flash_size") + private Integer flashSize; + + @Schema(description = "最小空闲堆内存(字节)") + @JsonProperty("minimum_free_heap_size") + private Integer minimumFreeHeapSize; + + @Schema(description = "设备 MAC 地址") + @JsonProperty("mac_address") + private String macAddress; + + @Schema(description = "设备唯一标识 UUID") + private String uuid; + + @Schema(description = "芯片型号名称") + @JsonProperty("chip_model_name") + private String chipModelName; + + @Schema(description = "芯片详细信息") + @JsonProperty("chip_info") + private ChipInfo chipInfo; + + @Schema(description = "应用程序信息") + private Application application; + + @Schema(description = "分区表列表") + @JsonProperty("partition_table") + private List partitionTable; + + @Schema(description = "当前运行的 OTA 分区信息") + private OtaInfo ota; + + @Schema(description = "板子配置信息") + private BoardInfo board; + + // endregion + + @Getter + @Setter + @Schema(description = "芯片信息") + public static class ChipInfo { + @Schema(description = "芯片模型代码") + private Integer model; + + @Schema(description = "核心数") + private Integer cores; + + @Schema(description = "硬件修订版本") + private Integer revision; + + @Schema(description = "芯片功能标志位") + private Integer features; + } + + @Getter + @Setter + @Schema(description = "板子编译信息") + public static class Application { + @Schema(description = "名称") + private String name; + + @Schema(description = "应用版本号") + private String version; + + @Schema(description = "编译时间(UTC ISO格式)") + @JsonProperty("compile_time") + private String compileTime; + + @Schema(description = "ESP-IDF 版本号") + @JsonProperty("idf_version") + private String idfVersion; + + @Schema(description = "ELF 文件 SHA256 校验") + @JsonProperty("elf_sha256") + private String elfSha256; + } + + @Getter + @Setter + @Schema(description = "分区信息") + public static class Partition { + @Schema(description = "分区标签名") + private String label; + + @Schema(description = "分区类型") + private Integer type; + + @Schema(description = "子类型") + private Integer subtype; + + @Schema(description = "起始地址") + private Integer address; + + @Schema(description = "分区大小") + private Integer size; + } + + @Getter + @Setter + @Schema(description = "OTA信息") + public static class OtaInfo { + @Schema(description = "当前OTA标签") + private String label; + } + + @Getter + @Setter + @Schema(description = "板子连接和网络信息") + public static class BoardInfo { + @Schema(description = "板子类型") + private String type; + + @Schema(description = "连接的 Wi-Fi SSID") + private String ssid; + + @Schema(description = "Wi-Fi 信号强度(RSSI)") + private Integer rssi; + + @Schema(description = "Wi-Fi 信道") + private Integer channel; + + @Schema(description = "IP 地址") + private String ip; + + @Schema(description = "MAC 地址") + private String mac; + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportRespDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportRespDTO.java new file mode 100644 index 00000000..5845b687 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportRespDTO.java @@ -0,0 +1,64 @@ +package xiaozhi.modules.device.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +@Data +@Schema(description = "设备OTA检测版本返回体,包含激活码要求") +public class DeviceReportRespDTO +{ + @Schema(description = "服务器时间") + private ServerTime serverTime; + + @Schema(description = "激活码") + private Activation activation; + + @Schema(description = "错误信息") + private String error; + + @Schema(description = "固件版本信息") + private Firmware firmware; + + @Getter + @Setter + public static class Firmware { + @Schema(description = "版本号") + private String version; + @Schema(description = "下载地址") + private String url; + } + + public static DeviceReportRespDTO createError(String message) { + DeviceReportRespDTO resp = new DeviceReportRespDTO(); + resp.setError(message); + return resp; + } + + @Setter + @Getter + public static class Activation + { + @Schema(description = "激活码") + private String code; + + @Schema(description = "激活码信息: 激活地址") + private String message; + + } + + @Getter + @Setter + public static class ServerTime + { + @Schema(description = "时间戳") + private Long timestamp; + + @Schema(description = "时区") + private String timeZone; + + @Schema(description = "时区偏移量,单位为分钟") + private Integer timezoneOffset; + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/entity/DeviceEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/device/entity/DeviceEntity.java index ccbe4683..cbdee85f 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/entity/DeviceEntity.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/entity/DeviceEntity.java @@ -11,7 +11,7 @@ import java.util.Date; @Schema(description = "设备信息") public class DeviceEntity { @Schema(description = "设备ID") - private Long id; + private String id; @Schema(description = "关联用户ID") private Long userId; diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java index 8ba966d2..4feb1024 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java @@ -3,12 +3,22 @@ package xiaozhi.modules.device.service; import xiaozhi.common.page.PageData; import xiaozhi.common.service.BaseService; import xiaozhi.modules.device.dto.DeviceHeaderDTO; +import xiaozhi.modules.device.dto.DeviceReportReqDTO; +import xiaozhi.modules.device.dto.DeviceReportRespDTO; import xiaozhi.modules.device.entity.DeviceEntity; import java.util.List; import java.util.Map; -public interface DeviceService extends BaseService { +public interface DeviceService { + + /** + * 根据Mac地址获取设备信息 + */ + DeviceEntity getDeviceById(String macAddress); + + DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId, DeviceReportReqDTO deviceReport); + DeviceEntity bindDevice(Long userId, DeviceHeaderDTO deviceHeader); List getUserDevices(Long userId); @@ -16,4 +26,6 @@ public interface DeviceService extends BaseService { void unbindDevice(Long userId, Long deviceId); PageData adminDeviceList(Map params); + + Boolean deviceActivation(String activationCode); } \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java index 279a9f2e..b7a456e1 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java @@ -1,28 +1,168 @@ package xiaozhi.modules.device.service.impl; +import cn.hutool.core.util.RandomUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import xiaozhi.common.exception.RenException; import xiaozhi.common.page.PageData; import xiaozhi.common.service.impl.BaseServiceImpl; +import xiaozhi.common.user.UserDetail; import xiaozhi.modules.device.dao.DeviceDao; import xiaozhi.modules.device.dto.DeviceHeaderDTO; +import xiaozhi.modules.device.dto.DeviceReportReqDTO; +import xiaozhi.modules.device.dto.DeviceReportRespDTO; import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.service.DeviceService; +import xiaozhi.modules.security.user.SecurityUser; -import java.util.Date; -import java.util.List; -import java.util.Map; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.TimeUnit; @Service public class DeviceServiceImpl extends BaseServiceImpl implements DeviceService { + private final DeviceDao deviceDao; + private final String frontedUrl; + + private final RedisTemplate redisTemplate; + // 添加构造函数来初始化 deviceMapper - public DeviceServiceImpl(DeviceDao deviceDao) { + public DeviceServiceImpl(DeviceDao deviceDao, + @Value("${app.fronted-url:http://localhost:8001}") String frontedUrl, + RedisTemplate redisTemplate) { this.deviceDao = deviceDao; + this.frontedUrl = frontedUrl; + this.redisTemplate = redisTemplate; } + @Override + public DeviceEntity getDeviceById(String deviceId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(DeviceEntity::getId, deviceId); + return deviceDao.selectOne(queryWrapper); + } + + @Override + public Boolean deviceActivation(String activationCode) { + if (StringUtils.isBlank(activationCode)) { + throw new RenException("激活码不能为空"); + } + String deviceKey = "ota:activation:code:" + activationCode; + Object cacheDeviceId = redisTemplate.opsForValue().get(deviceKey); + if (cacheDeviceId == null) { + throw new RenException("激活码错误"); + } + String deviceId = (String) cacheDeviceId; + String safeDeviceId = deviceId.replace(":", "_").toLowerCase(); + String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId); + Map cacheMap = redisTemplate.opsForHash().entries(cacheDeviceKey); + if (cacheMap == null) { + throw new RenException("激活码错误"); + } + String cachedCode = (String) cacheMap.get("activation_code"); + if (!activationCode.equals(cachedCode)) { + throw new RenException("激活码错误"); + } + // 检查设备有没有被激活 + if (selectById(deviceId) != null) { + throw new RenException("设备已激活"); + } + + String macAddress = (String) cacheMap.get("mac_address"); + String board = (String) cacheMap.get("board"); + String appVersion = (String) cacheMap.get("app_version"); + UserDetail user = SecurityUser.getUser(); + if (user.getId() == null) { + throw new RenException("用户未登录"); + } + + Date currentTime = new Date(); + DeviceEntity deviceEntity = new DeviceEntity(); + deviceEntity.setId(deviceId); + deviceEntity.setBoard(board); + deviceEntity.setAppVersion(appVersion); + deviceEntity.setMacAddress(macAddress); + deviceEntity.setUserId(user.getId()); + deviceEntity.setCreator(user.getId()); + deviceEntity.setCreateDate(currentTime); + deviceEntity.setUpdater(user.getId()); + deviceEntity.setUpdateDate(currentTime); + deviceEntity.setLastConnectedAt(currentTime); + deviceDao.insert(deviceEntity); + + // 清理redis缓存 + redisTemplate.delete(cacheDeviceKey); + redisTemplate.delete(deviceKey); + return true; + } + + @Override + public DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId, DeviceReportReqDTO deviceReport) { + DeviceReportRespDTO response = new DeviceReportRespDTO(); + response.setServerTime(buildServerTime()); + // todo: 此处是固件信息,目前是针对固件上传上来的版本号再返回回去 + // 在未来开发了固件更新功能,需要更换此处代码, + // 或写定时任务定期请求虾哥的OTA,获取最新的版本讯息保存到服务内 + DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware(); + firmware.setVersion(deviceReport.getApplication().getVersion()); + firmware.setUrl("http://localhost:8002/xiaozhi-esp32-api/api/v1/ota/download"); + response.setFirmware(firmware); + + DeviceEntity deviceById = getDeviceById(deviceId); + if (deviceById != null) { // 如果设备存在,则更新上次连接时间 + deviceById.setLastConnectedAt(new Date()); + deviceDao.updateById(deviceById); + } else { // 如果设备不存在,则生成激活码 + String safeDeviceId = deviceId.replace(":", "_").toLowerCase(); + String dataKey = String.format("ota:activation:data:%s", safeDeviceId); + + Map cacheMap = redisTemplate.opsForHash().entries(dataKey); + DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation(); + + if (cacheMap != null && cacheMap.containsKey("activation_code")) { + String cachedCode = (String) cacheMap.get("activation_code"); + code.setCode(cachedCode); + code.setMessage(frontedUrl + "\n" + cachedCode); + } else { + String newCode = RandomUtil.randomNumbers(6); + code.setCode(newCode); + code.setMessage(frontedUrl + "\n" + newCode); + + Map dataMap = new HashMap<>(); + dataMap.put("id", deviceId); + dataMap.put("mac_address", macAddress); + dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName() + : (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown")); + dataMap.put("app_version", (deviceReport.getApplication() != null) + ? deviceReport.getApplication().getVersion() + : null); + dataMap.put("deviceId", deviceId); + dataMap.put("activation_code", newCode); + + // 写入主数据 key + redisTemplate.opsForHash().putAll(dataKey, dataMap); + redisTemplate.expire(dataKey, 24, TimeUnit.HOURS); + + // 写入反查激活码 key + String codeKey = "ota:activation:code:" + newCode; + redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS); + } + + response.setActivation(code); + } + + return response; + } + + + @Override public DeviceEntity bindDevice(Long userId, DeviceHeaderDTO deviceHeader) { DeviceEntity device = new DeviceEntity(); @@ -54,4 +194,13 @@ public class DeviceServiceImpl extends BaseServiceImpl return new PageData<>(page.getRecords(), page.getTotal()); } + private DeviceReportRespDTO.ServerTime buildServerTime() + { + DeviceReportRespDTO.ServerTime serverTime = new DeviceReportRespDTO.ServerTime(); + TimeZone tz = TimeZone.getDefault(); + serverTime.setTimestamp(Instant.now().toEpochMilli()); + serverTime.setTimeZone(tz.getID()); + serverTime.setTimezoneOffset(tz.getOffset(System.currentTimeMillis()) / (60 * 1000)); + return serverTime; + } } \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/utils/NetworkUtil.java b/main/manager-api/src/main/java/xiaozhi/modules/device/utils/NetworkUtil.java new file mode 100644 index 00000000..1a5302db --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/utils/NetworkUtil.java @@ -0,0 +1,35 @@ +package xiaozhi.modules.device.utils; + +import org.apache.commons.lang3.StringUtils; + +import java.util.regex.Pattern; + +/** + * 网络工具类 + */ +public class NetworkUtil +{ + /** + * MAC地址正则表达式 + */ + private static final Pattern macPattern = Pattern.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"); + + /** + * 判断MAC地址是否合法 + */ + public static boolean isMacAddressValid(String mac) { + if (StringUtils.isBlank(mac)) { + return false; + } + // 正则校验格式 + if (!macPattern.matcher(mac).matches()) { + return false; + } + // 校验MAC地址是否为单播地址 + String normalized = mac.toLowerCase(); + String[] parts = normalized.split("[:-]"); + int firstByte = Integer.parseInt(parts[0], 16); + return (firstByte & 1) == 0; // 最低位为0表示单播地址,合法 + } + +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java index c90b78f7..f5c00fe9 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java @@ -65,6 +65,7 @@ public class ShiroConfig { perms:拥有对某个资源的权限才能访问 role:拥有某个角色权限才能访问*/ Map filterMap = new LinkedHashMap<>(); + filterMap.put("/ota/**", "anon"); filterMap.put("/webjars/**", "anon"); filterMap.put("/druid/**", "anon"); filterMap.put("/v3/api-docs/**", "anon"); diff --git a/main/manager-api/src/main/resources/application.yml b/main/manager-api/src/main/resources/application.yml index 74dfb0e6..9c38b392 100644 --- a/main/manager-api/src/main/resources/application.yml +++ b/main/manager-api/src/main/resources/application.yml @@ -63,4 +63,7 @@ mybatis-plus: configuration-properties: prefix: blobType: BLOB - boolValue: TRUE \ No newline at end of file + boolValue: TRUE + +app: + fronted-url: http://localhost:8001 \ No newline at end of file diff --git a/main/manager-web/src/components/ActivateDeviceDialog.vue b/main/manager-web/src/components/ActivateDeviceDialog.vue new file mode 100644 index 00000000..59384e79 --- /dev/null +++ b/main/manager-web/src/components/ActivateDeviceDialog.vue @@ -0,0 +1,98 @@ + + + + + \ No newline at end of file