// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\service; use think\facade\Db; use think\facade\Log; /** * 传感器结构化时序服务(落库 + 云端复核判定 + 曲线查询) * * 与 DeviceLogService 的分工: * - iot_device_log :原始报文留痕(longtext payload),用于诊断/审计,不做统计; * - iot_sensor_point :解析后的结构化数值(一行 = 一个位置的一次上报), * 用于画曲线、算统计、云端阈值复核 —— longtext 里加不了数值索引。 * * 为什么必须存 iv:设备是「事件驱动稀疏上报」(温变 / 告警翻转 / 有效性变化才发, * 冷却 5s、兜底 120s),相邻两点可能隔几十秒。画图与统计必须按 ts → ts+iv 前向填充, * 否则曲线全是断点、均值会偏。 * * 为什么云端要再判一遍:固件 alertMaskOf() 是「实时兜底」(断网可用、零延迟), * 但它看不见自己固件有 bug、阈值版本没同步(cfg_ver 落后服务端)等情况。 * 云端拿同一份真源复核,兜住设备漏报 —— 阈值定义仍只有 iot_sensor_cfg 这一处。 * * 落库点:Broker 的 mqtt_message_published 钩子(QoS0/1 与 QoS2/PUBREL 两条路径)。 * 该钩子在 Workerman 长驻进程内执行 → 全部防御式 try/catch,绝不影响 Broker 主流程。 */ class SensorPointService { /** 单条上报最多入库的位置数(与固件 POS_MAX 一致) */ public const MAX_POS = 3; /** 云端复核告警抑制窗口(秒):窗口内已有该设备告警则不再补,避免双份刷屏 */ public const RECHECK_SUPPRESS = 600; /** 历史点默认保留天数(0 = 不清理) */ public const KEEP_DAYS = 90; /* ============================================================ * 表自愈 * ========================================================== */ /** 进程内建表标记:capture 每条上报都会调用,不能每条消息都跑一次 DDL */ private static bool $tableReady = false; /** 确保 iot_sensor_point 表存在(老库/未执行 install.sql 亦可用) */ public static function ensureTable(): bool { if (self::$tableReady) { return true; } $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_sensor_point` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid', `pos_index` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '位置序号(0起)', `ts` int unsigned NOT NULL DEFAULT '0' COMMENT '设备NTP秒(0=未对时,已取服务端接收时间)', `iv` smallint unsigned NOT NULL DEFAULT '0' COMMENT '本条数据覆盖秒数(前向填充用)', `t` decimal(6,2) DEFAULT NULL COMMENT '位置温度=有效探头均值', `df` decimal(6,2) DEFAULT NULL COMMENT '位置内极差', `v` tinyint(1) NOT NULL DEFAULT '0' COMMENT '1=位置有有效读数', `vs_json` varchar(255) NOT NULL DEFAULT '' COMMENT '逐探头明细 {\"A3F2\":25.6}', `ok_mask` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '探头有效位图', `alert_mask` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '云端复核告警位图(bit7=gap)', `cfg_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '上报时的配置版本', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_dev_pos_ts` (`uuid`,`pos_index`,`ts`), KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='传感器结构化时序(曲线/统计/云端复核)'"); return true; } catch (\Throwable $e) { return false; } } /* ============================================================ * 上报捕获(Broker 钩子入口) * ========================================================== */ /** * 捕获一条设备上报:解析 → 结构化落库 → 云端复核补告警 * * 只处理 /devices/{uuid}/state,其余主题立即返回(Broker 会收到全部流量, * 这里必须尽早短路,避免给无关消息做 json_decode)。 */ public static function capture(string $topic, string $payload): void { if (!preg_match('#^/devices/([^/]+)/state$#', $topic, $m)) { return; } $uuid = $m[1]; if ($uuid === '' || strlen($uuid) > 64) { return; } $data = json_decode($payload, true); /* 位置数组键名三代都认:pos(v1.7.0)/ positions(≥1.5.0)/ data(≤1.4.0)。 元素怎么定位(index / i / 数组下标)见下面 $i 的推导 */ $rawPos = is_array($data) ? ($data['pos'] ?? $data['positions'] ?? $data['data'] ?? null) : null; if (!is_array($rawPos) || !$rawPos) { return; } if (!self::ensureTable()) { return; } $now = time(); $ts = (int) ($data['ts'] ?? 0); if ($ts <= 0) { $ts = $now; // 设备未对时 → 用服务端接收时间兜底 } /* 覆盖秒数:iv(v1.7.0)/ interval(v1.6.x)/ iv(≤1.5.0 同名同义)—— 老固件本来就叫 iv,所以新键回退过来天然兼容 */ $iv = max(0, min(65535, (int) ($data['iv'] ?? $data['interval'] ?? 0))); /* 配置版本:cv(v1.7.0)/ cfg_ver(≤1.6.2) */ $cfgVer = (int) ($data['cv'] ?? $data['cfg_ver'] ?? 0); /* 阈值一律取自配置真源(不在这里另立一套),按位置号索引 */ $cfgByPos = []; try { $cfg = SensorConfigService::get($uuid, true); foreach ($cfg['sensors'] as $s) { /* 位置序号键:index(2026-09-15 统一)/ i(旧出口)—— 取不到会静默落到 0, 于是**所有位置都按位置 0 的阈值**复核(落库 alert_mask、曲线标注全错), 所以两个键都要认。 */ $cfgByPos[(int) ($s['index'] ?? $s['i'] ?? 0)] = $s; } } catch (\Throwable $e) { // 读配置失败不阻断落库:曲线数据本身仍有价值 } $rows = []; $alertRows = []; foreach (array_values($rawPos) as $k => $pos) { /* 非数组 = v1.6.0 的占位元素(该位置未启用)→ 跳过。 空数组 `[]` 也当占位:正常位置至少带 pin,空对象只可能来自 「固件把占位写成了 {} 而不是 null」这种异常(断言式兜底,不指望用上)。 */ if (!is_array($pos) || $pos === []) { continue; } /* 位置序号各版式: * ≤1.4.0 data[] 元素自带 i * v1.5.0 positions[] 元素自带 index * v1.6.0 positions[] **数组下标即位置序号**(没有 index,未启用位置写 null 占位) * v1.7.0 pos[] 与 v1.6.0 完全同构,只是数组键名 positions → pos * 显式键优先、都没有才用数组下标 —— 灰度期各版混跑都能落库 */ $i = (int) ($pos['index'] ?? $pos['i'] ?? $k); if ($i < 0 || $i >= self::MAX_POS) { continue; } /* 探头明细统一成「槽位 → {id, temp}」再往下走: * v1.7.0 pr[[id,temp]] —— 二元组(键名 probes → pr),下标即槽位 * v1.6.0 probes[[id,temp]] —— 二元组,下标即槽位(键名省掉,省约 12B/颗) * v1.5.0 probes[{id,temp}] —— 对象数组,temp=null 即该槽无读数 * ≤1.4.0 vs[]/ok[]/ids[] —— 三个平行数组按位对应 * 槽位下标 j 各版式都是「总线 ROM 搜索序第几颗」,语义一致。 */ $probes = []; /* 键名 pr(v1.7.0)优先、回落 probes(≤1.6.2)—— 现场老固件不会重烧,长期兼容 */ $rawProbes = $pos['pr'] ?? $pos['probes'] ?? null; if (is_array($rawProbes)) { foreach (array_values($rawProbes) as $j => $p) { if (is_array($p) && array_key_exists(0, $p)) { $pid = $p[0] ?? null; // v1.6.0 二元组 [身份, 温度] $pt = $p[1] ?? null; } else { $p = is_array($p) ? $p : []; $pid = $p['id'] ?? null; $pt = $p['temp'] ?? null; } $probes[$j] = [ 'id' => (is_string($pid) && $pid !== '') ? $pid : null, 'temp' => is_numeric($pt) ? round((float) $pt, 2) : null, ]; } } else { $vs = (isset($pos['vs']) && is_array($pos['vs'])) ? array_values($pos['vs']) : []; $ok = (isset($pos['ok']) && is_array($pos['ok'])) ? array_values($pos['ok']) : []; $ids = (isset($pos['ids']) && is_array($pos['ids'])) ? array_values($pos['ids']) : []; foreach ($vs as $j => $rawT) { $probes[$j] = [ 'id' => (isset($ids[$j]) && is_string($ids[$j]) && $ids[$j] !== '') ? $ids[$j] : null, 'temp' => !empty($ok[$j]) ? round((float) $rawT, 2) : null, ]; } } $c = $cfgByPos[$i] ?? null; $upper = $c ? (float) $c['upper'] : 0.0; $lower = $c ? (float) $c['lower'] : 0.0; $gap = $c ? (float) $c['gap'] : 0.0; /* 位置级:判「本位置有没有有效读数」并取温度/极差 * ≤1.4.0 v 标志 + t / df —— 用 v 判有效(旧版没有 null 语义) * v1.5.0 temp / temp_diff —— 值为 null 即无有效读数 * v1.6.0 t / df —— 同上,只是键名缩短 * ⚠️ 必须先判 v 是否存在:v1.6.0 的 t 与旧式同键名,只有 v 能区分二者 —— * 旧固件 v=0 时 t 照样带 0,若误当有效会把"没读数"存成 0.00℃ * ⚠️ 新式必须用 array_key_exists:值本身就是 null,isset 会返回 false */ if (array_key_exists('v', $pos)) { $vValid = !empty($pos['v']); $tVal = $vValid ? round((float) ($pos['t'] ?? 0), 2) : null; $dfVal = $vValid ? round((float) ($pos['df'] ?? 0), 2) : null; } else { $rawT = array_key_exists('t', $pos) ? $pos['t'] : ($pos['temp'] ?? null); $rawDf = array_key_exists('df', $pos) ? $pos['df'] : ($pos['temp_diff'] ?? null); $vValid = is_numeric($rawT); $tVal = $vValid ? round((float) $rawT, 2) : null; $dfVal = ($vValid && is_numeric($rawDf)) ? round((float) $rawDf, 2) : null; } $kv = []; $okMask = 0; $alertMask = 0; foreach ($probes as $j => $p) { $isOk = ($p['temp'] !== null); if ($isOk) { $okMask |= (1 << $j); } /* key 优先用探头身份(ROM 短码),未识别时退化为下标 key,保证不丢数据。 短码只有 4 位十六进制,同总线两颗撞码时会互相覆盖 → 追加槽位消歧 */ $key = ($p['id'] !== null) ? $p['id'] : ('p' . $j); if (array_key_exists($key, $kv)) { $key .= '@' . $j; } $kv[$key] = $p['temp']; /* 云端复核:逐探头越限(与固件 alertMaskOf 同语义) */ if ($isOk && $c) { $val = (float) $p['temp']; if (($upper > 0 && $val > $upper) || ($lower != 0.0 && $val < $lower)) { $alertMask |= (1 << $j); } } } /* 位置内温差告警(bit7,与固件一致) */ if ($c && $vValid && $gap > 0 && $dfVal !== null && $dfVal >= $gap) { $alertMask |= 0x80; } $vsJson = json_encode($kv, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($vsJson === false || strlen($vsJson) > 255) { $vsJson = ''; // 异常超长则放弃明细,t/df/ok_mask 仍完整 } $rows[] = [ 'uuid' => $uuid, 'pos_index' => $i, 'ts' => $ts, 'iv' => $iv, 't' => $tVal, 'df' => $dfVal, 'v' => $vValid ? 1 : 0, 'vs_json' => (string) $vsJson, 'ok_mask' => $okMask, 'alert_mask' => $alertMask, 'cfg_ver' => $cfgVer, 'create_at' => $now, ]; if ($alertMask !== 0) { $alertRows[] = [ 'pos' => $i, 'alias' => $c ? (string) $c['alias'] : ('位置' . ($i + 1)), 't' => $tVal, 'df' => $dfVal, 'mask' => $alertMask, 'upper' => $upper, 'lower' => $lower, 'gap' => $gap, ]; } } if ($rows) { try { Db::name('iot_sensor_point')->insertAll($rows); } catch (\Throwable $e) { return; // 落库失败则不再补告警,避免同一份数据反复告警 } } if ($alertRows) { try { self::raiseAlerts($uuid, $alertRows, $cfgVer); } catch (\Throwable $e) { } } } /* ============================================================ * 云端复核告警 * ========================================================== */ /** * 云端复核发现异常 → 补告警(AlertService 落 iot_alert + 推 /notify/{account}/alert) * * 抑制策略(三道,目的:只兜「设备真漏报」,不制造双份打扰): * 1) 连续两次确认:设备触发告警时会同时发 state 与 event 两条消息,首次异常时 * 设备自己的 event 可能只差几十毫秒入库 —— 等下一次上报仍异常才补, * 这样「设备已报」的情况自然被策略 2 挡住; * 2) 窗口内该设备已有告警(含设备端 event)→ 跳过; * 3) 一次上报最多补一条(取第一个越限位置),防多点同时越限刷屏。 */ protected static function raiseAlerts(string $uuid, array $items, int $cfgVer): void { /* 策略 1:本次是「首次异常」→ 等下一次确认(此刻设备的 event 可能尚未入库) */ if (!self::lastWasAlert($uuid, (int) $items[0]['pos'])) { return; } /* 策略 2 */ if (self::hasRecentAlert($uuid)) { return; } $dev = DeviceService::findByUuid($uuid); if (!$dev || (int) ($dev['member_id'] ?? 0) <= 0) { return; // 未绑定会员 → 无处投递 } $it = $items[0]; $reasons = []; if (((int) $it['mask'] & 0x80) !== 0) { $reasons[] = sprintf('位置内温差 %.2f℃ 达到阈值 %.2f℃', (float) $it['df'], (float) $it['gap']); } $t = (float) $it['t']; if ((float) $it['upper'] > 0 && $t > (float) $it['upper']) { $reasons[] = sprintf('温度 %.2f℃ 超上限 %.2f℃', $t, (float) $it['upper']); } if ((float) $it['lower'] != 0.0 && $t < (float) $it['lower']) { $reasons[] = sprintf('温度 %.2f℃ 低于下限 %.2f℃', $t, (float) $it['lower']); } /* 同一次上报里其它越限位置只作提示(不各发一条) */ $others = []; foreach ($items as $k => $x) { if ($k === 0) { continue; } $others[] = (string) $x['alias']; } if ($others) { $reasons[] = implode('、', $others) . ' 同时异常'; } if (!$reasons) { return; } AlertService::add( (int) $dev['member_id'], 'condition', 3, '温度异常:' . (string) $it['alias'], implode(';', $reasons) . '(云端复核 · 配置 v' . $cfgVer . ')', $uuid, (int) $it['pos'] ); } /** * 上一条同位置落库点是否已判为异常(「连续两次确认」用) * * 调用时本次数据已入库,所以取最近 2 条的 [1] 才是「上一条」。 * 查不出来返回 false(不补告警)——宁可漏补一次,也不要误报打扰。 */ protected static function lastWasAlert(string $uuid, int $pos): bool { try { $masks = Db::name('iot_sensor_point') ->where('uuid', $uuid) ->where('pos_index', $pos) ->order('id', 'desc') ->limit(2) ->column('alert_mask'); } catch (\Throwable $e) { return false; } return isset($masks[1]) && (int) $masks[1] !== 0; } /** 抑制判定:窗口内该设备已有告警(condition/event)则返回 true */ protected static function hasRecentAlert(string $uuid): bool { try { return (int) Db::name('iot_alert') ->where('uuid', $uuid) ->whereIn('type', ['condition', 'event']) ->where('create_at', '>', time() - self::RECHECK_SUPPRESS) ->count() > 0; } catch (\Throwable $e) { return true; // 查不出来时保守跳过:宁可少报也不刷屏 } } /* ============================================================ * 查询(App 曲线) * ========================================================== */ /** * 查询某位置的历史曲线点(按 ts 升序) * * 返回「稀疏点」而非等间隔序列:每条带 iv(覆盖秒数),需要等间隔序列时由调用方 * 按 ts → ts+iv 前向填充展开 —— 这样避免服务端把数据放大数倍(温变上报 10s~120s 一条)。 * * @param int $fromTs 起(含),0 = 不限 * @param int $toTs 止(含),0 = 不限 * @param int $limit 最多返回点数(取最近 N 条后翻回升序) * @return array */ public static function series(string $uuid, int $posIndex, int $fromTs = 0, int $toTs = 0, int $limit = 500): array { $uuid = trim($uuid); if ($uuid === '' || $posIndex < 0 || $posIndex >= self::MAX_POS) { return []; } self::ensureTable(); $limit = max(1, min(2000, $limit)); try { $q = Db::name('iot_sensor_point') ->where('uuid', $uuid) ->where('pos_index', $posIndex); if ($fromTs > 0) { $q->where('ts', '>=', $fromTs); } if ($toTs > 0) { $q->where('ts', '<=', $toTs); } $rows = $q->order('ts', 'desc')->limit($limit)->select()->toArray(); } catch (\Throwable $e) { /* ⚠️ 这里以前是「静默 return []」——导致接口只回 count=0,看不出是连库失败、 表名不对还是查询语法错。落一条日志:排障时能直接看到异常原文。 */ Log::error('[iot] sensorSeries 查询失败 uuid=' . $uuid . ' pos=' . $posIndex . ' from=' . $fromTs . ' to=' . $toTs . ' err=' . $e->getMessage()); return []; } $out = []; foreach (array_reverse($rows) as $r) { // 翻回升序,前端可直接画 /* vs_json 的 key 是探头身份(ROM 短码,如 "A3F2");身份未识别时是 "p{槽位}"。 这里转成对象数组 {id,temp}。注意它与 MQTT 上行**故意不同**: · MQTT 上行是「每 10s~120s 一条」的高频链路 → 用最省的二元组 [id,temp]; · 本接口是「用户点开曲线才请求一次」的 HTTP 链路 → 保持可读键名 (每条 6 个键 ≈18B,相对一次 500 点的传输占比可忽略,换来 App 侧好读)。 不要再把两者"统一"成一种形状:统一只会让其中一端付出不该付的代价。 DB 列名(iv/t/df/v/vs_json)保持不动:那是存储实现,改列要动历史数据。 */ $vsJson = json_decode((string) $r['vs_json'], true); $probes = []; if (is_array($vsJson)) { foreach ($vsJson as $pid => $pt) { $probes[] = [ 'id' => preg_match('/^p\d+$/', (string) $pid) ? null : (string) $pid, 'temp' => is_numeric($pt) ? (float) $pt : null, // null = 该探头无读数 ]; } } $out[] = [ 'ts' => (int) $r['ts'], 'interval' => (int) $r['iv'], 'temp' => $r['t'] === null ? null : (float) $r['t'], 'temp_diff' => $r['df'] === null ? null : (float) $r['df'], 'probes' => $probes, 'alert_mask' => (int) $r['alert_mask'], // bit j = 槽位 j 越限,bit7 = 位置内温差 ]; } return $out; } /** * 曲线查询诊断(排障专用;`debug=1` 时由 Monitor::sensorSeries 附加返回) * * 起因:接口回 count=0,但直接在数据库里跑同样的 SQL 明明有数据 —— series() 只有一个 * 静默的 `catch { return []; }`,无从分辨「连错库 / 表名不对 / 时间条件不成立 / 查询抛异常」。 * 本方法把**每一层条件各算一次行数**,并回传真实库名、实际下发的 SQL(含前缀解析结果) * 与最新几条原始行,一次请求即可定位,不用再猜。 * * ⚠️ 只在显式传 debug=1 时执行;正常链路零开销。返回内容含表名/库名,属内部信息, * 调用方(Monitor::sensorSeries)本身已要求「已登录 + 对该设备有查看权限」。 * * @return array */ public static function seriesDebug(string $uuid, int $posIndex, int $fromTs, int $toTs, int $limit = 3): array { $uuid = trim($uuid); $out = [ 'database' => '', // 接口实际连的库(与你跑 SQL 的库对比,一票定生死) 'table' => '', // 实际带前缀的表名 'sql' => '', // 实际下发的 SQL(fetchSql 只拼不执行) 'server_ts' => time(), // 接口侧当前时间戳(与 DB 的 UNIX_TIMESTAMP() 对比可辨时钟偏移) 'uuid' => $uuid, 'pos' => $posIndex, 'from' => $fromTs, 'to' => $toTs, 'rows_uuid' => 0, // 只按 uuid 的行数 'rows_pos' => 0, // uuid + pos 'rows_range' => 0, // uuid + pos + 时间范围(= 接口实际条件的行数) 'min_ts' => null, // uuid 维度最早/最新 ts 'max_ts' => null, 'pos_min_ts' => null, // uuid + pos 维度最早/最新 ts 'pos_max_ts' => null, 'recent' => [], // uuid 最新 3 条原始行(ts/pos_index/iv/t/alert_mask) 'error' => '', // 查询异常原文(原来被静默吞掉的那条) ]; if ($uuid === '') { return $out; } try { self::ensureTable(); $db = Db::query('SELECT DATABASE() AS db'); $out['database'] = (string) ($db[0]['db'] ?? ''); $out['sql'] = (string) Db::name('iot_sensor_point') ->where('uuid', $uuid) ->where('pos_index', $posIndex) ->where('ts', '>=', $fromTs) ->where('ts', '<=', $toTs) ->order('ts', 'desc') ->limit($limit) ->fetchSql(true) ->select(); if (preg_match('/\bfrom\s+`?([\w]+)`?/i', $out['sql'], $m)) { $out['table'] = $m[1]; } $out['rows_uuid'] = (int) Db::name('iot_sensor_point')->where('uuid', $uuid)->count(); $out['rows_pos'] = (int) Db::name('iot_sensor_point') ->where('uuid', $uuid)->where('pos_index', $posIndex)->count(); $out['rows_range'] = (int) Db::name('iot_sensor_point') ->where('uuid', $uuid)->where('pos_index', $posIndex) ->where('ts', '>=', $fromTs)->where('ts', '<=', $toTs)->count(); if ($out['rows_uuid'] > 0) { $agg = Db::name('iot_sensor_point')->where('uuid', $uuid) ->field('MIN(ts) AS mn, MAX(ts) AS mx')->find(); $out['min_ts'] = isset($agg['mn']) ? (int) $agg['mn'] : null; $out['max_ts'] = isset($agg['mx']) ? (int) $agg['mx'] : null; $out['recent'] = Db::name('iot_sensor_point')->where('uuid', $uuid) ->field('ts,pos_index,iv,t,alert_mask') ->order('ts', 'desc')->limit(3)->select()->toArray(); } if ($out['rows_pos'] > 0) { $agg = Db::name('iot_sensor_point')->where('uuid', $uuid) ->where('pos_index', $posIndex) ->field('MIN(ts) AS mn, MAX(ts) AS mx')->find(); $out['pos_min_ts'] = isset($agg['mn']) ? (int) $agg['mn'] : null; $out['pos_max_ts'] = isset($agg['mx']) ? (int) $agg['mx'] : null; } // fetchSql 不执行,这里真跑一次,把异常原文捞出来(这是最可能的 count=0 元凶) Db::name('iot_sensor_point') ->where('uuid', $uuid)->where('pos_index', $posIndex) ->where('ts', '>=', $fromTs)->where('ts', '<=', $toTs) ->limit(1)->select(); } catch (\Throwable $e) { $out['error'] = $e->getMessage(); } return $out; } /** * 按时间桶聚合某位置历史(时间加权平均,供"每分钟/每N分钟一个点"的粗粒度曲线) * * 加权公式:桶内 avg = SUM(t×有效秒) / SUM(有效秒)。用 iv(有效期)加权而非简单 AVG: * 一条 iv=120 的点和一条 iv=10 的点权重不该一样,温变期间简单平均会失真。 * 有效秒 = min(iv, 下一点的 ts - 本点 ts, 桶尾 - 本点 ts),即把 iv 裁剪到桶内, * 避免跨桶"溢出"(12:58 上报 iv=120 的点不会把权重漏进 13:00 桶)。 * * 与 series() 的分工:series 返回原始稀疏点(细粒度、App 自己前向填充); * 本方法返回聚合桶(粗粒度、点数可控)——典型用途:最近 30/60 分钟每分钟一点。 * 两者并存,不要用本方法替代 series。 * * @param int $bucketSec 桶大小(秒),60 = 每分钟一点;会被钳到 [60, 86400] * @param int $fromTs 起(含),0 = 不限 * @param int $toTs 止(含),0 = 不限 * @param int $limit 最多返回桶数(取最近 N 桶后翻回升序) * @return array * ts = 桶起点(UTC 对齐;App 显示时按本地时区格式化);coverage_s = 桶内被数据覆盖的秒数 */ public static function seriesAvg(string $uuid, int $posIndex, int $bucketSec = 60, int $fromTs = 0, int $toTs = 0, int $limit = 720): array { $uuid = trim($uuid); if ($uuid === '' || $posIndex < 0 || $posIndex >= self::MAX_POS) { return []; } $bucketSec = max(60, min(86400, $bucketSec)); // 桶最小 1 分钟,最大 1 天 $limit = max(1, min(2000, $limit)); self::ensureTable(); try { $q = Db::name('iot_sensor_point') ->where('uuid', $uuid) ->where('pos_index', $posIndex) ->whereNotNull('t'); if ($fromTs > 0) { $q->where('ts', '>=', $fromTs); } if ($toTs > 0) { $q->where('ts', '<=', $toTs); } $rows = $q->order('ts', 'asc')->select()->toArray(); } catch (\Throwable $e) { /* 与 series() 同口径:不要静默 return [](否则接口只回 count=0, 分不清「连错库 / 表名不对 / 查询异常」)。排障先看这条日志。 */ Log::error('[iot] sensorAvg 查询失败 uuid=' . $uuid . ' pos=' . $posIndex . ' step=' . $bucketSec . ' from=' . $fromTs . ' to=' . $toTs . ' err=' . $e->getMessage()); return []; } if (!$rows) { return []; } // 一次遍历完成分桶 + 跨点裁剪(rows 已按 ts 升序) $buckets = []; // bucketTs => [w_sum, sec_sum, min, max, samples] $n = count($rows); $lastTs = (int) $rows[$n - 1]['ts'] + (int) $rows[$n - 1]['iv']; // 末点的有效期终点 for ($i = 0; $i < $n; $i++) { $ts = (int) $rows[$i]['ts']; $t = (float) $rows[$i]['t']; // 有效期终点:iv 与"下一点 ts"取小(下一点来了,本点权重就到此为止) $end = $ts + (int) $rows[$i]['iv']; if ($i + 1 < $n) { $nextTs = (int) $rows[$i + 1]['ts']; if ($nextTs > $ts && $nextTs < $end) { $end = $nextTs; } } if ($end <= $ts) { $end = $ts + 1; // 防御:iv=0 至少记 1 秒权重 } // 跨桶把本点切段:每段只把落在桶内的秒数计入该桶 $cur = $ts; while ($cur < $end && $cur < $lastTs) { $bucketTs = intdiv($cur, $bucketSec) * $bucketSec; $segEnd = min($end, $bucketTs + $bucketSec); $sec = $segEnd - $cur; if ($sec > 0) { if (!isset($buckets[$bucketTs])) { $buckets[$bucketTs] = ['w' => 0.0, 's' => 0, 'min' => $t, 'max' => $t, 'n' => 0]; } $b = &$buckets[$bucketTs]; $b['w'] += $t * $sec; $b['s'] += $sec; $b['n'] += 1; if ($t < $b['min']) $b['min'] = $t; if ($t > $b['max']) $b['max'] = $t; unset($b); } $cur = $segEnd; } } if (count($buckets) > $limit) { $buckets = array_slice($buckets, -$limit, null, true); // 只留最近 N 桶 } $out = []; foreach ($buckets as $bucketTs => $b) { $out[] = [ 'ts' => $bucketTs, 'avg' => $b['s'] > 0 ? round($b['w'] / $b['s'], 2) : null, 'min' => round($b['min'], 2), 'max' => round($b['max'], 2), 'samples' => $b['n'], 'coverage_s' => $b['s'], ]; } return $out; // 桶起点天然升序 } /** * 最近 N 分钟按位置加权平均(一次性返回所有位置,单条 SQL) * * 加权公式与 seriesAvg 一致:SUM(t×有效秒) / SUM(秒), * 跨点用下一点 ts 裁剪,跨窗口边界用 [from, to] 裁剪 * —— 一条 iv=120 的点与一条 iv=10 的点权重不同,简单 AVG 在温变期间会失真 * * 与 seriesAvg() 的差别:seriesAvg 返回「按 step 分桶的曲线」, * 本方法返回「每个位置一条汇总值」——典型用途:首屏概览、最近 N 分钟均值、阈值刷新、排障。 * * @param int $windowMin 窗口分钟数,[1, 1440];1=最近 1 分钟,1440=最近 1 天 * @param int $posIndex 指定位置返回(>=0),-1 = 全部位置 * @return array{from:int,to:int,samples_total:int,positions:array} */ public static function recentAvg(string $uuid, int $windowMin = 10, int $posIndex = -1): array { $uuid = trim($uuid); if ($uuid === '') { return ['from' => 0, 'to' => 0, 'samples_total' => 0, 'positions' => []]; } $windowMin = max(1, min(1440, $windowMin)); self::ensureTable(); $now = time(); $from = $now - $windowMin * 60; // 一次拉窗口内全部有效点(窗口 1~1440 分钟,单设备一般几千行内,单次查询很轻) try { $q = Db::name('iot_sensor_point') ->where('uuid', $uuid) ->where('ts', '>=', $from) ->where('ts', '<=', $now) ->whereNotNull('t'); if ($posIndex >= 0) { $q->where('pos_index', $posIndex); } $rows = $q->order('ts', 'asc')->select()->toArray(); } catch (\Throwable $e) { return ['from' => $from, 'to' => $now, 'samples_total' => 0, 'positions' => []]; } // 拿 cfg 取别名(轻量;cfg 用 uuid+index 主键,列只取 alias),拿不到不影响主流程 $aliasMap = []; try { $cfgRows = Db::name('iot_sensor_cfg')->where('uuid', $uuid)->column('alias', 'index'); foreach ($cfgRows as $idx => $name) { $aliasMap[(int) $idx] = (string) $name; } } catch (\Throwable $e) { } // 按 pos_index 聚合(每组内做 iv 加权 + 跨点裁剪 + 跨窗口裁剪) $byPos = []; $n = count($rows); for ($i = 0; $i < $n; $i++) { $pos = (int) $rows[$i]['pos_index']; if ($pos < 0 || $pos >= self::MAX_POS) { continue; } $ts = (int) $rows[$i]['ts']; $t = (float) $rows[$i]['t']; $end = $ts + max(1, (int) $rows[$i]['iv']); if ($i + 1 < $n) { $nextTs = (int) $rows[$i + 1]['ts']; if ($nextTs > $ts && $nextTs < $end) { $end = $nextTs; // 下一点来时,本点 iv 自动让位(与 seriesAvg 同语义) } } // 跨窗口边界裁剪:只统计落在 [from, now] 内的秒数 $segStart = max($ts, $from); $segEnd = min($end, $now); if ($segEnd <= $segStart) { continue; } $sec = $segEnd - $segStart; if (!isset($byPos[$pos])) { $byPos[$pos] = ['w' => 0.0, 's' => 0, 'min' => $t, 'max' => $t, 'n' => 0, 'last' => 0]; } $b = &$byPos[$pos]; $b['w'] += $t * $sec; $b['s'] += $sec; $b['n'] += 1; if ($t < $b['min']) { $b['min'] = $t; } if ($t > $b['max']) { $b['max'] = $t; } if ($ts > $b['last']) { $b['last'] = $ts; } unset($b); } // 全部位置补齐(即便 0 样本也返回,便于前端占位显示「无数据」而非跳过整行) $positions = []; $totalSamples = 0; for ($p = 0; $p < self::MAX_POS; $p++) { if ($posIndex >= 0 && $p !== $posIndex) { continue; } $b = $byPos[$p] ?? null; $positions[] = [ 'index' => $p, 'alias' => $aliasMap[$p] ?? ('位置' . ($p + 1)), 'avg' => ($b && $b['s'] > 0) ? round($b['w'] / $b['s'], 2) : null, 'min' => $b ? round($b['min'], 2) : null, 'max' => $b ? round($b['max'], 2) : null, 'samples' => $b ? $b['n'] : 0, 'coverage_s' => $b ? $b['s'] : 0, 'last_ts' => $b ? $b['last'] : 0, 'data_ok' => $b !== null && $b['s'] > 0, ]; if ($b) { $totalSamples += $b['n']; } } return [ 'from' => $from, 'to' => $now, 'samples_total' => $totalSamples, 'positions' => $positions, ]; } /** 最近一条落库点(App 首屏快速回填用;取不到返回 null) */ public static function latest(string $uuid, int $posIndex): ?array { $list = self::series($uuid, $posIndex, 0, 0, 1); return $list ? $list[0] : null; } /* ============================================================ * 维护 * ========================================================== */ /** 清理超过保留期的历史点(定时任务调用),返回删除行数 */ public static function cleanup(int $days = self::KEEP_DAYS): int { if ($days <= 0) { return 0; } self::ensureTable(); try { return (int) Db::name('iot_sensor_point') ->where('create_at', '<', time() - $days * 86400) ->delete(); } catch (\Throwable $e) { return 0; } } /** 删除某设备全部历史点(解绑/删除设备时清理) */ public static function removeByUuid(string $uuid): void { if ($uuid === '') { return; } try { Db::name('iot_sensor_point')->where('uuid', $uuid)->delete(); } catch (\Throwable $e) { } } /** * 按小时聚合某位置历史(时间加权平均) * * @param int $fromTs 起(秒,含),0 = 不限 * @param int $toTs 止(秒,含),0 = 不限 * @param int $limit 最多返回多少小时桶(取最近 N 个) * @return array */ public static function hourlyAvg(string $uuid, int $posIndex, int $fromTs = 0, int $toTs = 0, int $limit = 168): array { $uuid = trim($uuid); if ($uuid === '' || $posIndex < 0 || $posIndex >= self::MAX_POS) { return []; } self::ensureTable(); $limit = max(1, min(8760, $limit)); // 一年上限 try { // 一次拉范围内全部点(小时桶后不超过几万行,单次查询很轻) $q = Db::name('iot_sensor_point') ->where('uuid', $uuid) ->where('pos_index', $posIndex) ->where('t', 'not null'); if ($fromTs > 0) $q->where('ts', '>=', $fromTs); if ($toTs > 0) $q->where('ts', '<=', $toTs); $rows = $q->order('ts', 'asc')->select()->toArray(); } catch (\Throwable $e) { return []; } $buckets = []; // hour_ts => [sum_t_iv, sum_iv, min, max, samples] foreach ($rows as $r) { $ts = (int) $r['ts']; $t = (float) $r['t']; $iv = max(1, (int) $r['iv']); // 防御:iv=0 视为 1s 权重 $hour = intdiv($ts, 3600) * 3600; // 整点对齐 if (!isset($buckets[$hour])) { $buckets[$hour] = [ 'sum_t_iv' => 0.0, 'sum_iv' => 0, 'min' => $t, 'max' => $t, 'samples' => 0, ]; } $b = &$buckets[$hour]; $b['sum_t_iv'] += $t * $iv; $b['sum_iv'] += $iv; $b['samples'] += 1; if ($t < $b['min']) $b['min'] = $t; if ($t > $b['max']) $b['max'] = $t; unset($b); } ksort($buckets); if (count($buckets) > $limit) { $buckets = array_slice($buckets, -$limit, null, true); // 只留最近 N 个桶 } $out = []; foreach ($buckets as $hour => $b) { $out[] = [ 'hour_ts' => $hour, 'avg' => $b['sum_iv'] > 0 ? round($b['sum_t_iv'] / $b['sum_iv'], 2) : null, 'min' => round($b['min'], 2), 'max' => round($b['max'], 2), 'samples' => $b['samples'], 'coverage_s' => $b['sum_iv'], // 桶内"被覆盖的秒数" ]; } return $out; } }