// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\service; use think\facade\Db; /** * 传感器位置配置服务 —— 配置的唯一真源(Single Source of Truth) * * 为什么配置不放报文里: * 1) 位置列表必须在设备离线时也能渲染(App 打开详情页不能空白),只能来自服务端; * 2) alias/upper/lower/gap 是「月级」静态数据,塞进秒级 state 报文里是纯浪费(约占报文体积一半)。 * * 三方一致(服务端 → 设备 → App): * 后台/App 保存 → iot_sensor_cfg(真源)+ iot_device.cfg_ver++ * → publish retained /devices/{uuid}/config(带 cfg_ver) * → 设备写 NVS + 回 /devices/{uuid}/ack 的 config_ack(带 cfg_ver) * → 后台/App 比对 cfg_ver 与 sync_ver 显示「已生效 / 待设备上线生效」 * App 另经 HTTP 读同一份配置渲染骨架,state 只负责填值。 * * 字段约定:对外统一 index/alias/probes/upper/lower/gap/enabled(index = 位置序号,App 按 index 对齐而非数组顺序) */ class SensorConfigService { /** 配置下发主题(retained,设备订阅;设备 ACL `/devices/%u/#` 已覆盖) */ public const TOPIC_CONFIG = '/devices/%s/config'; /** 设备应答主题(非 retained) */ public const TOPIC_ACK = '/devices/%s/ack'; /** 位置数上限(与固件 OneWire 总线数 A/B/C 一致) */ public const MAX_POS = 3; /** 单位置探头数上限 */ public const MAX_PROBE = 3; /** 默认超温上限(℃) */ public const DEF_UPPER = 85.0; /** 默认低温下限(℃) */ public const DEF_LOWER = 0.0; /** 默认位置内温差告警阈值(0=关闭) */ public const DEF_GAP = 0.0; public static function configTopic(string $uuid): string { return sprintf(self::TOPIC_CONFIG, $uuid); } public static function ackTopic(string $uuid): string { return sprintf(self::TOPIC_ACK, $uuid); } /* ============================================================ * 表 / 列自愈 * ========================================================== */ /** 确保 iot_sensor_cfg 表存在(老库/未执行 install.sql 亦可用) */ public static function ensureTable(): bool { $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_sensor_cfg` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `device_uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid', `pos_index` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '位置序号(OneWire总线A/B/C,0起)', `alias` varchar(32) NOT NULL DEFAULT '' COMMENT '位置名称(如 进水/回水)', `probes` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '该位置探头数(1~3)', `upper` decimal(6,2) NOT NULL DEFAULT '85.00' COMMENT '超温上限(℃)', `lower` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '低温下限(℃)', `gap` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '位置内温差告警阈值(0=关闭)', `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_dev_pos` (`device_uuid`,`pos_index`), KEY `idx_device` (`device_uuid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='传感器位置配置表(配置真源)'"); DeviceService::ensureColumns(); return true; } catch (\Throwable $e) { return false; } } /* ============================================================ * 读取 * ========================================================== */ /** * 读取某设备配置(含同步状态,供 App / 后台 / 下发共用) * * @param bool $onlyEnabled 仅返回启用的位置(下发设备时用) * @return array{cfg_ver:int,sync_ver:int,sync_at:int,applied:bool,sensors:array} */ public static function get(string $uuid, bool $onlyEnabled = false): array { $uuid = trim($uuid); if ($uuid === '') { return ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []]; } self::ensureTable(); $dev = Db::name('iot_device')->where('uuid', $uuid)->find() ?: []; $ver = (int) ($dev['cfg_ver'] ?? 0); $sync = (int) ($dev['sync_ver'] ?? 0); $q = Db::name('iot_sensor_cfg')->where('device_uuid', $uuid); if ($onlyEnabled) { $q->where('enabled', 1); } $rows = $q->order('pos_index', 'asc')->select()->toArray(); $sensors = []; foreach ($rows as $r) { $sensors[] = self::format($r); } return [ 'cfg_ver' => $ver, 'sync_ver' => $sync, 'sync_at' => (int) ($dev['sync_at'] ?? 0), 'applied' => $ver > 0 && $sync >= $ver, 'sensors' => $sensors, ]; } /** * 批量取多个设备的配置(按 uuid 分组)—— 供**设备列表**接口内联配置用 * * 为什么要批量:列表接口是 N 台设备一把返回,若在 foreach 里逐台调 get() 就是 N+1 * (get() 自己还要各查一次 iot_device)⇒ 设备一多就把接口拖垮。 * 这里固定 2 条 SQL:一条查版本、一条查位置配置,与设备台数无关。 * * @param string[] $uuids * @return array [uuid => ['cfg_ver'=>int,'sync_ver'=>int,'sync_at'=>int,'applied'=>bool,'sensors'=>array]] */ public static function getAll(array $uuids, bool $onlyEnabled = false): array { $clean = []; foreach ($uuids as $u) { $u = trim((string) $u); if ($u !== '' && !isset($clean[$u])) { $clean[$u] = true; } } $out = []; if (!$clean) { return $out; } $uuids = array_keys($clean); self::ensureTable(); // 缺省值:任何一台设备都要有这几个键,App 端才能无脑取值 foreach ($uuids as $u) { $out[$u] = ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []]; } // 1) 版本信息(iot_device.cfg_ver / sync_ver)—— 一次查完 try { $devs = Db::name('iot_device') ->whereIn('uuid', $uuids) ->field('uuid,cfg_ver,sync_ver,sync_at') ->select() ->toArray(); foreach ($devs as $d) { $u = (string) $d['uuid']; if (!isset($out[$u])) { continue; } $ver = (int) ($d['cfg_ver'] ?? 0); $sync = (int) ($d['sync_ver'] ?? 0); $out[$u]['cfg_ver'] = $ver; $out[$u]['sync_ver'] = $sync; $out[$u]['sync_at'] = (int) ($d['sync_at'] ?? 0); $out[$u]['applied'] = $ver > 0 && $sync >= $ver; } } catch (\Throwable $e) { // 老库缺列:保持缺省值,绝不因为配置信息拖垮设备列表 } // 2) 位置配置(iot_sensor_cfg)—— 一次查完,再按 uuid 分组 try { $q = Db::name('iot_sensor_cfg')->whereIn('device_uuid', $uuids); if ($onlyEnabled) { $q->where('enabled', 1); } $rows = $q->order('pos_index', 'asc')->select()->toArray(); foreach ($rows as $r) { $u = (string) $r['device_uuid']; if (!isset($out[$u])) { continue; } // format() 是服务端统一出口:index/alias/probes/upper/lower/gap/enabled // ⇒ 列表、deviceConfig 接口两处的字段完全一致,App 端可共用一套解析 $out[$u]['sensors'][] = self::format($r); } } catch (\Throwable $e) { // iot_sensor_cfg 不存在(老库)等:留空数组,App 端会回退到 deviceConfig 接口 } return $out; } /** * 单条配置对外格式(App / 后台 / 设备列表共用同一出口) * * 🔑 位置序号对外统一叫 `index`(2026-09-15 统一)—— 与下行 config 报文的键名一致, * 也避免单字母键在 HTTP JSON 里看不出含义。 * ⚠️ 改这个键名必须**同步改三处消费方**,否则位置序号会静默变 0(不报错、只是错): * SensorPointService(按位置取阈值落库)、App device-panel、后台 device.html。 * ⚠️ 下行 config 报文里的位置序号**同样叫 `index`**(2026-09-15 与 HTTP 字段统一,且只有这 * 一个键)。它也是**下行独有**的字段:上行 state 靠数组下标定位(省 10B/位置),而下行 * 只发启用位置、下标与序号会错开 ⇒ `index` 必须带。详见 push() 的注释。 */ protected static function format(array $r): array { return [ 'index' => (int) $r['pos_index'], 'alias' => (string) $r['alias'], 'probes' => (int) $r['probes'], 'upper' => (float) $r['upper'], 'lower' => (float) $r['lower'], 'gap' => (float) $r['gap'], 'enabled' => (int) $r['enabled'], 'update_at' => (int) $r['update_at'], ]; } /** 当前服务端配置版本(iot_device.cfg_ver) */ public static function ver(string $uuid): int { try { return (int) (Db::name('iot_device')->where('uuid', $uuid)->value('cfg_ver') ?? 0); } catch (\Throwable $e) { return 0; } } /* ============================================================ * 保存 / 删除 * ========================================================== */ /** * 保存设备配置(全量覆盖式:后台弹窗即整体编辑语义,简单且不会残留脏位置) * * @param array $sensors [['index'=>0,'alias'=>'进水','probes'=>2,'upper'=>85,'lower'=>0,'gap'=>3], ...] * @param bool $push 保存后是否立即下发(retained) * @return array 保存后的完整配置 * @throws \InvalidArgumentException|\RuntimeException */ public static function save(string $uuid, array $sensors, bool $push = true): array { $uuid = trim($uuid); if ($uuid === '') { throw new \InvalidArgumentException('设备 uuid 不能为空'); } self::ensureTable(); $dev = Db::name('iot_device')->where('uuid', $uuid)->find(); if (!$dev) { throw new \RuntimeException('设备不存在'); } if (empty($sensors)) { throw new \InvalidArgumentException('至少需要保留一个传感器位置'); } $clean = []; foreach ($sensors as $idx => $s) { $row = self::normalize($s, $idx); $clean[$row['pos_index']] = $row; // 同序号后写覆盖 } ksort($clean); if (count($clean) > self::MAX_POS) { throw new \InvalidArgumentException('位置数超出上限(最多 ' . self::MAX_POS . ' 个)'); } $now = time(); Db::startTrans(); try { Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->delete(); $rows = []; foreach ($clean as $row) { $row['device_uuid'] = $uuid; $row['create_at'] = $now; $row['update_at'] = $now; $rows[] = $row; } Db::name('iot_sensor_cfg')->insertAll($rows); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw new \RuntimeException('保存配置失败:' . $e->getMessage()); } $ver = self::bumpVer($uuid); if ($push) { self::push($uuid, $ver); } return self::get($uuid); } /** 归一化并校验单个位置(非法值自动纠正而非报错,避免后台弹窗体验割裂) */ protected static function normalize($s, int $fallbackIndex): array { $s = (array) $s; /* 位置序号:get/save 对外统一 `index`;`i`(App ≤2026-09-15 提交的旧键)与 `pos_index`(DB 列名)继续认,不要删 —— 少认一个键就会静默回退成「数组下标」, 而 App 提交顺序一变位置就整体错位,更糟的是多个位置会挤到同一序号、 被 save() 的全量覆盖语义合并成一个(2026-09-15 现场事故)。 */ if (isset($s['index'])) { $pos = (int) $s['index']; } elseif (isset($s['i'])) { $pos = (int) $s['i']; } elseif (isset($s['pos_index'])) { $pos = (int) $s['pos_index']; } else { $pos = $fallbackIndex; } if ($pos < 0 || $pos >= self::MAX_POS) { throw new \InvalidArgumentException('位置序号超出范围(0 ~ ' . (self::MAX_POS - 1) . ')'); } $alias = trim((string) ($s['alias'] ?? ($s['title'] ?? ''))); if ($alias === '') { $alias = '位置' . ($pos + 1); } if (mb_strlen($alias) > 16) { $alias = mb_substr($alias, 0, 16); } /* 探头数:HTTP 入口沿用长名 probes,顺带认 pn(与 MQTT 下行的新键同名,方便将来统一) */ $probes = (int) ($s['probes'] ?? $s['pn'] ?? 1); if ($probes < 1) { $probes = 1; } if ($probes > self::MAX_PROBE) { $probes = self::MAX_PROBE; } $upper = round((float) ($s['upper'] ?? self::DEF_UPPER), 2); $lower = round((float) ($s['lower'] ?? self::DEF_LOWER), 2); $gap = round((float) ($s['gap'] ?? self::DEF_GAP), 2); if ($lower > $upper) { // 上下限填反 → 自动交换 $tmp = $upper; $upper = $lower; $lower = $tmp; } if ($gap < 0) { $gap = 0.0; } $enabled = isset($s['enabled']) ? ((int) $s['enabled'] ? 1 : 0) : 1; return [ 'pos_index' => $pos, 'alias' => $alias, 'probes' => $probes, 'upper' => $upper, 'lower' => $lower, 'gap' => $gap, 'enabled' => $enabled, ]; } /** 配置版本自增(设备 ack 的 sync_ver 与之比对即知是否生效) */ public static function bumpVer(string $uuid): int { DeviceService::ensureColumns(); $dev = Db::name('iot_device')->where('uuid', $uuid)->find(); if (!$dev) { throw new \RuntimeException('设备不存在'); } $ver = (int) ($dev['cfg_ver'] ?? 0) + 1; Db::name('iot_device')->where('id', $dev['id'])->update([ 'cfg_ver' => $ver, 'update_at' => time(), ]); return $ver; } /** 删除某设备全部配置(删除设备时清理) */ public static function removeByUuid(string $uuid): void { try { Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->delete(); } catch (\Throwable $e) { } } /* ============================================================ * 下发 / 回执 * ========================================================== */ /** * 下发配置到设备(retained → 设备离线时也会在重连订阅瞬间收到,无需服务端重推) * * @param int|null $ver 指定版本号,null 取当前 cfg_ver */ public static function push(string $uuid, ?int $ver = null): bool { $uuid = trim($uuid); if ($uuid === '') { return false; } $cfg = self::get($uuid, true); if ($ver === null) { $ver = $cfg['cfg_ver']; } /* 🛡 空配置绝不下发 —— 这是一条能「把设备打瘫且双方都不知情」的路径: 固件 onConfigJson 的语义是「先 POS_MAX 个位置全置 used=false,再按下发的数组逐个启用」 ⇒ 收到 pos:[] 就等于**把 NVS 里的配置整体清空**,此后设备每周期上报 pos:[], 而它 ack 的还是那条 cv(成功)⇒ 服务端 sync_ver == cfg_ver ⇒ 后台显示「已生效」, 双方都以为没事,**没有任何自动纠正的途径**(只能靠人再保存一次配置)。 所以真源一行都没有时(被清空/误删)宁可不下发 —— 该做的是补齐配置,不是把设备打瘫。 注意「把位置全停用」不在此列:那是用户显式意图,数组非空、照发。 */ if (!$cfg['sensors'] && !self::get($uuid)['sensors']) { return false; } /* 报文(v1.7.2 起只剩这一种版式,键名与上行 state 完全一致): * {"cv":7,"pos":[{"index":0,"alias":"进水","pn":2,"upper":85,"lower":0,"gap":3}]} * * 🔑 这里曾经叠了三层「灰度兼容」,2026-09-15 全部删除 —— 现场只有两台自用设备、 * 且都会重烧固件 ⇒ 兼容的收益是 0,代价却是一个字段挂两把钥匙: * ① 键名长短不一:下行写长名 positions/probes、上行写 pos/pr ⇒ 现在统一为短键; * ② 数组复制两份:多出一份旧式 data[](≤1.4.0 只认它)⇒ 现在只有 pos; * ③ 标量新旧同发:cfg_ver/probes 与 cv/pn 同值双发 ⇒ 现在只有 cv/pn。 * 教训:每多一个「同义键」就多一把备用钥匙,改键名时只改一边就**静默拆锁** * (2026-09-15 现场:位置序号全变 0、数据全填位置 0、编辑一行改掉所有行)。 * 🔴 升级顺序:**先烧固件 ≥1.7.2,再上线本改动**。旧固件只认 positions/data 与 * cfg_ver/probes,收到本条报文会 ack "missing pos"(配置不生效,但**不会误报成功**) * —— 一眼就能看出来,总好过以前那种「ack 成功、配置没落地」的静默错。 * 语义不变:全量覆盖,数组里没有的位置一律停用。 */ $pos = []; foreach ($cfg['sensors'] as $s) { /* ⚠️ `index` 必须带:sensors 是**只含启用位置**的(get(uuid, true)), 所以数组下标 ≠ 位置序号 —— 例如位置 0 停用、位置 2 启用,它会排在下标 0 上。 这就是「上行没有 index、下行必须有 index」的原因(上行靠下标定位、且未启用 位置写 null 占位把下标撑住;下行不发废位置,下标会塌缩)。 */ $pos[] = [ 'index' => $s['index'], 'alias' => $s['alias'], 'pn' => $s['probes'], // 该位置「期望探头数」(1 ~ MAX_PROBE) 'upper' => $s['upper'], 'lower' => $s['lower'], 'gap' => $s['gap'], ]; } $payload = json_encode( ['cv' => $ver, 'pos' => $pos], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ($payload === false) { return false; } return Store::enqueueManual(self::configTopic($uuid), $payload, 1, 1, 'admin'); } /** * 按**当前版本**重发一次配置(不 bumpVer,不产生版本跳动) * * 用途:设备每次上线(/status 带 pins)时调用,专治「设备端配置与服务端不一致」这类 * **没有自愈途径**的僵局 —— 最典型的就是设备曾被一条空配置(pos:[])打成全停用: * 设备侧 cfg 被清空、cv 停在那一刻,服务端 sync_ver 恰好也等于那个版本,双方都认为 * 「已生效」,于是谁都不会主动纠正,只能等人工再保存一次配置。 * 每次上线同版本重发是安全的:固件 onConfigJson **不做版本大小比较**,收到即应用 * (见固件该函数的 🔑 注释);retained 投递 ⇒ 设备重连订阅瞬间就能拿到。 * * @return bool 是否已入队下发(false = 无启用位置 / 真源为空,见 push() 的 🛡 说明) */ public static function repush(string $uuid): bool { $uuid = trim($uuid); if ($uuid === '') { return false; } $cfg = self::get($uuid, true); if (!$cfg['sensors']) { return false; } return self::push($uuid, $cfg['cfg_ver']); } /** * 处理设备 config_ack(Broker 收到 /devices/{uuid}/ack 时调用) * 成功则记 sync_ver,供后台/App 显示「已生效」;失败保持 0 → 显示「待生效」并提示原因 * * @param array $payload 已解析的 ack 报文 */ public static function onAck(string $uuid, array $payload): void { $type = (string) ($payload['type'] ?? ''); if ($type !== '' && $type !== 'config_ack') { return; // cmd_ack 等其它应答不处理 } DeviceService::ensureColumns(); /* 配置版本:`cv`(与上行 state / 下行 config 同一套短键);旧键 cfg_ver 的兜底已随 老固件兼容一起删除(2026-09-15)—— 固件 ack 也只剩 cv,见固件 sendAck() */ $ver = (int) ($payload['cv'] ?? 0); $ok = !empty($payload['success']); try { Db::name('iot_device')->where('uuid', $uuid)->update([ 'sync_ver' => $ok ? $ver : 0, 'sync_at' => time(), 'update_at' => time(), ]); } catch (\Throwable $e) { } } /* ============================================================ * 默认配置(设备首次纳管) * ========================================================== */ /** * 确保设备至少有一份配置(设备激活/首次注册时调用) * 已有配置则原样返回,不覆盖用户设置 * * @param int $probes 默认探头数(新建时使用) * @param string $alias 默认位置名,空则「位置1」 */ public static function ensureDefault(string $uuid, int $probes = 2, string $alias = ''): array { $uuid = trim($uuid); if ($uuid === '') { return ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []]; } self::ensureTable(); $exist = 0; try { $exist = (int) Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->count(); } catch (\Throwable $e) { $exist = 0; } if ($exist > 0) { return self::get($uuid); } return self::save($uuid, [[ 'index' => 0, 'alias' => $alias !== '' ? $alias : '位置1', 'probes' => $probes, 'upper' => self::DEF_UPPER, 'lower' => self::DEF_LOWER, 'gap' => self::DEF_GAP, ]]); } /** * 按设备上报的引脚表**自动补齐**缺失的位置(只补不覆盖) * * 为什么需要它:位置 ↔ GPIO 是**下标映射**(固件编译期常量 ONEWIRE_PINS[i]), * 而设备只采「服务端配置里启用的位置」—— 新设备只有 ensureDefault 建的 * pos_index=0(= GPIO3),于是探头插在 GPIO4/GPIO10(= 位置 1/2)上**永远不出数**。 * 现场典型困惑:「只有某根针脚能出数据,断掉它接别的都没数」。 * 设备上线时 /status 会带全表 pins(长度 = POS_MAX,与启不启用无关)⇒ 服务端据此 * 把缺失的位置补上,于是插 3/4/10 任意一路都能出数,无需重烧固件、无需手动加位置。 * * 三条语义(都很重要): * ① **只补不覆盖**:已存在的 pos_index 一行都不动 —— 用户改过的别名/阈值/探头数全保留; * ② **停用不复活**:enabled=0 的行同样算「已存在」,不会被重新启用 * (注意这是本功能的固有语义:把位置**删除**了下一次上线还会补回来, * 想彻底不要某个位置应该「停用」而不是删除); * ③ **幂等**:每次上线都会被调用;无新增则直接返回 0,不 bumpVer、不重复下发。 * * 老固件(≤1.6.2)的 /status 不含 pins ⇒ 这里不会被触发,行为与从前完全一致。 * * @param int[] $pins 下标 = 位置序号(DeviceService::getPins 的原样传入) * @return int 实际新增的位置数(0 = 无需改动 / 失败) */ public static function fillMissing(string $uuid, array $pins): int { $uuid = trim($uuid); if ($uuid === '' || !$pins) { return 0; } self::ensureTable(); $n = min(count($pins), self::MAX_POS); // pins 长度 = POS_MAX,超出上限的部分忽略 try { $exist = Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->column('pos_index'); } catch (\Throwable $e) { return 0; } $exist = array_map('intval', is_array($exist) ? $exist : []); $now = time(); $rows = []; for ($i = 0; $i < $n; $i++) { if (in_array($i, $exist, true)) { continue; // 已存在(含停用)→ 绝不动它 } $rows[] = [ 'device_uuid' => $uuid, 'pos_index' => $i, 'alias' => '位置' . ($i + 1), 'probes' => 1, 'upper' => self::DEF_UPPER, 'lower' => self::DEF_LOWER, 'gap' => self::DEF_GAP, 'enabled' => 1, 'create_at' => $now, 'update_at' => $now, ]; } if (!$rows) { return 0; // 幂等:无事发生 } try { Db::name('iot_sensor_cfg')->insertAll($rows); } catch (\Throwable $e) { return 0; // 唯一键撞车等 → 不阻断设备上线 } try { $ver = self::bumpVer($uuid); self::push($uuid, $ver); // retained:设备重连订阅瞬间即可收到 } catch (\Throwable $e) { // 版本自增 / 下发失败不回滚已补的行:下次上线还会走到这里 } return count($rows); } }