// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\service; use think\facade\Db; use ywxapp\service\AddonService; /** * 智能设备绑定服务(华为式:后台预登记SN+配对码+激活令牌 → 设备入云激活领凭证 → 用户扫码+配对码绑定) * * 绑定与激活分离(华为 HiLink 模型): * 1. 后台 register() 预登记:生成 pair_code(机身贴纸)与 activate_token(出厂预置),不产生连接凭证 * 2. 设备 activate() 入云激活:设备首次联网持 sn+token 领取 uuid/secret(凭证仅此一次返回) * 3. 用户 bind() 认领:App 扫码 SN + 输入配对码,绑定到会员账号;已激活设备不重置凭证 * 兼容旧流程:直接 bind() 未激活设备(无配对码)时自动激活,米家式扫码即绑仍可用 * * Topic 规范: * /devices/{uuid}/state 设备上报状态(retain=1,即"设备影子",离线可查最后状态) * /devices/{uuid}/cmd 服务端 / App 下发命令 * 连接约定: * clientId = uuid(MQTT username 约定同值) * username = uuid * password = device_secret(库中为 bcrypt 哈希,明文仅激活/重置时返回一次) * * ACL 自动规则(remark 前缀 auto:,可追踪、可整组清理): * auto:device:{uuid} target=user={uuid} /devices/%u/# 发布+订阅 * auto:member:{uuid}:* target=user={会员account} state/status订阅 + cmd发布(仅 allow_member_login 开启时生成) */ class DeviceService { /** 设备状态上报主题(影子) */ public const TOPIC_STATE = '/devices/%s/state'; /** * 设备在线态主题(LWT 保留消息;与 event 分主题避免 retained 互相覆盖) * v1.7.0+ 上线报文带上引脚表:{"online":1,"pins":[3,4,10]};LWT 仍固定 {"online":0} */ public const TOPIC_STATUS = '/devices/%s/status'; /** 传感器配置下发主题(retained,由服务端 SensorConfigService 发布) */ public const TOPIC_CONFIG = '/devices/%s/config'; /** 设备应答主题(config_ack / cmd_ack,非 retained) */ public const TOPIC_ACK = '/devices/%s/ack'; /** 命令下发主题 */ public const TOPIC_CMD = '/devices/%s/cmd'; /** 设备注册主题(设备发布激活请求,{sn} 占位) */ public const TOPIC_REGISTER = 'system/device/register/%s'; /** 设备注册回执主题(服务端发布凭证/订阅信息,{sn} 占位) */ public const TOPIC_REGISTER_RESP = 'system/device/register/%s/resp'; /** 设备注册请求主题(设备侧 publish) */ public static function registerTopic(string $sn): string { return sprintf(self::TOPIC_REGISTER, $sn); } /** 设备注册回执主题(服务端 publish,设备订阅等待) */ public static function registerRespTopic(string $sn): string { return sprintf(self::TOPIC_REGISTER_RESP, $sn); } /** * 设备连接/订阅信息(激活或绑定成功后返回,供 App / 设备固件使用) */ public static function deviceTopics(string $uuid): array { return [ 'state' => "/devices/{$uuid}/state", 'status' => "/devices/{$uuid}/status", 'config' => "/devices/{$uuid}/config", 'ack' => "/devices/{$uuid}/ack", 'cmd' => "/devices/{$uuid}/cmd", 'ota' => "/devices/{$uuid}/ota", 'ota_report' => "/devices/{$uuid}/ota/report", 'event' => "/devices/{$uuid}/event", ]; } /** * MQTT 注册入口:设备通过 system/device/register/{sn} 主题携带 activate_token 激活入网 * 复用 activate(),成功后附加订阅主题信息 */ public static function registerViaMqtt(string $sn, string $token): array { $res = self::activate($sn, $token); $uuid = (string) ($res['uuid'] ?? ''); if ($uuid !== '') { $res['topics'] = self::deviceTopics($uuid); } return $res; } /** * MAC 即插即用注册(设备 publish system/device/register/{mac} 触发) * * 设备上电后发布 retained 注册消息: * {"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"} * - mac 为设备唯一身份(12 位十六进制小写,去冒号) * - 首次上报:自动创建设备入网并发放 uuid/secret(无需后台预登记) * - 再次上报:幂等更新 type/model/ver(含固件版本升级上报),不重置凭证 * - secret 仅首次注册返回明文,设备需立即保存并改用 uuid+secret 连接 * * @param string $mac 设备 MAC(主题名中的一段,已去冒号小写) * @param array $info 注册信息 {mac?, type?, model?, ver?} * @return array {id, sn, mac, uuid, dev_type, model, ver, secret?, activated, bound, topics} * @throws \InvalidArgumentException|\RuntimeException */ public static function registerByMac(string $mac, array $info = []): array { self::ensureTable(); self::ensureColumns(); $mac = strtolower(trim($mac)); $mac = str_replace([':', '-', ' '], '', $mac); if ($mac === '' || !preg_match('/^[0-9a-f]{12}$/', $mac)) { throw new \InvalidArgumentException('mac 格式非法(应为12位十六进制,去冒号小写)'); } $type = trim((string) ($info['type'] ?? '')); $model = trim((string) ($info['model'] ?? '')); $ver = trim((string) ($info['ver'] ?? '')); $now = time(); // 优先按 mac 查,兼容老数据按 sn 兜底 $dev = Db::name('iot_device')->where('mac', $mac)->find(); if (!$dev) { $dev = Db::name('iot_device')->where('sn', $mac)->find(); } if (!$dev) { // 即插即用:自动入网(无配对码/激活令牌,用户扫码凭 mac 绑定即可) $uuid = self::genUuid(); $secret = self::genSecret(); $id = Db::name('iot_device')->insertGetId([ 'sn' => $mac, 'mac' => $mac, 'uuid' => $uuid, 'secret' => Auth::hashPassword($secret), 'title' => $mac, 'model' => $model, 'dev_type' => $type, 'ver' => $ver, 'member_id' => 0, 'status' => 1, 'create_at' => $now, 'update_at' => $now, ]); self::provisionCredentials($uuid, $mac, $secret, $now); return [ 'id' => (int) $id, 'sn' => $mac, 'mac' => $mac, 'uuid' => $uuid, 'dev_type' => $type, 'model' => $model, 'ver' => $ver, 'secret' => $secret, 'activated' => true, 'bound' => false, 'topics' => self::deviceTopics($uuid), ]; } // 已存在:补 mac 与 type/model/ver(不重置凭证) $uuid = (string) $dev['uuid']; $update = ['update_at' => $now]; if ((string) $dev['mac'] === '') { $update['mac'] = $mac; } if ($type !== '') { $update['dev_type'] = $type; } if ($model !== '') { $update['model'] = $model; } if ($ver !== '') { $update['ver'] = $ver; } // 后台已登记但设备尚未领取凭证 → 补齐并发放 if ($uuid === '' || (string) $dev['secret'] === '') { if ($uuid === '') { $uuid = self::genUuid(); } $secret = self::genSecret(); $update['uuid'] = $uuid; $update['secret'] = Auth::hashPassword($secret); Db::name('iot_device')->where('id', $dev['id'])->update($update); self::provisionCredentials($uuid, $mac, $secret, $now); return [ 'id' => (int) $dev['id'], 'sn' => (string) $dev['sn'], 'mac' => $mac, 'uuid' => $uuid, 'dev_type' => $type !== '' ? $type : (string) $dev['dev_type'], 'model' => $model !== '' ? $model : (string) $dev['model'], 'ver' => $ver !== '' ? $ver : (string) $dev['ver'], 'secret' => $secret, 'activated' => true, 'bound' => (int) $dev['member_id'] > 0, 'topics' => self::deviceTopics($uuid), ]; } Db::name('iot_device')->where('id', $dev['id'])->update($update); return [ 'id' => (int) $dev['id'], 'sn' => (string) $dev['sn'], 'mac' => $mac, 'uuid' => $uuid, 'dev_type' => $type !== '' ? $type : (string) $dev['dev_type'], 'model' => $model !== '' ? $model : (string) $dev['model'], 'ver' => $ver !== '' ? $ver : (string) $dev['ver'], 'activated' => true, 'bound' => (int) $dev['member_id'] > 0, 'secret' => '', 'topics' => self::deviceTopics($uuid), ]; } /** 确保 device 表存在(自愈,老库/未执行 install.sql 也可用) */ public static function ensureTable(): bool { // prefix 需从连接实例取(connections.mysql.prefix),Db::getConfig('prefix') 顶层取不到 $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_device` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `sn` varchar(64) NOT NULL DEFAULT '', `mac` varchar(32) NOT NULL DEFAULT '' COMMENT '设备MAC(去冒号小写,即插即用注册身份)', `uuid` varchar(64) NOT NULL DEFAULT '', `secret` varchar(191) NOT NULL DEFAULT '', `pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定时校验)', `activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)', `title` varchar(128) NOT NULL DEFAULT '', `model` varchar(64) NOT NULL DEFAULT '', `dev_type` varchar(64) NOT NULL DEFAULT '' COMMENT '设备类型(esp32c3_temp等,App据此渲染面板)', `ver` varchar(32) NOT NULL DEFAULT '' COMMENT '固件版本(OTA检测/提示)', `cfg_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '最新传感器配置版本(服务端真源)', `sync_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '设备已回执生效的配置版本', `sync_at` int DEFAULT NULL COMMENT '配置回执时间', `member_id` int unsigned NOT NULL DEFAULT '0', `room` varchar(64) NOT NULL DEFAULT '', `status` tinyint(1) NOT NULL DEFAULT '1', `bind_at` int DEFAULT NULL, `create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_sn` (`sn`), UNIQUE KEY `uk_mac` (`mac`), UNIQUE KEY `uk_uuid` (`uuid`), KEY `idx_member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); self::ensureColumns(); return true; } catch (\Throwable $e) { return false; } } /** * 老库加列自愈(2.2.6 新增 pair_code / activate_token;2.2.8 新增 mac / dev_type / ver; * v1.7.0 新增 pins) * ensureTable 的 CREATE IF NOT EXISTS 对已存在的表不会补列,此处 ALTER 补齐 * * ⚠️ 进程内只真正查一次库(static $done):Broker 是常驻进程,而这个方法会被 * 每台设备每次上线(/status 报文)调到,没必要反复 `SHOW COLUMNS`。 * CLI 常驻进程里加完列就一直为真;Web 请求每次是新进程,行为不变。 */ public static function ensureColumns(): bool { static $done = false; if ($done) { return true; } $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; $table = $prefix . 'iot_device'; try { $cols = []; $rows = Db::query("SHOW COLUMNS FROM `{$table}`"); foreach ($rows as $r) { $cols[(string) $r['Field']] = true; } if (!isset($cols['pair_code'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定校验)' AFTER `secret`"); } if (!isset($cols['activate_token'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)' AFTER `pair_code`"); } if (!isset($cols['mac'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `mac` varchar(32) NOT NULL DEFAULT '' COMMENT '设备MAC(去冒号小写,即插即用注册身份)' AFTER `sn`"); } if (!isset($cols['dev_type'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `dev_type` varchar(64) NOT NULL DEFAULT '' COMMENT '设备类型(esp32c3_temp等,App据此渲染面板)' AFTER `model`"); } if (!isset($cols['ver'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `ver` varchar(32) NOT NULL DEFAULT '' COMMENT '固件版本(OTA检测/提示)' AFTER `dev_type`"); } // 2.3.0 新增:传感器配置版本三方对齐(cfg_ver 服务端最新 / sync_ver 设备已回执) if (!isset($cols['cfg_ver'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `cfg_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '最新传感器配置版本(服务端真源)' AFTER `ver`"); } if (!isset($cols['sync_ver'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `sync_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '设备已回执生效的配置版本' AFTER `cfg_ver`"); } if (!isset($cols['sync_at'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `sync_at` int DEFAULT NULL COMMENT '配置回执时间' AFTER `sync_ver`"); } // v1.7.0 新增:总线 GPIO 表(设备编译期常量 ONEWIRE_PINS[],下标=位置序号) // 设备上线时在 /status 报一次 → 这里缓存 → 随 deviceConfig 下发给 App。 // 有了它,state 报文就不必每条都带 pin(3 个位置省约 24B/条 ≈ 报文 7%)。 if (!isset($cols['pins'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `pins` varchar(64) NOT NULL DEFAULT '' COMMENT '总线GPIO表(CSV如3,4,10;下标=位置序号;设备上线时上报一次)' AFTER `sync_at`"); } $done = true; return true; } catch (\Throwable $e) { return false; } } /* ============================================================ * 总线 GPIO 表(pins)—— 把「编译期常量」从高频报文里搬出来 * ========================================================== */ /** * 捕获设备 /status 上线报文里的引脚表并缓存 * * 背景:pin 是固件的**编译期常量**(ONEWIRE_PINS[]),与位置序号严格一一对应、 * 设备这辈子都不会变,却原本每条 state 都带一遍(3 个位置 = 24B/条 ≈ 报文 7%)。 * v1.7.0 改成「上线时报一次」:设备在 /status 的 retained 报文里带 pins,这里落库, * 再由 deviceConfig 下发给 App —— 人眼对接线端子的信息一点没少。 * * 报文形状(新旧兼容): * v1.7.0+ {"online":1,"pins":[3,4,10]} ← 带 pins,缓存 * ≤1.6.2 {"online":0/1} ← 没有 pins,直接返回(正常情况,不是错误) * * ⚠️ 只认 online=1 时的 pins;不含 pins 的老报文一律原样放过,绝不清空已有缓存 * (否则一次老设备上线就会把同 uuid 的缓存冲掉 —— 虽然 uuid 唯一,但语义上就不该动)。 * * @param string $topic MQTT 主题(只处理 /devices/{uuid}/status) * @param string $payload 报文 */ public static function onStatusMessage(string $topic, string $payload): void { if (!preg_match('#^/devices/([^/]+)/status$#', $topic, $m)) { return; } $data = json_decode($payload, true); if (!is_array($data) || !isset($data['pins']) || !is_array($data['pins'])) { return; // 老固件只有 {"online":0/1} } $pins = []; foreach ($data['pins'] as $p) { if (is_numeric($p)) { $pins[] = (int) $p; } } if (!$pins) { return; } self::setPins($m[1], $pins); /* 引脚表是「位置序号 → GPIO」的唯一来源,顺手把缺失的位置配置补齐: 设备只采「配置里启用的位置」,而新设备默认只有位置 0(ensureDefault)—— 探头插在 GPIO4/GPIO10(位置 1/2)上会永远不出数(现场典型困惑: "只有某根针脚能出数据,断掉它接别的都没数")。补齐后可做到插哪路都能出数。 只补不覆盖:已存在的位置(含停用)一行不动;无新增则完全不产生版本跳动。 */ try { $added = SensorConfigService::fillMissing($m[1], $pins); if ($added === 0) { /* 「没新增位置」≠「不用下发」:设备端配置可能落后或已被清空(最典型的是 曾被一条空配置打成全停用,而两侧版本号还恰好对齐 ⇒ 谁都不会主动纠正)。 所以上线时按当前版本重发一次(retained,一条报文,代价可忽略)。 有新增时 fillMissing 内部已经 bumpVer + push,不必重复。 */ SensorConfigService::repush($m[1]); } } catch (\Throwable $e) { // 补齐/重发失败不阻塞上线主流程(用户仍可在后台手动保存配置) } } /** * 写入引脚表缓存(CSV 存库:内容相同则跳过写,避免每次上线都产生一次 UPDATE) * * @param int[] $pins 下标 = 位置序号 */ public static function setPins(string $uuid, array $pins): bool { $uuid = trim($uuid); if ($uuid === '' || !$pins) { return false; } $pins = array_map('intval', array_values($pins)); try { self::ensureColumns(); // 老库没这列时先补 if (self::getPins($uuid) === $pins) { return true; // 常量表,内容一样就不写库 } Db::name('iot_device')->where('uuid', $uuid)->update([ 'pins' => implode(',', $pins), 'update_at' => time(), ]); return true; } catch (\Throwable $e) { return false; } } /** * 读取引脚表缓存 * * @return int[] 下标 = 位置序号;从未上报过(老固件 / 老库无此列)返回空数组 */ public static function getPins(string $uuid): array { $uuid = trim($uuid); if ($uuid === '') { return []; } try { $raw = Db::name('iot_device')->where('uuid', $uuid)->value('pins'); } catch (\Throwable $e) { return []; // 老库还没有 pins 列 } if (!is_string($raw) || trim($raw) === '') { return []; } $out = []; foreach (explode(',', $raw) as $p) { if (is_numeric($p)) { $out[] = (int) $p; } } return $out; } /** * 绑定设备(用户认领) * 华为式:设备已入云激活(uuid+secret 已签发)→ 只建立归属关系,不重置凭证; * 兼容旧流程:设备未激活(无凭证)→ 绑定即激活(扫码即绑,米家式) * @param string $pairCode 配对码(设备设置了 pair_code 则必填且须匹配,防抢绑) * @return array 绑定结果(激活/重置时含 secret 明文,仅此一次返回) * @throws \InvalidArgumentException|\RuntimeException */ public static function bind(string $sn, int $memberId, string $title = '', string $model = '', string $room = '', string $pairCode = ''): array { self::ensureTable(); self::ensureColumns(); $sn = trim($sn); if ($sn === '' || mb_strlen($sn) > 64) { throw new \InvalidArgumentException('设备序列号不能为空且不超过64字符'); } if ($memberId <= 0) { throw new \InvalidArgumentException('无效的会员ID'); } $now = time(); $dev = Db::name('iot_device')->where('sn', $sn)->find(); // 已被他人绑定 if ($dev && (int) $dev['member_id'] > 0 && (int) $dev['member_id'] !== $memberId) { throw new \RuntimeException('该设备已被其他用户绑定'); } // 重复绑定到同一会员:幂等返回(不再发放密钥,secret 仅激活时返回一次) if ($dev && (int) $dev['member_id'] === $memberId) { $uuid = (string) $dev['uuid']; return [ 'id' => (int) $dev['id'], 'sn' => $dev['sn'], 'uuid' => $uuid, 'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''), 'title' => $dev['title'], 'model' => $dev['model'], 'room' => $dev['room'], 'bound' => true, 'activated' => $uuid !== '' && $dev['secret'] !== '', 'topics' => $uuid !== '' ? self::deviceTopics($uuid) : [], ]; } // 配对码校验(华为式防抢绑):设备设置了 pair_code 则必填且须匹配 if ($dev && (string) $dev['pair_code'] !== '') { $code = trim($pairCode); if ($code === '' || !hash_equals((string) $dev['pair_code'], $code)) { throw new \RuntimeException('配对码错误(请查看设备机身贴纸)'); } } $activated = $dev && $dev['uuid'] !== '' && $dev['secret'] !== ''; if ($activated) { // 华为式:设备已入云激活,绑定仅建立归属,凭证保持不变(设备激活时已领取) $uuid = (string) $dev['uuid']; Db::name('iot_device')->where('id', $dev['id'])->update([ 'title' => $title !== '' ? $title : $dev['title'], 'model' => $model !== '' ? $model : $dev['model'], 'room' => $room !== '' ? $room : $dev['room'], 'member_id' => $memberId, 'status' => 1, 'bind_at' => $now, 'update_at' => $now, ]); if (self::allowMemberMqtt()) { self::syncMemberAcl($memberId, $uuid); } self::ensureSensorConfig($uuid); // 老设备补默认配置(幂等) return [ 'id' => (int) $dev['id'], 'sn' => $sn, 'uuid' => $uuid, 'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''), 'title' => $title !== '' ? $title : $dev['title'], 'model' => $model !== '' ? $model : $dev['model'], 'room' => $room !== '' ? $room : $dev['room'], 'bound' => true, 'activated' => true, 'secret' => '', 'topics' => self::deviceTopics($uuid), ]; } if (!$dev) { // 全新设备:登记 + 激活(米家式兼容:扫码即绑,无配对码) $uuid = self::genUuid(); $secret = self::genSecret(); $devId = Db::name('iot_device')->insertGetId([ 'sn' => $sn, 'uuid' => $uuid, 'secret' => Auth::hashPassword($secret), 'title' => $title !== '' ? $title : $sn, 'model' => $model, 'room' => $room, 'member_id' => $memberId, 'status' => 1, 'bind_at' => $now, 'create_at' => $now, 'update_at' => $now, ]); $dev = ['id' => $devId, 'sn' => $sn, 'uuid' => $uuid, 'title' => $title !== '' ? $title : $sn]; } else { // 已登记未激活(后台预登记)→ 激活:重置凭证(旧密钥作废,安全),激活令牌作废 $uuid = $dev['uuid'] !== '' ? $dev['uuid'] : self::genUuid(); $secret = self::genSecret(); Db::name('iot_device')->where('id', $dev['id'])->update([ 'uuid' => $uuid, 'secret' => Auth::hashPassword($secret), 'activate_token' => '', 'title' => $title !== '' ? $title : $dev['title'], 'model' => $model !== '' ? $model : $dev['model'], 'room' => $room !== '' ? $room : $dev['room'], 'member_id' => $memberId, 'status' => 1, 'bind_at' => $now, 'update_at' => $now, ]); } self::provisionCredentials($uuid, $sn, $secret, $now); if (self::allowMemberMqtt()) { self::syncMemberAcl($memberId, $uuid); } return [ 'id' => (int) ($dev['id'] ?? 0), 'sn' => $sn, 'uuid' => $uuid, 'mac' => (string) ($dev['mac'] ?? (preg_match('/^[0-9a-f]{12}$/', $sn) ? $sn : '')), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''), 'title' => $title !== '' ? $title : ($dev['title'] ?? $sn), 'model' => $model, 'room' => $room, 'secret' => $secret, 'bound' => true, 'activated' => true, 'topics' => self::deviceTopics($uuid), ]; } /** * 设备入云激活(华为式,设备侧调用,无需登录) * 设备出厂预置 sn + activate_token,首次联网调此接口领取正式连接凭证 uuid/secret * @param string $sn 设备序列号(机身二维码) * @param string $token 激活令牌(后台预登记生成,一次性;激活成功即作废) * @return array 激活结果(secret 仅此一次返回明文) * @throws \InvalidArgumentException|\RuntimeException */ public static function activate(string $sn, string $token): array { self::ensureTable(); self::ensureColumns(); $sn = trim($sn); $token = trim($token); if ($sn === '' || $token === '') { throw new \InvalidArgumentException('sn 与 token 均为必填'); } $dev = Db::name('iot_device')->where('sn', $sn)->find(); if (!$dev) { throw new \RuntimeException('设备未登记,请联系管理员预登记'); } if ((int) $dev['status'] !== 1) { throw new \RuntimeException('设备已停用'); } // 已激活:幂等返回(设备重试/重复请求不报错),但不再发放 secret(明文仅激活时返回一次) if ($dev['uuid'] !== '' && $dev['secret'] !== '') { return [ 'id' => (int) $dev['id'], 'sn' => $sn, 'uuid' => $dev['uuid'], 'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''), 'title' => $dev['title'], 'model' => $dev['model'], 'room' => $dev['room'], 'activated' => true, 'bound' => (int) $dev['member_id'] > 0, 'secret' => '', 'topics' => self::deviceTopics((string) $dev['uuid']), ]; } if ((string) $dev['activate_token'] === '' || !hash_equals((string) $dev['activate_token'], $token)) { throw new \RuntimeException('激活令牌无效或已失效'); } $now = time(); $uuid = self::genUuid(); $secret = self::genSecret(); Db::name('iot_device')->where('id', $dev['id'])->update([ 'uuid' => $uuid, 'secret' => Auth::hashPassword($secret), 'activate_token' => '', 'status' => 1, 'update_at' => $now, ]); self::provisionCredentials($uuid, $sn, $secret, $now); return [ 'id' => (int) $dev['id'], 'sn' => $sn, 'uuid' => $uuid, 'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''), 'title' => $dev['title'], 'model' => $dev['model'], 'room' => $dev['room'], 'secret' => $secret, 'activated' => true, 'bound' => (int) $dev['member_id'] > 0, 'topics' => self::deviceTopics($uuid), ]; } /** 后台重新生成激活令牌(仅未激活设备可用,按 SN 定位;已激活设备如需换凭证走 resetSecret) */ public static function resetActivateToken(string $sn): array { self::ensureTable(); self::ensureColumns(); $dev = self::findByUuid($sn); if (!$dev) { $dev = Db::name('iot_device')->where('sn', $sn)->find(); } if (!$dev) { throw new \RuntimeException('设备不存在'); } if ($dev['uuid'] !== '' && $dev['secret'] !== '') { throw new \RuntimeException('设备已激活,无需重新生成激活令牌'); } $token = self::genActivateToken(); Db::name('iot_device')->where('id', $dev['id'])->update([ 'activate_token' => $token, 'update_at' => time(), ]); return ['sn' => $sn, 'activate_token' => $token]; } /** 解绑设备(设备账号/凭证与设备ACL保留,可被重新绑定;已激活设备重绑时凭证保持不变) */ public static function unbind(string $uuid, int $memberId): void { $dev = self::findByUuid($uuid); if (!$dev) { throw new \RuntimeException('设备不存在'); } if ((int) $dev['member_id'] !== $memberId) { throw new \RuntimeException('无权解绑该设备'); } Db::name('iot_device')->where('id', $dev['id'])->update([ 'member_id' => 0, 'bind_at' => null, 'update_at' => time(), ]); self::clearMemberAcl($uuid); } /** 重置设备密钥,返回明文(旧密钥立即作废) */ public static function resetSecret(string $uuid, ?int $memberId = null): array { $dev = self::findByUuid($uuid); if (!$dev) { throw new \RuntimeException('设备不存在'); } if ($memberId !== null && (int) $dev['member_id'] !== $memberId) { throw new \RuntimeException('无权重置该设备密钥'); } $secret = self::genSecret(); $now = time(); Db::name('iot_device')->where('id', $dev['id'])->update([ 'secret' => Auth::hashPassword($secret), 'update_at' => $now, ]); Db::name('iot_auth')->where('username', $uuid)->update([ 'password' => Auth::hashPassword($secret), 'update_at' => $now, ]); return ['uuid' => $uuid, 'secret' => $secret]; } /** * 后台登记设备(华为式预注册 / MAC 设备补录) * 生成 pair_code(机身贴纸,绑定校验防抢绑)与 activate_token(出厂预置,设备入云激活用), * 不产生连接凭证,等待设备激活或用户扫码绑定 * @param string $pairCode 配对码,传空自动生成 6 位数字 * @param string $mac 设备 MAC(选填,即插即用设备可用 mac 定位) * @param string $type 设备类型(选填,决定 App 面板渲染) * @param string $ver 固件版本(选填,OTA 检测用) */ public static function register(string $sn, string $title, string $model = '', string $room = '', string $pairCode = '', string $mac = '', string $type = '', string $ver = ''): array { self::ensureTable(); self::ensureColumns(); $sn = trim($sn); if ($sn === '' || mb_strlen($sn) > 64) { throw new \InvalidArgumentException('序列号必填且不超过64字符'); } $mac = str_replace([':', '-', ' '], '', strtolower(trim($mac))); if ($mac !== '' && !preg_match('/^[0-9a-f]{12}$/', $mac)) { throw new \InvalidArgumentException('mac 格式非法(应为12位十六进制,去冒号小写)'); } $now = time(); $dev = Db::name('iot_device')->where('sn', $sn)->find(); if (!$dev && $mac !== '') { $dev = Db::name('iot_device')->where('mac', $mac)->find(); } if ($dev) { $update = [ 'title' => $title !== '' ? $title : $dev['title'], 'model' => $model !== '' ? $model : $dev['model'], 'room' => $room !== '' ? $room : $dev['room'], 'update_at' => $now, ]; if ($mac !== '' && (string) $dev['mac'] === '') { $update['mac'] = $mac; } if ($type !== '') { $update['dev_type'] = $type; } if ($ver !== '') { $update['ver'] = $ver; } // 未激活设备:补发激活令牌(已有令牌不覆盖);传入配对码则更新 $token = (string) $dev['activate_token']; if ($token === '' && $dev['uuid'] === '') { $token = self::genActivateToken(); $update['activate_token'] = $token; } if ($pairCode !== '') { $update['pair_code'] = $pairCode; } Db::name('iot_device')->where('id', $dev['id'])->update($update); return [ 'id' => (int) $dev['id'], 'sn' => $sn, 'mac' => (string) ($dev['mac'] ?? ''), 'uuid' => (string) $dev['uuid'], 'dev_type' => $type !== '' ? $type : (string) ($dev['dev_type'] ?? ''), 'ver' => $ver !== '' ? $ver : (string) ($dev['ver'] ?? ''), 'activate_token' => $token, 'pair_code' => $pairCode !== '' ? $pairCode : (string) ($dev['pair_code'] ?? ''), ]; } $token = self::genActivateToken(); $code = $pairCode !== '' ? $pairCode : self::genPairCode(); $id = Db::name('iot_device')->insertGetId([ 'sn' => $sn, 'mac' => $mac, 'uuid' => '', 'secret' => '', 'pair_code' => $code, 'activate_token' => $token, 'title' => $title !== '' ? $title : $sn, 'model' => $model, 'dev_type' => $type, 'ver' => $ver, 'room' => $room, 'member_id' => 0, 'status' => 1, 'bind_at' => null, 'create_at' => $now, 'update_at' => $now, ]); return ['id' => (int) $id, 'sn' => $sn, 'mac' => $mac, 'uuid' => '', 'dev_type' => $type, 'ver' => $ver, 'activate_token' => $token, 'pair_code' => $code]; } /** 删除设备(含 MQTT 账号、ACL、影子、订阅、离线队列,慎用) */ public static function remove(string $uuid): void { $dev = self::findByUuid($uuid); if (!$dev) { return; } Db::name('iot_device')->where('id', $dev['id'])->delete(); Db::name('iot_auth')->where('username', $uuid)->delete(); Db::name('iot_acl')->where('remark', 'auto:device:' . $uuid)->delete(); self::clearMemberAcl($uuid); Db::name('iot_retain')->where('topic', 'like', '/devices/' . $uuid . '/%')->delete(); Db::name('iot_connection')->where('username', $uuid)->update(['status' => 0]); Db::name('iot_topic')->where('client_id', $uuid)->delete(); Db::name('iot_offline')->where('client_id', $uuid)->delete(); // 清分组关联 GroupService::clearDevice((int) $dev['id']); // 清共享关系与共享 ACL ShareService::removeByDevice((int) $dev['id'], $uuid); // 清传感器配置(配置真源在服务端,随设备一并删除) SensorConfigService::removeByUuid($uuid); // 清结构化时序历史(否则残留数据会一直占空间,且 uuid 复用时会串数据) SensorPointService::removeByUuid($uuid); } /** 设备是否在线 */ public static function isOnline(string $uuid): bool { if ($uuid === '') { return false; } try { return Db::name('iot_connection') ->where('username', $uuid) ->where('status', 1) ->count() > 0; } catch (\Throwable $e) { return false; } } /** 读取设备影子(最后上报的 state 保留消息) */ public static function shadow(string $uuid): ?array { if ($uuid === '') { return null; } try { $row = Db::name('iot_retain') ->where('topic', sprintf(self::TOPIC_STATE, $uuid)) ->find(); } catch (\Throwable $e) { return null; } if (!$row) { return null; } return [ 'payload' => (string) $row['payload'], 'update_at' => (int) $row['update_at'], ]; } /** * 会员绑定的设备列表(含在线状态 + 主题表;**不发凭证、不发影子**) * * 🔑 这里是 App 设备列表/面板骨架的入口(经 visibleDevices),所以字段用**显式白名单**, * 不再 select() 全字段 —— 三处瘦身,理由都不是「省字节」而是「不该给」: * ① 凭证类一律不出(2026-09-15):`secret`(连接密码 hash)、`activate_token` * (入云激活一次性令牌)、`pair_code`(机身贴纸防抢绑码)—— 谁拿到谁能顶替设备 * 或抢绑,列表页拿它们**没有任何用途**(`secret` 原先靠 unset 兜,现在是压根不查)。 * ⚠️ 后台设备管理(backend\Devices::deviceTable)**必须继续返回** pair_code / * activate_token:那一页要显示配对码、要在编辑弹窗里展示未激活设备的一次性令牌。 * 两条路径互不相干,别把这里的白名单套到后台去。 * ② 🚫 **不发 `shadow`**:整条 state 报文原文(≈260B/台)在列表里**没有任何消费方** * —— App 走 /iot/api/client/deviceState 单独取影子(device-panel 的 loadShadow()), * 后台 GroupService 自己拼一份。塞在列表里纯属冗余(要影子请调 deviceState())。 * ③ `member_id` 也去掉:这条路径只查"我自己"的设备 ⇒ 它恒等于调用者, * 归属语义已由 role / perm 表达,App 端从不读它。(共享设备走 sharedToMe(), * 那一条返回的 owner_id 是**别人**,不在本白名单内。) * * ⚠️ 下列字段是**契约**,删任何一个 App 就静默显示空白(不改也不会报错,只是没数据): * id / sn / mac / uuid / title / model / dev_type / ver / room * + cfg_ver / sync_ver / sync_at(attachSensorConfig() 拼 applied 给首屏骨架) * (App 还会另补 online / role / topics / sensors / applied) * ⚠️ ensureTable() 已保证 cfg_ver/sync_ver/sync_at 存在(老库会自动 ALTER 补列), * 所以 field() 里写死这几列是安全的。 */ public static function myDevices(int $memberId): array { self::ensureTable(); $list = Db::name('iot_device') ->where('member_id', $memberId) ->field('id,sn,mac,uuid,title,model,dev_type,ver,room,status,pins,' . 'bind_at,create_at,update_at,cfg_ver,sync_ver,sync_at') ->order('id', 'desc') ->select() ->toArray(); foreach ($list as &$d) { $d['online'] = self::isOnline((string) $d['uuid']); $d['role'] = 'owner'; $d['topics'] = self::deviceTopics((string) $d['uuid']); } unset($d); return $list; } /** * 会员可见设备(主人 + 共享给我),role 区分 owner/shared * * @param bool $withConfig 是否内联位置配置(sensors/cfg_ver/sync_ver/applied) * · 设备列表(App 首屏骨架)需要 → 默认开; * · 只是顺带取设备行的接口(如 otaList)不需要 → 传 false,省掉那 2 条 SQL 与响应体积。 */ public static function visibleDevices(int $memberId, bool $withConfig = true): array { $owner = self::myDevices($memberId); $shared = ShareService::sharedToMe($memberId); $list = array_merge($owner, $shared); // 配置内联:App 拉一次设备列表就能画骨架(含离线/首屏),省掉 deviceConfig 那次往返 return $withConfig ? self::attachSensorConfig($list) : $list; } /** * 给设备列表批量补「位置配置」(sensors / cfg_ver / sync_ver / applied) * * 为什么放这里、而不是在 myDevices() 里逐台补: * ① owner + shared 两批设备一次性补,共享设备(perm=1 只读)也能看到配置,与 ACL 白名单含 config 一致; * ② 必须**批量** —— 见 SensorConfigService::getAll() 的注释(N+1 会拖垮列表接口)。 * * ⚠️ 不改 iot_device.pins 的类型:DB 里存的是 CSV 字符串("3,4,10"), * 与 deviceConfig 接口下发的 pins(数组)**不同型**,App 端别把列表里的 pins 直接当数组用; * 要 GPIO 提示就等 /status 保留消息或调 deviceConfig(applyPins 只补不清,不会闪空)。 * * @param array $list myDevices()/sharedToMe() 产出的设备行 */ public static function attachSensorConfig(array $list): array { if (!$list) { return $list; } $uuids = []; foreach ($list as $d) { $u = (string) ($d['uuid'] ?? ''); if ($u !== '') { $uuids[] = $u; } } if (!$uuids) { return $list; } try { $map = SensorConfigService::getAll($uuids); } catch (\Throwable $e) { return $list; // 配置取不到不影响设备列表本身(App 端回退到 deviceConfig 接口) } foreach ($list as &$d) { $u = (string) ($d['uuid'] ?? ''); if (!isset($map[$u])) { continue; } $c = $map[$u]; $d['sensors'] = $c['sensors']; $d['cfg_ver'] = $c['cfg_ver']; $d['sync_ver'] = $c['sync_ver']; $d['applied'] = $c['applied']; } unset($d); return $list; } /** 按 uuid 查设备 */ public static function findByUuid(string $uuid): ?array { if ($uuid === '') { return null; } try { $dev = Db::name('iot_device')->where('uuid', $uuid)->find(); } catch (\Throwable $e) { return null; } return $dev ?: null; } /** 按 id 查设备 */ public static function findById(int $id): ?array { if ($id <= 0) { return null; } try { $dev = Db::name('iot_device')->where('id', $id)->find(); } catch (\Throwable $e) { return null; } return $dev ?: null; } /** 会员登录账号(member_user.account),用于自动 ACL */ public static function memberAccount(int $memberId): string { if ($memberId <= 0) { return ''; } try { // member_user 主键为 uid(勿用 id) $acc = Db::name('member_user')->where('uid', $memberId)->value('account'); } catch (\Throwable $e) { return ''; } return (string) ($acc ?: ''); } /* ============================================================ * 内部:凭证与 ACL 维护 * ========================================================== */ protected static function genUuid(): string { return 'dev_' . strtolower(bin2hex(random_bytes(8))); } protected static function genSecret(int $len = 16): string { return substr(bin2hex(random_bytes(32)), 0, $len); } /** 生成 6 位数字配对码(机身贴纸) */ protected static function genPairCode(int $len = 6): string { $code = ''; for ($i = 0; $i < $len; $i++) { $code .= (string) random_int(0, 9); } return $code; } /** 生成激活令牌(设备出厂预置,一次性) */ protected static function genActivateToken(int $len = 32): string { return substr(bin2hex(random_bytes(32)), 0, $len); } /** 发放 MQTT 连接凭证:账号(username=uuid)+ 设备 ACL(激活/绑定共用) */ protected static function provisionCredentials(string $uuid, string $sn, string $secret, int $now): void { $acc = Db::name('iot_auth')->where('username', $uuid)->find(); if ($acc) { Db::name('iot_auth')->where('id', $acc['id'])->update([ 'password' => Auth::hashPassword($secret), 'status' => 1, 'remark' => '智能设备:' . $sn, 'update_at' => $now, ]); } else { Db::name('iot_auth')->insert([ 'username' => $uuid, 'password' => Auth::hashPassword($secret), 'is_superuser' => 0, 'status' => 1, 'remark' => '智能设备:' . $sn, 'create_at' => $now, 'update_at' => $now, ]); } self::syncDeviceAcl($uuid, $sn); self::ensureSensorConfig($uuid); } /** * 确保设备有传感器配置(配置真源在服务端,设备上线订阅 retained config 即可拿到) * 幂等且失败不影响注册/激活/绑定主流程 */ protected static function ensureSensorConfig(string $uuid): void { if ($uuid === '') { return; } try { SensorConfigService::ensureDefault($uuid); } catch (\Throwable $e) { // 配置下发失败不应阻塞设备接入(用户可在后台补配) } } /** 设备自身 ACL:/devices/%u/# 全权限(%u 会替换为 uuid) */ protected static function syncDeviceAcl(string $uuid, string $sn): void { Db::name('iot_acl')->where('remark', 'auto:device:' . $uuid)->delete(); Db::name('iot_acl')->insert([ 'target_type' => 'user', 'target' => $uuid, 'topic' => '/devices/%u/#', 'access' => 3, 'allow' => 1, 'sort' => 100, 'remark' => 'auto:device:' . $uuid, 'create_at' => time(), ]); } /** 会员直连 ACL:state 只读 + cmd 可写(仅在允许会员登录时生成) */ protected static function syncMemberAcl(int $memberId, string $uuid): void { $account = self::memberAccount($memberId); if ($account === '') { return; } self::clearMemberAcl($uuid); $now = time(); $rows = [ [ 'target_type' => 'user', 'target' => $account, 'topic' => sprintf(self::TOPIC_STATE, $uuid), 'access' => 1, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':state', 'create_at' => $now, ], [ 'target_type' => 'user', 'target' => $account, 'topic' => sprintf(self::TOPIC_STATUS, $uuid), 'access' => 1, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':status', 'create_at' => $now, ], [ 'target_type' => 'user', 'target' => $account, 'topic' => sprintf(self::TOPIC_CMD, $uuid), 'access' => 2, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':cmd', 'create_at' => $now, ], [ 'target_type' => 'user', 'target' => $account, 'topic' => '/notify/' . $account . '/#', 'access' => 1, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':notify', 'create_at' => $now, ], ]; Db::name('iot_acl')->insertAll($rows); } protected static function clearMemberAcl(string $uuid): void { Db::name('iot_acl')->where('remark', 'like', 'auto:member:' . $uuid . '%')->delete(); } /** * 重新同步某会员名下全部设备的 MQTT ACL(自愈) * * 为什么需要:ACL 是「生成时快照」——后来新增的自动规则(如 2.2.9 的 * /devices/{uuid}/status 订阅,用于 LWT 在线态)不会自动补到老设备的 ACL 上, * 老用户升级 App 后会订阅被拒、在线态永远不刷新。 * 触发点:App 每次换取 MQTT ticket 时调用(幂等,设备量小,代价可忽略)。 * * @return int 同步的设备数 */ public static function resyncMemberAcl(int $memberId): int { if ($memberId <= 0 || !self::allowMemberMqtt()) { return 0; } $n = 0; try { $uuids = Db::name('iot_device') ->where('member_id', $memberId)->where('uuid', '<>', '') ->column('uuid'); if (empty($uuids)) { return 0; } // 已有 :status 规则的设备直接跳过,避免每次换 ticket 都全量重建 $remarks = []; foreach ($uuids as $uuid) { $remarks[] = 'auto:member:' . $uuid . ':status'; } $have = Db::name('iot_acl')->whereIn('remark', $remarks)->column('remark'); $haveMap = array_flip(array_map('strval', $have)); foreach ($uuids as $uuid) { if (isset($haveMap['auto:member:' . $uuid . ':status'])) { continue; } self::syncMemberAcl($memberId, (string) $uuid); $n++; } } catch (\Throwable $e) { } return $n; } /** 是否开启框架会员直连 Broker(决定是否生成会员 ACL) */ protected static function allowMemberMqtt(): bool { try { $saved = AddonService::config('iot'); } catch (\Throwable $e) { $saved = []; } return !empty($saved['allow_member_login']); } }