// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\service; use think\facade\Db; /** * OTA 固件升级服务 * * 流程: * 后台/App 上传固件 → 计算 SHA256/大小 → 存 iot_ota * → publishOta 选中设备 → 下发 /devices/{uuid}/ota 指令 * → 设备下载/校验/升级 → 上报 /devices/{uuid}/ota/report 进度 * → onReport 解析进度,写 device_log + 失败告警/通知 * * 升级指令 payload: * {"version":"1.2.0","url":"https://...","hash":"sha256","size":12345} */ class OtaService { /** OTA 下发主题 */ public const TOPIC_OTA = '/devices/%s/ota'; /** OTA 进度上报主题 */ public const TOPIC_REPORT = '/devices/%s/ota/report'; /** 确保 ota 表存在(自愈) */ public static function ensureTable(): bool { $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_ota` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `model` varchar(64) NOT NULL DEFAULT '', `version` varchar(32) NOT NULL DEFAULT '', `filename` varchar(128) NOT NULL DEFAULT '', `file_hash` varchar(64) NOT NULL DEFAULT '', `size` int unsigned NOT NULL DEFAULT '0', `url` varchar(255) NOT NULL DEFAULT '', `remark` varchar(255) DEFAULT NULL, `status` tinyint(1) NOT NULL DEFAULT '1', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_model` (`model`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); return true; } catch (\Throwable $e) { return false; } } /** * 登记固件(后台上传后调用) * @return int OTA记录ID */ public static function add(array $data): int { self::ensureTable(); $version = trim((string) ($data['version'] ?? '')); if ($version === '') { throw new \InvalidArgumentException('版本号不能为空'); } $file = trim((string) ($data['filename'] ?? '')); $url = trim((string) ($data['url'] ?? '')); $hash = trim((string) ($data['file_hash'] ?? '')); $size = max(0, (int) ($data['size'] ?? 0)); if ($file === '' || $url === '') { throw new \InvalidArgumentException('固件文件与下载地址不能为空'); } // 校验哈希格式(SHA256 为 64 位 hex;允许手动录入,上传时自动计算) if ($hash !== '' && !preg_match('/^[a-f0-9]{64}$/i', $hash)) { throw new \InvalidArgumentException('文件哈希须为 SHA256(64位hex)'); } return (int) Db::name('iot_ota')->insertGetId([ 'model' => mb_substr(trim((string) ($data['model'] ?? '')), 0, 64), 'version' => mb_substr($version, 0, 32), 'filename' => mb_substr($file, 0, 128), 'file_hash' => strtolower($hash), 'size' => $size, 'url' => mb_substr($url, 0, 255), 'remark' => mb_substr((string) ($data['remark'] ?? ''), 0, 255), 'status' => isset($data['status']) && !$data['status'] ? 0 : 1, 'create_at' => time(), ]); } /** 固件列表(按型号过滤) */ public static function list(string $model = ''): array { self::ensureTable(); $q = Db::name('iot_ota'); if ($model !== '') { $q->where('model', $model); } return $q->order('id', 'desc')->select()->toArray(); } /** 删除固件 */ public static function remove(int $otaId): void { Db::name('iot_ota')->where('id', $otaId)->delete(); } /** * 下发升级指令到指定设备(逐个 outbox 入队,离线自动补发) * @return int 成功入队数 */ public static function publish(int $otaId, array $deviceUuids): int { $ota = Db::name('iot_ota')->where('id', $otaId)->find(); if (!$ota || !$ota['status']) { throw new \RuntimeException('固件不存在或已停用'); } $payload = (string) json_encode([ 'version' => $ota['version'], 'url' => $ota['url'], 'hash' => $ota['file_hash'], 'size' => (int) $ota['size'], ], JSON_UNESCAPED_SLASHES); $ok = 0; foreach (array_unique($deviceUuids) as $uuid) { if ($uuid === '') { continue; } if (Store::enqueueManual(sprintf(self::TOPIC_OTA, $uuid), $payload, 1, 0, 'ota')) { $ok++; } } return $ok; } /** * 设备 OTA 进度上报处理(Broker 钩子 /ota/report 调用) * payload: {"state":"downloading|installing|done|fail","progress":42,"version":"1.2.0"} */ public static function onReport(string $uuid, string $payload): void { DeviceLogService::log(sprintf(self::TOPIC_REPORT, $uuid), $payload, 'in'); $data = json_decode($payload, true); if (!is_array($data)) { return; } $state = (string) ($data['state'] ?? ''); $dev = DeviceService::findByUuid($uuid); if (!$dev || (int) $dev['member_id'] <= 0) { return; } $memberId = (int) $dev['member_id']; $version = (string) ($data['version'] ?? ''); switch ($state) { case 'done': AlertService::add($memberId, 'ota', 1, '固件升级完成', '设备「' . $dev['title'] . '」已升级到 ' . $version, $uuid); break; case 'fail': AlertService::add($memberId, 'ota', 3, '固件升级失败', '设备「' . $dev['title'] . '」升级失败:' . mb_substr((string) ($data['error'] ?? '未知原因'), 0, 200), $uuid); break; default: // downloading/installing 进度 → 实时通知(不写告警表,避免刷屏) AlertService::push($memberId, 'ota', (string) json_encode([ 'type' => 'progress', 'uuid' => $uuid, 'state' => $state, 'progress' => (int) ($data['progress'] ?? -1), 'version' => $version, 'time' => time(), ])); break; } } }