* @Date: 2026-09-18 01:39:38 * @LastEditors: YwxApp * @LastEditTime: 2026-09-18 01:41:50 * @Description: * @FilePath: \ywxapp_dev\addon\iot\controller\api\Monitor.php * @CustomString: Copyright (c) 2026 YwxApp */ // +---------------------------------------------------------------------- // | YwxApp [ WE CAN DO IT JUST THINK ] // +---------------------------------------------------------------------- // | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved. // +---------------------------------------------------------------------- // | Author: ywxapp // +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\controller\api; use think\facade\Db; use addon\iot\service\DeviceService; use addon\iot\service\ShareService; use addon\iot\service\AlertService; use addon\iot\service\DeviceLogService; use addon\iot\service\SensorConfigService; use addon\iot\service\SensorPointService; /** * App 端监测域 API(需框架会员 JWT 登录) * 路由前缀:/iot/api/monitor/...(监测域;旧 /iot/api/client/* 兼容别名迁移期保留) * * 覆盖:传感器位置配置(读写)· 历史曲线 · 设备日志 · 告警。 * 多路数值品类(温湿度/液位/气压等)共用本域,见 ClientBase 头注释的拆分原则。 */ class Monitor extends ClientBase { /* ============================================================ * 传感器配置(配置真源在服务端:App 用它渲染面板骨架,state 只负责填值) * ========================================================== */ /** * 读取设备传感器配置 * GET /iot/api/monitor/deviceConfig?uuid=xxx * * 返回 {cfg_ver, sync_ver, sync_at, applied, pins, sensors:[{index,alias,probes,upper,lower,gap,enabled}]} * - 设备离线时同样可用(这正是配置放服务端的核心原因) * - applied=false 表示设备尚未回执生效(离线时改的配置,等它上线自动收到) * - pins:总线 GPIO 表(下标 = 位置序号)。pin 是设备编译期常量,v1.7.0 起不在 * state 报文里逐条上报,改由设备上线时在 /status 报一次、服务端缓存后在此下发; * 从没上报过(≤1.6.2 老固件)返回 [],App 此时靠 state 里的 pin 自行补 */ public function deviceConfig() { $uid = $this->memberId(); $uuid = trim((string) $this->request->get('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } if (!DeviceService::findByUuid($uuid)) { return $this->result->apiError('设备不存在'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) { return $this->result->apiError('无权查看该设备'); } $cfg = SensorConfigService::get($uuid); $cfg['uuid'] = $uuid; /* 总线 GPIO 表(下标 = 位置序号):pin 是设备编译期常量,不再逐条进 state 报文, 由设备上线时在 /status 报一次 → DeviceService 缓存 → 这里下发给 App 显示。 没缓存过(≤1.6.2 老固件)返回 [],App 退回用 state 里带的 pin(老固件仍会带)。 */ $cfg['pins'] = DeviceService::getPins($uuid); $this->result->apiSuccess($cfg); } /** * 保存设备传感器配置(保存即下发 retained,设备在线秒级生效、离线则上线即生效) * POST /iot/api/monitor/saveDeviceConfig * param: uuid(必填), sensors(JSON 数组:{index,alias,probes,upper,lower,gap,enabled}) * ⚠️ 位置序号键统一 `index`;旧键 `i` 仍兼容(老 App 提交不会错位),别删兼容分支 */ public function saveDeviceConfig() { $uid = $this->memberId(); $uuid = trim((string) $this->request->post('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_CTRL)) { return $this->result->apiError('无权限修改该设备配置(只读共享)'); } $sensors = $this->request->post('sensors', ''); if (is_string($sensors)) { $sensors = json_decode($sensors, true); } if (!is_array($sensors)) { return $this->result->apiError('sensors 参数须为数组或 JSON 数组字符串'); } try { $cfg = SensorConfigService::save($uuid, $sensors); } catch (\Throwable $e) { return $this->result->apiError($e->getMessage()); } $cfg['uuid'] = $uuid; $this->result->apiSuccess($cfg, '配置已保存并下发(设备上线后自动生效)'); } /** * 传感器历史曲线(服务端结构化时序) * GET /iot/api/monitor/sensorSeries?uuid=xxx&pos=0&from=1700000000&to=1700003600&limit=500 * * 返回「稀疏点」(每条带 interval = 本条覆盖秒数);需要等间隔曲线时由客户端按 * ts → ts+interval 前向填充展开。不在服务端展开的原因:温变上报 10s~120s 一条, * 展开会放大数倍且大量是重复值,纯浪费带宽。 * * 每条形状**刻意沿用 HTTP 长名**(temp/temp_diff/probes[{id,temp}],null 即无读数)—— * 与 MQTT 上行报文的两字母键(t/df/pr)是两套:压字节只发生在设备↔服务端之间, * 服务端往外(HTTP / App / 后台)一律用可读长名,换报文键名不必动 App 的 HTTP 链路。 * alert_mask 为云端复核告警位图(bit j = 槽位 j 越限,bit7 = 位置内温差超限), * 供 App 标注异常区间。 */ public function sensorSeries() { $uid = $this->memberId(); $uuid = trim((string) $this->request->get('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } if ($uuid === '' || !DeviceService::findByUuid($uuid)) { return $this->result->apiError('设备不存在'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) { return $this->result->apiError('无权查看该设备'); } $pos = (int) $this->request->get('pos', 0); $from = (int) $this->request->get('from', 0); $to = (int) $this->request->get('to', 0); $limit = (int) $this->request->get('limit', 500); $points = SensorPointService::series($uuid, $pos, $from, $to, $limit); $data = [ 'uuid' => $uuid, 'pos' => $pos, 'from' => $from, 'to' => $to, 'count' => count($points), 'points' => $points, ]; /* debug=1:附查询诊断(库名/表名/实际 SQL/各条件行数/最新原始行/异常原文)。 用于定位「接口 count=0 但直接跑 SQL 有数据」这类静默故障;正常调用不受影响。 */ if ((int) $this->request->get('debug', 0) === 1) { $data['debug'] = SensorPointService::seriesDebug($uuid, $pos, $from, $to); } return $this->result->apiSuccess($data); } /** * 传感器分钟级聚合曲线(时间加权平均) * GET /iot/api/monitor/sensorAvg?uuid=xxx&pos=0&step=60&window=30&from=&to=&limit=720 * * - step:桶大小(秒),默认 60 = 每分钟一点;可传 60/120/300/3600…,服务端钳到 [60, 86400] * - window:查"最近 N 分钟",默认 30;**仅在未传 from 时生效**(from = now - window*60) * —— 典型用法:最近 30 分钟每分钟一点(step=60&window=30)、最近 60 分钟每分钟一点(step=60&window=60) * - from/to:显式时间戳(秒),传了优先于 window;to 缺省 = now * - limit:最多返回桶数(默认 720,即 step=60 时最多覆盖 12 小时) * - debug=1:附加查询诊断(库名/表名/实际 SQL/各条件行数/最新原始行/异常原文), * 用于定位「直接跑 SQL 有数据、本接口却空」;正常调用不传即零开销 * - avg 为时间加权平均(SUM(t×秒)/SUM(秒)),min/max 为桶内极值,coverage_s 为桶内数据覆盖秒数 * (coverage_s < step 说明该分钟内有断档,App 可据此画"数据缺口") * - ts 为桶起点(UTC 对齐),App 显示时按本地时区格式化 * * 与 sensorSeries 的分工:sensorSeries 返回原始稀疏点(细粒度);本接口返回聚合桶(点数可控)。 */ public function sensorAvg() { $uid = $this->memberId(); $uuid = trim((string) $this->request->get('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } if ($uuid === '' || !DeviceService::findByUuid($uuid)) { return $this->result->apiError('设备不存在'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) { return $this->result->apiError('无权查看该设备'); } $pos = (int) $this->request->get('pos', 0); $from = (int) $this->request->get('from', 0); $to = (int) $this->request->get('to', 0); $step = (int) $this->request->get('step', 60); $window = (int) $this->request->get('window', 30); $limit = (int) $this->request->get('limit', 720); if ($from <= 0 && $window > 0) { $from = time() - $window * 60; // window 仅在未显式传 from 时生效 } if ($to <= 0) { $to = time(); } $buckets = SensorPointService::seriesAvg($uuid, $pos, $step, $from, $to, $limit); $data = [ 'uuid' => $uuid, 'pos' => $pos, 'step' => $step, 'from' => $from, 'to' => $to, 'count' => count($buckets), 'points' => $buckets, ]; /* debug=1:与 sensorSeries 同一套诊断(库名/表名/实际 SQL/各条件行数/最新原始行/异常原文)。 「SQL 明明有数据、聚合接口却 count=0」时先看这里,不用再猜。 */ if ((int) $this->request->get('debug', 0) === 1) { $data['debug'] = SensorPointService::seriesDebug($uuid, $pos, $from, $to); } return $this->result->apiSuccess($data); } /** * 最近 N 分钟窗口平均(按位置汇总) * GET /iot/api/monitor/recentAvg?uuid=xxx&window=10&pos= * * 与 sensorAvg 的分工: * - sensorAvg 返回按 step 分桶的曲线序列(适合画趋势) * - recentAvg 返回每个位置一条汇总值(首屏概览 / 阈值刷新 / 排障都够用) * * - window:最近多少分钟,默认 10,范围 [1, 1440](被服务端钳位) * - pos:可选,只返回某个位置(>=0),默认 -1 = 全部位置 * - 每位置返回 {index, alias, avg, min, max, samples, coverage_s, last_ts, data_ok} * 无样本的位置 avg=null 且 data_ok=false(前端可据此显示「无数据」而非「0 度」) * * 加权方式与 sensorAvg 一致:SUM(t×有效秒)/SUM(秒),避免温变期间简单 AVG 失真。 */ public function recentAvg() { $uid = $this->memberId(); $uuid = trim((string) $this->request->get('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } if ($uuid === '' || !DeviceService::findByUuid($uuid)) { return $this->result->apiError('设备不存在'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) { return $this->result->apiError('无权查看该设备'); } $window = (int) $this->request->get('window', 10); $pos = (int) $this->request->get('pos', -1); $result = SensorPointService::recentAvg($uuid, $window, $pos); $result['uuid'] = $uuid; $result['window_min'] = $window; return $this->result->apiSuccess($result); } /* ============================================================ * 设备日志 & 告警(P1:基础留痕与通知) * ========================================================== */ /** * 设备日志分页(设备维度留痕) * GET /iot/api/monitor/deviceLogs?uuid=xxx&page=1&size=20&direction=in|out */ public function deviceLogs() { $uid = $this->memberId(); $uuid = trim((string) $this->request->get('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } if ($uuid === '') { return $this->result->apiError('设备uuid不能为空'); } $dev = DeviceService::findByUuid($uuid); if (!$dev) { return $this->result->apiError('设备不存在'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) { return $this->result->apiError('无权查看该设备'); } $page = max(1, (int) $this->request->get('page', 1)); $size = min(100, max(1, (int) $this->request->get('size', 20))); $direction = trim((string) $this->request->get('direction', '')); $data = DeviceLogService::page($uuid, $page, $size, $direction); return $this->result->apiSuccess($data); } /** * 我的告警列表 * GET /iot/api/monitor/alertList?page=1&size=20&unread=0|1 */ public function alertList() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $page = max(1, (int) $this->request->get('page', 1)); $size = min(100, max(1, (int) $this->request->get('size', 20))); $onlyUnread = (int) $this->request->get('unread', 0) === 1; $data = AlertService::page($uid, $page, $size, $onlyUnread); return $this->result->apiSuccess($data); } /** * 标记告警已读(空 ids=全部) * POST /iot/api/monitor/readAlert * param: ids[](可选) */ public function readAlert() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $ids = $this->request->post('ids/a', []); $n = AlertService::markRead($uid, $ids); return $this->result->apiSuccess(['affected' => $n]); } /** * 未读告警数(App 角标轮询) * GET /iot/api/monitor/alertUnread */ public function alertUnread() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $data = AlertService::page($uid, 1, 1, true); return $this->result->apiSuccess(['unread' => $data['unread']]); } }