// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\service; use think\facade\Db; /** * 场景/自动化引擎(智慧生活核心) * * 触发三类: * - device_state:设备状态/事件上报 → topic 匹配 + 条件求值(支持 ref 读其他设备影子) * - schedule :定时(5 段 cron,Broker 进程内每分钟扫描) * - manual :App/后台手动一键执行 * * 执行原则: * - 评估在 Broker 事件钩子内同步、纯内存判断,动作统一入 outbox 异步投递,不阻塞主循环 * - 场景配置内存缓存 TTL 30s(同 Auth::loadAclRules 模式,Broker 单进程安全) * - 所有 DB 访问防御式 try/catch */ class SceneService { /** 场景缓存 TTL(秒) */ public const CACHE_TTL = 30; protected static array $sceneCache = []; protected static int $loadedAt = 0; /* ============================================================ * 建表自愈 * ========================================================== */ public static function ensureTables(): bool { $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `member_id` int unsigned NOT NULL DEFAULT '0', `title` varchar(64) NOT NULL DEFAULT '', `icon` varchar(32) NOT NULL DEFAULT '', `enabled` tinyint(1) NOT NULL DEFAULT '1', `remark` varchar(255) DEFAULT NULL, `create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_trigger` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `scene_id` int unsigned NOT NULL DEFAULT '0', `type` varchar(16) NOT NULL DEFAULT 'device_state', `topic` varchar(255) NOT NULL DEFAULT '', `condition_json` text, `cron` varchar(64) NOT NULL DEFAULT '', `enabled` tinyint(1) NOT NULL DEFAULT '1', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_action` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `scene_id` int unsigned NOT NULL DEFAULT '0', `sort` int NOT NULL DEFAULT '0', `type` varchar(16) NOT NULL DEFAULT 'send_cmd', `device_uuid` varchar(64) NOT NULL DEFAULT '', `payload_json` text, `topic` varchar(255) NOT NULL DEFAULT '', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_log` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `scene_id` int unsigned NOT NULL DEFAULT '0', `trigger_type` varchar(16) NOT NULL DEFAULT '', `matched` tinyint(1) NOT NULL DEFAULT '0', `message` text, `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`), KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); return true; } catch (\Throwable $e) { return false; } } /* ============================================================ * 事件钩子入口(Broker::handlePublish / handlePubRel 调用) * ========================================================== */ /** * 消息发布 → 评估所有启用场景的 device_state 触发器 */ public static function onMessagePublished(string $topic, string $payload): void { foreach (self::loadScenes() as $scene) { foreach ($scene['triggers'] as $trg) { if ($trg['type'] !== 'device_state' || !$trg['enabled']) { continue; } $matched = false; $reason = ''; try { if (!self::topicMatch($trg['topic'], $topic)) { continue; // 主题不匹配不记日志 } $matched = self::matchConditions($trg['condition_json'], $payload, $topic); $reason = $matched ? '条件命中' : '条件未满足'; } catch (\Throwable $e) { $reason = '评估异常: ' . $e->getMessage(); } if ($matched) { self::runScene($scene['id'], 'device_state', 'topic=' . $topic); } else { self::writeLog($scene['id'], 'device_state', false, $reason); } } } } /* ============================================================ * 定时触发(Broker onWorkerStart Timer 每分钟调用) * ========================================================== */ public static function onTick(): void { $now = time(); foreach (self::loadScenes() as $scene) { foreach ($scene['triggers'] as $trg) { if ($trg['type'] !== 'schedule' || !$trg['enabled'] || $trg['cron'] === '') { continue; } if (self::cronMatch($trg['cron'], $now)) { self::runScene($scene['id'], 'schedule', 'cron=' . $trg['cron']); } } } } /* ============================================================ * 场景执行 * ========================================================== */ /** * 执行场景(App/后台手动 + 引擎内部) */ public static function runScene(int $sceneId, string $triggerType = 'manual', string $message = ''): bool { $scene = self::findScene($sceneId); if (!$scene || !$scene['enabled']) { return false; } $done = 0; foreach ($scene['actions'] as $act) { try { if (self::runAction($act)) { $done++; } } catch (\Throwable $e) { // 单动作失败不中断后续 } } self::writeLog($sceneId, $triggerType, $done > 0, $message !== '' ? $message : ($done > 0 ? "执行 {$done} 个动作" : '无动作')); return $done > 0; } protected static function runAction(array $act): bool { switch ($act['type']) { case 'send_cmd': if ($act['device_uuid'] === '' || $act['payload_json'] === '') { return false; } return Store::enqueueManual( sprintf(DeviceService::TOPIC_CMD, $act['device_uuid']), $act['payload_json'], 1, 0, 'scene' ); case 'http': if ($act['topic'] === '') { return false; } return self::httpPost($act['topic'], $act['payload_json']); case 'notify': $dev = $act['device_uuid'] !== '' ? DeviceService::findByUuid($act['device_uuid']) : null; $memberId = $dev ? (int) $dev['member_id'] : (int) ($act['scene_member_id'] ?? 0); if ($memberId <= 0) { return false; } $title = $act['topic'] !== '' ? $act['topic'] : '场景通知'; AlertService::add($memberId, 'scene', 2, $title, mb_substr($act['payload_json'] !== '' ? $act['payload_json'] : '场景联动已执行', 0, 512)); return true; default: return false; } } /** HTTP POST 回调(短超时,失败不影响主流程) */ protected static function httpPost(string $url, string $body): bool { if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], ]); curl_exec($ch); $err = curl_errno($ch); curl_close($ch); return $err === 0; } return false; } /* ============================================================ * 条件求值 * ========================================================== */ /** MQTT 主题通配符匹配(+/#) */ public static function topicMatch(string $filter, string $topic): bool { if ($filter === '') { $filter = '/devices/+/state'; } // MQTT 通配符转正则:+ 单层,# 多层 $regex = preg_quote($filter, '#'); $regex = str_replace(['\\+', '\\#'], ['[^/]+', '.*'], $regex); return (bool) preg_match('#^' . $regex . '$#', $topic); } /** * 条件 JSON 求值(全部满足才 true;空条件视为 true) * 支持 ref:引用其他设备影子字段(/devices/{uuid}/state 的 retain payload) */ protected static function matchConditions(string $conditionJson, string $payload, string $topic): bool { $conds = json_decode($conditionJson, true); if (!is_array($conds) || !$conds) { return true; } $data = json_decode($payload, true); if (!is_array($data)) { $data = ['value' => $payload]; // 非 JSON payload 视为裸 value } foreach ($conds as $cond) { if (!is_array($cond)) { continue; } $field = (string) ($cond['field'] ?? ''); $op = (string) ($cond['op'] ?? 'eq'); $value = $cond['value'] ?? null; $ref = (string) ($cond['ref'] ?? ''); $src = $data; if ($ref !== '') { $shadow = DeviceService::shadow($ref); $parsed = json_decode((string) ($shadow['payload'] ?? ''), true); if (!is_array($parsed)) { return false; // 引用影子不可用 → 不触发 } $src = $parsed; } $actual = $field === '' ? ($src['value'] ?? null) : self::dig($src, $field); if (!self::compare($op, $actual, $value)) { return false; } } return true; } /** * 点分路径取值:dig(['pos'=>[['t'=>26]]], 'pos.0.t') = 26 * * 报文改过三轮键名,场景条件是用户手写在 condition_json 里的,不能在设备升级后悄悄失效: * ≤1.4.0 data.N.t / df / v / iv / vs.M * v1.5.0 positions.N.temp / temp_diff / probes.M.{id,temp}、interval * v1.6.0 positions.N.t / df / probes.M.[id, temp](下标即位置序号、探头变二元组) * v1.7.0 pos.N.t / df / pr.M.<分量下标>(**只是键名压短**,结构与语义同 v1.6.0) * 这里按「payload 里有没有 pos / positions 键」判版式,是新格式就把路径自动改写 * 成当前线上词汇(v1.7.0)。⚠️ 库里既存场景的条件路径可能写成任一历史词汇 * (data. / positions. / interval. / probes.),全靠 $map 一趟收敛到 pos. / iv. / pr. —— * 漏一条就会出现「路径取不到值 → 条件恒不成立」这种**静默**故障, * 所以每次改报文键名都必须同步这里(这是全项目唯一靠字符串路径取报文的地方)。 * 两个迁不了的(路径取不到值 → 条件恒不成立,得人工改): * · `v` —— v1.5.0 起字段已删(等价于「t !== null」)→ 改用 pos.N.t 的 exists/gt * · `i` —— v1.6.0 起位置序号就是数组下标,报文里没有对应字段了 */ protected static function dig(array $arr, string $path) { /* 「新式」= 带 pos(v1.7.0)或 positions(≥1.5.0)且不是旧式 data。 旧式(≤1.4.0)没有 null 语义、词汇也是另一套,绝不能改写路径 */ if ((array_key_exists('pos', $arr) || array_key_exists('positions', $arr)) && !array_key_exists('data', $arr)) { $path = self::upgradeStatePath($path); } $cur = $arr; foreach (explode('.', $path) as $seg) { if (!is_array($cur) || !array_key_exists($seg, $cur)) { return null; } $cur = $cur[$seg]; } return $cur; } /** * 旧 state 路径 → v1.6.0 词汇(只改名字与探头取法,语义不变) * * 目标形态:positions[下标] = {pin, t, df, probes[[id, temp]]} * 关键点:probes 从「对象数组」变成「二元组」,所以 probes.N 后面那一段 * 要从**字段名**(temp/id)改成**分量下标**(1/0)。 * 两趟走,顺序不能反:先把分量名换成下标,再改名 —— 否则 `probes.0.temp` * 会先被改名成 `probes.0.t`,第二趟就认不出它是"温度"还是"位置温度"了。 */ protected static function upgradeStatePath(string $path): string { $segs = explode('.', $path); /* 第一趟:探头部份(vs.N / probes.N / ids.N / pr.N)→ pr.N.<分量下标> */ for ($k = 0; $k + 1 < count($segs); $k++) { $head = $segs[$k]; if (!in_array($head, ['vs', 'probes', 'ids', 'pr'], true) || !ctype_digit((string) $segs[$k + 1])) { continue; } $tail = $segs[$k + 2] ?? null; if ($head === 'ids') { /* 旧写法 ids.N 取的就是身份本身 */ $segs[$k] = 'pr'; if ($tail === null || !ctype_digit((string) $tail)) { array_splice($segs, $k + 2, 0, ['0']); } continue; } $segs[$k] = 'pr'; if ($tail === 'temp' || $tail === 'id' || $tail === 't') { $segs[$k + 2] = ($tail === 'id') ? '0' : '1'; // 二元组 [身份, 温度] } elseif ($tail === null || !ctype_digit((string) $tail)) { /* `vs.N` 这种只顾到探头号的旧写法:当时取到的是温度 → 补上分量下标 1 */ array_splice($segs, $k + 2, 0, ['1']); } } /* 第二趟:段名映射 —— 把所有历史词汇收敛到 v1.7.0 线上词汇 (`t` / `df` 各版同名,天然不用改)。 ⚠️ `positions`→`pos`、`interval`→`iv` 这两条是给**库里已存**的场景路径用的: 它们是按 v1.5.0/v1.6.0 词汇写进 iot_scene 的,不改写就取不到值。 */ $map = [ 'data' => 'pos', 'positions' => 'pos', 'probes' => 'pr', 'vs' => 'pr', 'ids' => 'pr', 'temp' => 't', 'temp_diff' => 'df', 'interval' => 'iv', ]; foreach ($segs as $k => $seg) { if (isset($map[$seg])) { $segs[$k] = $map[$seg]; } } return implode('.', $segs); } protected static function compare(string $op, $actual, $expected): bool { if (is_numeric($actual) && is_numeric($expected)) { $actual = (float) $actual; $expected = (float) $expected; } switch ($op) { case 'eq': return $actual == $expected; case 'neq': return $actual != $expected; case 'gt': return $actual > $expected; case 'gte': return $actual >= $expected; case 'lt': return $actual < $expected; case 'lte': return $actual <= $expected; case 'exists': return $actual !== null && $actual !== ''; case 'contains': return is_string($actual) && strpos($actual, (string) $expected) !== false; default: return false; } } /* ============================================================ * cron 匹配(最小 5 段实现:分 时 日 月 周,支持 * 与数字) * ========================================================== */ public static function cronMatch(string $cron, ?int $ts = null): bool { $ts = $ts ?? time(); $parts = preg_split('/\s+/', trim($cron)); if (count($parts) !== 5) { return false; } $minute = (int) date('i', $ts); $hour = (int) date('G', $ts); $day = (int) date('j', $ts); $month = (int) date('n', $ts); $week = (int) date('w', $ts); $fields = [$minute, $hour, $day, $month, $week]; foreach ($parts as $i => $part) { if ($part === '*') { continue; } $allowed = []; foreach (explode(',', $part) as $seg) { $allowed[] = (int) $seg; } if (!in_array($fields[$i], $allowed, true)) { return false; } } return true; } /* ============================================================ * 数据访问(内存缓存) * ========================================================== */ /** 加载启用场景(含触发器/动作),TTL 缓存 */ protected static function loadScenes(): array { $now = time(); if ($now - self::$loadedAt < self::CACHE_TTL && self::$sceneCache) { return self::$sceneCache; } try { $scenes = Db::name('iot_scene') ->where('enabled', 1) ->order('id', 'asc') ->select()->toArray(); $sceneIds = array_column($scenes, 'id'); $triggers = $actions = []; if ($sceneIds) { $triggers = Db::name('iot_scene_trigger') ->whereIn('scene_id', $sceneIds) ->where('enabled', 1) ->order('id', 'asc') ->select()->toArray(); $actions = Db::name('iot_scene_action') ->whereIn('scene_id', $sceneIds) ->order('sort', 'asc') ->select()->toArray(); } $byId = []; foreach ($triggers as $t) { $byId[(int) $t['scene_id']]['triggers'][] = $t; } foreach ($actions as $a) { $byId[(int) $a['scene_id']]['actions'][] = $a; } foreach ($scenes as &$s) { $sid = (int) $s['id']; $s['triggers'] = $byId[$sid]['triggers'] ?? []; $s['actions'] = $byId[$sid]['actions'] ?? []; foreach ($s['actions'] as &$a) { $a['scene_member_id'] = (int) $s['member_id']; } } self::$sceneCache = $scenes; self::$loadedAt = $now; } catch (\Throwable $e) { self::$sceneCache = []; self::$loadedAt = $now; } return self::$sceneCache; } /** 单场景完整查询(不过滤 enabled) */ public static function findScene(int $sceneId): ?array { try { $scene = Db::name('iot_scene')->where('id', $sceneId)->find(); if (!$scene) { return null; } $scene['triggers'] = Db::name('iot_scene_trigger') ->where('scene_id', $sceneId)->order('id', 'asc')->select()->toArray(); $scene['actions'] = Db::name('iot_scene_action') ->where('scene_id', $sceneId)->order('sort', 'asc')->select()->toArray(); foreach ($scene['actions'] as &$a) { $a['scene_member_id'] = (int) $scene['member_id']; } return $scene; } catch (\Throwable $e) { return null; } } /** 写执行日志(防爆:单场景保留最近 500 条) */ protected static function writeLog(int $sceneId, string $type, bool $matched, string $message): void { try { Db::name('iot_scene_log')->insert([ 'scene_id' => $sceneId, 'trigger_type' => $type, 'matched' => $matched ? 1 : 0, 'message' => mb_substr($message, 0, 500), 'create_at' => time(), ]); // 防爆清理 $cnt = (int) Db::name('iot_scene_log')->where('scene_id', $sceneId)->count(); if ($cnt > 500) { $ids = Db::name('iot_scene_log')->where('scene_id', $sceneId) ->order('id', 'asc')->limit($cnt - 500)->column('id'); if ($ids) { Db::name('iot_scene_log')->whereIn('id', $ids)->delete(); } } } catch (\Throwable $e) { } } /* ============================================================ * CRUD(App / 后台) * ========================================================== */ public static function save(array $data): int { self::ensureTables(); $id = (int) ($data['id'] ?? 0); $now = time(); $sceneData = [ 'member_id' => (int) ($data['member_id'] ?? 0), 'title' => mb_substr(trim((string) ($data['title'] ?? '')), 0, 64), 'icon' => mb_substr(trim((string) ($data['icon'] ?? '')), 0, 32), 'enabled' => !empty($data['enabled']) ? 1 : 0, 'remark' => mb_substr(trim((string) ($data['remark'] ?? '')), 0, 255), ]; if ($sceneData['title'] === '') { throw new \InvalidArgumentException('场景名不能为空'); } if ($id > 0) { $sceneData['update_at'] = $now; Db::name('iot_scene')->where('id', $id)->update($sceneData); } else { $sceneData['create_at'] = $now; $sceneData['update_at'] = $now; $id = (int) Db::name('iot_scene')->insertGetId($sceneData); } // 替换触发器与动作 Db::name('iot_scene_trigger')->where('scene_id', $id)->delete(); Db::name('iot_scene_action')->where('scene_id', $id)->delete(); $triggers = $data['triggers'] ?? []; foreach ($triggers as $t) { $type = (string) ($t['type'] ?? 'device_state'); Db::name('iot_scene_trigger')->insert([ 'scene_id' => $id, 'type' => in_array($type, ['device_state', 'schedule', 'manual'], true) ? $type : 'device_state', 'topic' => mb_substr((string) ($t['topic'] ?? ''), 0, 255), 'condition_json' => is_array($t['condition_json'] ?? null) ? (string) json_encode($t['condition_json'], JSON_UNESCAPED_UNICODE) : (string) ($t['condition_json'] ?? ''), 'cron' => mb_substr((string) ($t['cron'] ?? ''), 0, 64), 'enabled' => !empty($t['enabled']) ? 1 : 0, 'create_at' => $now, ]); } $actions = $data['actions'] ?? []; $sort = 0; foreach ($actions as $a) { $type = (string) ($a['type'] ?? 'send_cmd'); Db::name('iot_scene_action')->insert([ 'scene_id' => $id, 'sort' => $sort++, 'type' => in_array($type, ['send_cmd', 'http', 'notify'], true) ? $type : 'send_cmd', 'device_uuid' => mb_substr((string) ($a['device_uuid'] ?? ''), 0, 64), 'payload_json' => is_array($a['payload_json'] ?? null) ? (string) json_encode($a['payload_json'], JSON_UNESCAPED_UNICODE) : (string) ($a['payload_json'] ?? ''), 'topic' => mb_substr((string) ($a['topic'] ?? ''), 0, 255), 'create_at' => $now, ]); } return $id; } public static function remove(int $sceneId, int $memberId = 0): void { $q = Db::name('iot_scene')->where('id', $sceneId); if ($memberId > 0) { $q->where('member_id', $memberId); } $q->delete(); Db::name('iot_scene_trigger')->where('scene_id', $sceneId)->delete(); Db::name('iot_scene_action')->where('scene_id', $sceneId)->delete(); } public static function toggle(int $sceneId, int $enabled, int $memberId = 0): void { $q = Db::name('iot_scene')->where('id', $sceneId); if ($memberId > 0) { $q->where('member_id', $memberId); } $q->update(['enabled' => $enabled ? 1 : 0, 'update_at' => time()]); } public static function list(int $memberId = 0): array { $q = Db::name('iot_scene'); if ($memberId > 0) { $q->where('member_id', $memberId); } $rows = $q->order('id', 'desc')->select()->toArray(); foreach ($rows as &$r) { $r['trigger_count'] = (int) Db::name('iot_scene_trigger')->where('scene_id', $r['id'])->count(); $r['action_count'] = (int) Db::name('iot_scene_action')->where('scene_id', $r['id'])->count(); } return $rows; } public static function logs(int $sceneId, int $page = 1, int $size = 20): array { $q = Db::name('iot_scene_log')->where('scene_id', $sceneId); $total = (int) $q->count(); $list = (clone $q)->order('id', 'desc') ->page(max(1, $page), min(100, max(1, $size))) ->select()->toArray(); return ['list' => $list, 'total' => $total]; } }