// +---------------------------------------------------------------------- namespace addon\iot\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\console\input\Argument; use think\console\input\Option; use think\facade\Db; use Workerman\Worker; use addon\iot\service\Broker; /** * MQTT Broker 统一管理命令(start / stop / restart / status 合并到一个文件) * * php think iot start [-d] 启动(-d 后台守护,Linux 有效) * php think iot stop [-f] 停止(-f 跳过优雅退出,直接 SIGKILL) * php think iot restart [-d] 重启(线上固定加 -d,否则新进程是前台进程) * php think iot status 查看状态(进程 / 端口 / 配置 / 环境 / 日志 / DB 快照) * php think iot probe [pos] 曲线/时序链路自检(只读;不经 HTTP,排「接口没有温度」) * * 兼容旧写法(同一实现,info.php 里注册了多个命令名): * php think iot:start / iot:stop / iot:restart / iot:status * * 为什么做成一个命令: * 1. 原来 4 个命令各自重复「找进程 → 停 → 复核端口」的样板代码,改动容易漏; * 2. 命令名里出现裸 `restart`/`stop` 会被 Workerman 的 parseCommand() 误当它自己的命令 * (它直接扫 argv 的第一个白名单词),集中到一处更好解释与兜底,见 doStart() 注释。 * * 设计要点(与旧版的差异全部保留): * - 进程查找不依赖 runtime/iot.pid:pid 丢失时按 Workerman 改写后的标题特征回退扫描; * - 停止有 SIGKILL 兜底 + 复核进程/端口,pid 文件只在「确认无任何 Broker 进程」后清理; * - 重启自管「停旧 → 等端口真释放 → 起新」,端口未释放直接报错中止; * - status 返回码 0=健康 / 1=异常,可挂监控。 */ class Iot extends Command { /** status 配置摘要展示的键(顺序即输出顺序) */ private const SHOW_KEYS = [ 'host', 'port', 'ws_port', 'allow_anonymous', 'allow_member_login', 'acl_enabled', 'acl_default', 'rule_enabled', 'cluster_enabled', 'max_connections', 'log_level', 'sys_enabled', ]; protected function configure() { // 允许被注册成多个名字(iot / iot:start / iot:stop / iot:restart / iot:status): // 保留构造期已设置的名字,避免多个注册项互相覆盖。 $this->setName($this->getName() ?: 'iot') ->addArgument('action', Argument::OPTIONAL, '子命令:start | stop | restart | status | probe', '') ->addArgument('uuid', Argument::OPTIONAL, 'probe 用:设备 uuid', '') ->addArgument('pos', Argument::OPTIONAL, 'probe 用:位置序号,默认 0', '0') ->addOption('host', 'H', Option::VALUE_OPTIONAL, '监听地址(start,默认 0.0.0.0)', '0.0.0.0') ->addOption('port', 'p', Option::VALUE_OPTIONAL, '监听端口(start,默认 1883)', 1883) ->addOption('daemon', 'd', Option::VALUE_NONE, '后台守护运行(start / restart,Linux 有效)') ->addOption('force', 'f', Option::VALUE_NONE, '跳过优雅退出,直接 SIGKILL 强杀(stop)') ->addOption('tcp-only', null, Option::VALUE_NONE, '仅启动 MQTT TCP 监听(Windows 双进程模式用)') ->addOption('ws-only', null, Option::VALUE_NONE, '仅启动 WebSocket 监听(Windows 双进程模式用)') ->addOption('lines', 'l', Option::VALUE_OPTIONAL, '日志显示行数(status,默认 20)', 20) ->addOption('no-db', null, Option::VALUE_NONE, '跳过数据库快照(status)') ->setDescription('MQTT Broker 管理:iot start [-d] | iot stop [-f] | iot restart [-d] | iot status | iot probe '); } protected function execute(Input $input, Output $output) { $action = $this->resolveAction($input); switch ($action) { case 'start': return $this->doStart($input, $output); case 'stop': return $this->doStop($input, $output); case 'restart': return $this->doRestart($input, $output); case 'status': return $this->doStatus($input, $output); case 'probe': // 曲线/时序链路自检(实现见 command/Probe.php,与 iot:probe 共用同一份代码) return Probe::report( (string) $input->getArgument('uuid'), (int) $input->getArgument('pos'), 1800, 60, 24, 5, $output ); case 'acltest': // ACL 验证工具是独立命令(含 30 条断言,会写 iot_acl 临时规则),不并入本文件 $output->writeln('ACL 测试请用独立命令:php think iot:acltest'); return 1; case '': $this->showUsage($output); return 0; default: $output->writeln('❌ 未知子命令:' . $action); $this->showUsage($output); return 1; } } /* ============================================================ * 子命令解析 / 帮助 * ========================================================== */ /** * 子命令来源:优先显式参数(`iot start`),其次调用时使用的旧命令名(`iot:start`)。 */ protected function resolveAction(Input $input): string { $action = strtolower(trim((string) $input->getArgument('action'))); if ($action !== '') { return $action; } // 注意:Input::getFirstArgument() 返回的是用户实际敲的命令名(tokens 未被消费) $invoked = strtolower(trim((string) $input->getFirstArgument())); $pos = strpos($invoked, ':'); return $pos === false ? '' : substr($invoked, $pos + 1); } protected function showUsage(Output $output): void { $output->writeln(''); $output->writeln('MQTT Broker 管理命令(必须在站点根目录执行)'); $output->writeln(' php think iot start [-d] 启动 Broker(-d 后台守护;不加 -d 为前台调试,SSH 断开即退出)'); $output->writeln(' php think iot stop [-f] 停止 Broker(-f 直接 SIGKILL,对付无响应/孤儿进程)'); $output->writeln(' php think iot restart [-d] 重启 Broker(线上固定加 -d;端口未释放会明确报错中止)'); $output->writeln(' php think iot status 查看状态(进程/端口/配置/环境/日志/DB 快照,返回码 0=正常)'); $output->writeln(' php think iot probe [pos] 曲线/时序自检:库表+行数+原始点+聚合桶(avg)+汇总(只读)'); $output->writeln(' php think iot:acltest ACL 规则验证(独立命令)'); $output->writeln(''); $output->writeln('start 附加参数:-H <地址> -p <端口> --tcp-only --ws-only(后两个用于 Windows 双进程调试)'); $output->writeln('status 附加参数:-l <行数> --no-db'); $output->writeln('旧写法 iot:start / iot:stop / iot:restart / iot:status 仍然可用(同一实现)'); $output->writeln(''); } /** 是否要求后台守护:优先取解析后的选项,再兜底查原始 argv(与 Workerman 判定一致) */ protected function daemonRequested(Input $input): bool { if ($input->getOption('daemon')) { return true; } $argv = (array) ($_SERVER['argv'] ?? []); return in_array('-d', $argv, true) || in_array('--daemon', $argv, true); } /* ============================================================ * start * ========================================================== */ protected function doStart(Input $input, Output $output): int { $host = (string) $input->getOption('host'); $port = (int) $input->getOption('port'); $mode = 'both'; if ($input->getOption('ws-only')) { $mode = 'ws'; } elseif ($input->getOption('tcp-only')) { $mode = 'tcp'; } $daemon = $this->daemonRequested($input); $output->writeln("启动 MQTT Broker on {$host}:{$port}(协议 MQTT 3.1.1 / 5.0, 模式 {$mode})"); if ($daemon) { $output->writeln('后台守护模式:Workerman 会把**所有输出(含启动失败原因)**重定向到 runtime/iot.log'); } else { $output->writeln('前台调试模式:按 Ctrl+C 停止(SSH 断开即退出;线上请用 iot start -d)'); } if (DIRECTORY_SEPARATOR === '\\') { $output->writeln('Windows 提示:一个 PHP 进程只能监听一个端口,TCP 与 WebSocket 请分窗口运行(--tcp-only / --ws-only)。'); } $this->warnPortBusy($output); // Workerman 的 daemonize 依据 argv 中的 -d(Worker::parseCommand → case 'start'), // 这里再显式设置一次,保证 `--daemon` 长写法与合并命令名下的行为一致。 if ($daemon) { Worker::$daemonize = true; } // ⚠️ 合并命令后 argv 里会出现裸的 'start'/'restart' 词:Workerman::parseCommand() 是 // 直接扫描 argv 取「第一个命中的白名单词」当自己的命令的。 // · `iot start -d` → 命中 'start' → 与旧版 iot:start 完全一致; // · `iot restart -d` → 命中 'restart' → 走 Workerman 的 restart 分支, // 该分支按 runtime/iot.pid 找旧 master 发信号;doRestart() 已先杀旧进程并删除 // pid 文件,所以 $masterPid=0、该分支只做「等待 → 设 daemonize」后继续启动, // 行为等价于 start(这也是能放心共用本方法的原因)。 // 旧写法 iot:restart 命中不了任何白名单词,则依赖 Broker::start() 注入 // Worker::$command='start' 兜底(见 Broker::start() 内注释)。 (new Broker())->start($host, $port, $mode); return 0; } /** * 端口预检:Workerman 的绑定失败信息只会写进 runtime/iot.log,终端看不到,这里先提示。 */ protected function warnPortBusy(Output $output): void { $report = Broker::portReport(); if (empty($report['busy'])) { return; } $hasMaster = false; foreach (Broker::findBrokerProcesses() as $info) { if ($info['role'] === 'master') { $hasMaster = true; break; } } $output->writeln('⚠️ 端口已被占用:' . implode(', ', $report['busy'])); if ($hasMaster) { $output->writeln(' 已有 master 在运行 → Workerman 会以「already running」直接退出(无需重复启动)。'); } else { $output->writeln(' 未发现 master 进程 → 多半是 master 被强杀后残留的孤儿 worker 仍持有监听端口。'); $output->writeln(' 处理:php think iot stop(必要时加 -f)后再启动。'); } } /* ============================================================ * stop * ========================================================== */ protected function doStop(Input $input, Output $output): int { if (DIRECTORY_SEPARATOR === '\\') { $output->writeln('Windows 下无守护进程概念:请直接关闭运行 iot start 的窗口(或按 Ctrl+C 停止)。'); return 0; } $pidFile = Broker::pidFilePath(); $procs = Broker::findBrokerProcesses(); if (empty($procs)) { $hadFile = is_file($pidFile); if ($hadFile) { @unlink($pidFile); } $output->writeln('未发现运行中的 MQTT Broker 进程。' . ($hadFile ? '(残留 pid 文件已清理)' : '')); foreach (Broker::portReport()['lines'] as $line) { $output->writeln(' · ' . $line); } return 0; } $output->writeln('发现 ' . count($procs) . ' 个 Broker 进程:'); foreach ($procs as $pid => $info) { $output->writeln(sprintf(' %-8s %-7s %s', $pid, $info['role'], $info['cmd'])); } $res = Broker::stopProcesses(array_keys($procs), 8, (bool) $input->getOption('force')); if (!$res['supported']) { $output->writeln('⚠️ 当前 PHP 未启用 posix 扩展,或 posix_kill 被 disable_functions 禁用,无法发送信号。'); $output->writeln(' 请用 root 手工执行:kill -9 ' . implode(' ', array_keys($procs))); $output->writeln(' (pid 文件已保留,手工杀干净后再执行一次本命令即可自动清理)'); return 1; } foreach ($res['steps'] as $step) { $output->writeln(' · ' . $step); } // 复核:进程 + 端口(端口才是「真的停掉了」的判据) $leftover = Broker::findBrokerProcesses(); $report = Broker::portReport(); if (!empty($leftover)) { $output->writeln('❌ 仍有进程存活:'); foreach ($leftover as $pid => $info) { $output->writeln(sprintf(' %-8s %-7s %s', $pid, $info['role'], $info['cmd'])); } $output->writeln(' 请用 root 执行:kill -9 ' . implode(' ', array_keys($leftover))); $output->writeln(' 或用:php think iot stop -f(强杀模式)'); return 1; } foreach ($report['lines'] as $line) { $output->writeln(' · ' . $line); } if ($report['busy']) { $output->writeln('⚠️ Broker 进程已全部退出,但端口仍被占用 —— 占用者不是本插件的 Broker:'); $output->writeln(' 请执行 ss -lntp | grep -E \':' . implode('|', $report['busy']) . '\' 查出占用进程。'); return 1; } if (is_file($pidFile)) { @unlink($pidFile); } $output->writeln('✅ MQTT Broker 已停止,端口已释放。'); return 0; } /* ============================================================ * restart * ========================================================== */ protected function doRestart(Input $input, Output $output): int { if (DIRECTORY_SEPARATOR === '\\') { $output->writeln('Windows 下请关闭运行 iot start 的窗口后,再重新运行 iot start' . '(需同时提供 TCP 与 WebSocket 时:--tcp-only 与 --ws-only 各开一个窗口)。'); return 0; } $pidFile = Broker::pidFilePath(); $procs = Broker::findBrokerProcesses(); // ① 停旧 if (!empty($procs)) { $output->writeln('停止旧进程(' . count($procs) . ' 个)...'); foreach ($procs as $pid => $info) { $output->writeln(sprintf(' %-8s %-7s %s', $pid, $info['role'], $info['cmd'])); } $res = Broker::stopProcesses(array_keys($procs), 8, false); if (!$res['supported']) { $output->writeln('⚠️ 当前 PHP 未启用 posix 扩展,或 posix_kill 被 disable_functions 禁用,无法停止旧进程,重启中止。'); $output->writeln(' 请用 root 执行:kill -9 ' . implode(' ', array_keys($procs)) . ' 后再运行本命令。'); return 1; } foreach ($res['steps'] as $step) { $output->writeln(' · ' . $step); } } else { $output->writeln('未发现运行中的旧进程,直接启动。'); } // ② 等端口真正释放(否则 Workerman 的 checkPortAvailable() 会抛异常并被 runAll() 静默吞掉) $report = ['lines' => [], 'busy' => []]; for ($i = 0; $i < 30; $i++) { $report = Broker::portReport(); if (empty($report['busy'])) { break; } usleep(300000); } if (!empty($report['busy'])) { $output->writeln('❌ 端口未释放,重启中止:'); foreach ($report['lines'] as $line) { $output->writeln(' · ' . $line); } $output->writeln(' 请执行 ss -lntp | grep -E \':' . implode('|', $report['busy']) . '\' 查出占用进程(可能不是本插件的 Broker),处理后再重启。'); return 1; } // pid 文件先删掉:Workerman 的 restart 分支会按它找旧 master,删掉可确保它只做启动 if (is_file($pidFile)) { @unlink($pidFile); } // ③ 启新:daemonize 由 -d 显式决定(不依赖 argv 解析,更可靠) $daemon = $this->daemonRequested($input); $output->writeln('启动新进程(' . ($daemon ? '后台守护' : '前台调试,SSH 断开即退出') . ')...'); Worker::$daemonize = $daemon; (new Broker())->start(); return 0; } /* ============================================================ * status * ========================================================== */ protected function doStatus(Input $input, Output $output): int { $output->writeln(''); $output->writeln('MQTT Broker 状态 ' . date('Y-m-d H:i:s')); $output->writeln(str_repeat('─', 60)); if (is_dir('/proc')) { $healthy = $this->showProcess($output); $healthy = $this->showPidFile($output) && $healthy; $healthy = $this->showPorts($output) && $healthy; } else { // Windows 无 /proc,端口是否监听就是唯一判据 $this->showProcess($output); $healthy = $this->showPorts($output); } $this->showConfig($output); $this->showEnv($output); $this->showLog($output, max(1, (int) $input->getOption('lines'))); if (!$input->getOption('no-db')) { $this->showDb($output); } $output->writeln(str_repeat('─', 60)); $output->writeln($healthy ? '✅ 结论:Broker 正在运行且端口正常。' : '❌ 结论:Broker 未正常运行,请看上面各段的异常提示。'); return $healthy ? 0 : 1; } /** 进程段 */ protected function showProcess(Output $output): bool { if (!is_dir('/proc')) { $output->writeln('[进程] Windows 环境无法扫描 /proc,请用 netstat -ano | findstr "1883 8083" 查看。'); return false; } $procs = Broker::findBrokerProcesses(); if (empty($procs)) { $output->writeln('[进程] ❌ 未发现运行中的 Broker 进程。'); $output->writeln(' 启动:php think iot start -d(必须在站点根执行)'); return false; } $output->writeln('[进程] 共 ' . count($procs) . ' 个'); $output->writeln(' ' . sprintf('%-8s %-7s %-12s %s', 'PID', '角色', '已运行', '进程标题')); $hasMaster = false; $hasOrphan = false; foreach ($procs as $pid => $info) { $started = Broker::processStartTime($pid); $output->writeln(' ' . sprintf( '%-8s %-7s %-12s %s', $pid, $info['role'], $started > 0 ? $this->humanDuration(time() - $started) : '-', $info['cmd'] )); $hasMaster = $hasMaster || $info['role'] === 'master'; $hasOrphan = $hasOrphan || $info['role'] === 'orphan'; } if ($hasOrphan) { $output->writeln(' ⚠️ 存在 orphan(孤儿)worker:master 已被杀但子进程仍持有监听端口。'); $output->writeln(' 处理:php think iot stop(会自动兜底 SIGKILL)'); } if (!$hasMaster) { $output->writeln(' ⚠️ 没有 master 进程,但仍有 worker 存活 → 端口会被占着,必须清掉后重启。'); } return $hasMaster; } /** pid 文件段 */ protected function showPidFile(Output $output): bool { $pidFile = Broker::pidFilePath(); if (!is_file($pidFile)) { $output->writeln('[pid 文件] ❌ 缺失:' . $pidFile); $output->writeln(' 影响:iot stop / iot restart 的常规路径失效(新版已能按进程标题兜底查找)。'); return false; } $pid = (int) trim((string) @file_get_contents($pidFile)); $ok = $pid > 0 && function_exists('posix_kill') && @posix_kill($pid, 0); $output->writeln('[pid 文件] ' . $pidFile . ' → ' . $pid . ($ok ? '(进程存活)' : '(⚠️ 进程已不存在,属残留文件)')); return $ok; } /** 端口段 */ protected function showPorts(Output $output): bool { $report = Broker::portReport(); if (empty($report['lines'])) { $output->writeln('[端口] 未能解析配置中的端口(port / ws_port),跳过。'); return true; } $output->writeln('[端口]'); foreach ($report['lines'] as $line) { $output->writeln(' · ' . $line); } if (!empty($report['busy'])) { $output->writeln(' 说明:占用端口但进程列表里没有 master 时,占用者可能不是本插件。'); $output->writeln(' 查询:ss -lntp | grep -E \':' . implode('|', $report['busy']) . '\''); } return empty($report['busy']); } /** 配置段(⚠️ 进程只在启动时读一次) */ protected function showConfig(Output $output): void { $cfg = Broker::currentConfig(); if ($cfg === null) { $output->writeln('[配置] ⚠️ 读取失败(AddonService::config(\'iot\') 抛异常),无法展示。'); return; } $output->writeln('[配置] 库/缓存中的当前值(⚠️ Broker 只在启动时读一次,改完必须 iot restart -d)'); $parts = []; foreach (self::SHOW_KEYS as $key) { if (array_key_exists($key, $cfg)) { $parts[] = $key . '=' . (is_bool($cfg[$key]) ? (int) $cfg[$key] : (string) $cfg[$key]); } elseif (in_array($key, ['allow_member_login', 'acl_enabled', 'acl_default'], true)) { $parts[] = $key . '=⚠️未设置'; } } $output->writeln(' ' . implode(' ', $parts)); if (!array_key_exists('allow_member_login', $cfg)) { $output->writeln(' ⚠️ allow_member_login 未保存过 → 会员账号/密码登录恒为「关」'); $output->writeln(' 表现:用会员账号连上后订阅不了自己设备(身份降级 anonymous-named,无 member_id)。'); $output->writeln(' 处理:后台保存一次 MQTT 配置 → php think iot restart -d'); } } /** 环境段(决定 stop/restart 能不能工作) */ protected function showEnv(Output $output): void { $disabled = (string) ini_get('disable_functions'); $posixOk = function_exists('posix_kill'); $posixHit = $disabled !== '' && str_contains($disabled, 'posix_kill'); $pcntlOk = function_exists('pcntl_fork'); $pidDir = dirname(Broker::pidFilePath()); $output->writeln('[环境] PHP ' . PHP_VERSION . ' ' . PHP_OS . ' disable_functions=' . ($disabled === '' ? '(空)' : $disabled)); $output->writeln(' posix_kill=' . ($posixOk ? '可用' : '❌不可用') . ($posixHit ? '(被 disable_functions 禁用)' : '') . ' pcntl_fork=' . ($pcntlOk ? '可用' : '❌不可用') . ' /proc=' . (is_dir('/proc') ? '可读' : '不可读') . ' pid 目录=' . (is_dir($pidDir) ? (is_writable($pidDir) ? '可写' : '❌不可写') : '❌不存在')); if (!$posixOk || !$pcntlOk) { $output->writeln(' ⚠️ posix/pcntl 缺失会导致 iot stop / iot restart 失效,' . '且必须用与启动 Broker 相同的那个 PHP 来执行命令(宝塔多版本 PHP 极易用错)。'); } } /** 日志末尾(daemon 模式唯一的错误出口) */ protected function showLog(Output $output, int $lines): void { $logFile = rtrim((string) runtime_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'iot.log'; if (!is_file($logFile)) { $output->writeln('[日志] 无 ' . $logFile . '(未以 -d 启动,或从未写入)'); return; } $size = (int) @filesize($logFile); $output->writeln('[日志] ' . $logFile . '(' . $this->humanSize($size) . ',末 ' . $lines . ' 行)'); foreach ($this->tail($logFile, $lines) as $line) { $output->writeln(' ' . $line); } } /** DB 快照段(全部防御式,失败不影响主流程) */ protected function showDb(Output $output): void { try { $total = (int) Db::name('iot_device')->count(); $bound = (int) Db::name('iot_device')->where('member_id', '>', 0)->count(); $output->writeln('[数据库] iot_device 共 ' . $total . ' 台,已绑定会员 ' . $bound . ' 台'); } catch (\Throwable $e) { $output->writeln('[数据库] ⚠️ iot_device 查询失败:' . $e->getMessage()); return; } try { $online = (int) Db::name('iot_connection')->where('status', 1)->count(); $output->writeln(' iot_connection status=1(记录值,非实时,实时看日志/后台):' . $online . ' 条'); } catch (\Throwable $e) { // 表可能不存在,忽略 } try { $rows = Db::name('iot_device') ->field('id,sn,uuid,member_id,create_at') ->order('id', 'desc') ->limit(5) ->select() ->toArray(); if (empty($rows)) { $output->writeln(' 最近设备:(空)'); return; } $output->writeln(' 最近 5 台:'); foreach ($rows as $row) { $output->writeln(sprintf( ' #%s sn=%s uuid=%s member_id=%s %s', $row['id'] ?? '-', $row['sn'] ?? '-', $row['uuid'] ?? '-', $row['member_id'] ?? '-', !empty($row['create_at']) ? date('Y-m-d H:i', (int) $row['create_at']) : '-' )); } } catch (\Throwable $e) { $output->writeln(' 最近设备查询失败:' . $e->getMessage()); } } /* ============================================================ * 小工具 * ========================================================== */ /** 读文件末尾 N 行(只读尾部 128KB,避免大日志拖慢) */ protected function tail(string $file, int $lines): array { $fp = @fopen($file, 'rb'); if (!$fp) { return ['(无法读取)']; } $size = (int) @filesize($file); $chunk = 131072; $offset = max(0, $size - $chunk); fseek($fp, $offset); $buf = (string) fread($fp, $chunk); fclose($fp); if ($offset > 0) { $pos = strpos($buf, "\n"); $buf = ($pos === false) ? '' : substr($buf, $pos + 1); } $arr = preg_split("/\r\n|\n|\r/", rtrim($buf)) ?: []; $arr = array_slice($arr, -max(1, $lines)); return array_filter($arr, static fn ($l): bool => $l !== ''); } /** 秒 → 人类可读 */ protected function humanDuration(int $seconds): string { if ($seconds < 60) { return $seconds . ' 秒'; } if ($seconds < 3600) { return intdiv($seconds, 60) . ' 分'; } if ($seconds < 86400) { return intdiv($seconds, 3600) . ' 时 ' . intdiv($seconds % 3600, 60) . ' 分'; } return intdiv($seconds, 86400) . ' 天 ' . intdiv($seconds % 86400, 3600) . ' 时'; } /** 字节 → 人类可读 */ protected function humanSize(int $bytes): string { if ($bytes < 1024) { return $bytes . 'B'; } if ($bytes < 1048576) { return round($bytes / 1024, 1) . 'KB'; } return round($bytes / 1048576, 1) . 'MB'; } }