Explorar el Código

feat(core): 新增状态API与MQTT日志,支持前端实时更新

- 添加 /api/status 接口提供实时状态
- 完善 MQTT 连接与消息日志记录
- 更新视图以支持动态状态刷新
- 修正默认设备ID配置
yangkaixiang hace 6 días
padre
commit
95903cd10f
Se han modificado 6 ficheros con 110 adiciones y 13 borrados
  1. 63 0
      public/app.js
  2. 2 2
      src/db.js
  3. 19 4
      src/index.js
  4. 18 0
      src/mqttService.js
  5. 5 5
      views/index.ejs
  6. 3 2
      views/partials/nav.ejs

+ 63 - 0
public/app.js

@@ -0,0 +1,63 @@
+(function () {
+  function formatDateTime(value) {
+    if (!value) return '暂无';
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return value;
+    const pad = (number) => String(number).padStart(2, '0');
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+  }
+
+  function updateMqttStatus(status) {
+    const root = document.getElementById('mqttStatus');
+    const text = document.getElementById('mqttStatusText');
+    if (!root || !text || !status) return;
+
+    root.classList.toggle('connected', Boolean(status.connected));
+    root.classList.toggle('disconnected', !status.connected);
+    root.title = status.lastError || '';
+    text.textContent = `MQTT ${status.connected ? '已连接' : (status.message || '未连接')}`;
+  }
+
+  function updateHomeStatus(data) {
+    const summary = document.getElementById('lightSummary');
+    if (summary && data.summary) {
+      summary.textContent = `${data.summary.on} 组开启,${data.summary.off} 组关闭。`;
+    }
+
+    const nextOccurrence = document.getElementById('nextOccurrenceText');
+    if (nextOccurrence) {
+      nextOccurrence.textContent = data.nextOccurrence
+        ? `下一次:${data.nextOccurrence.actionLabel},${data.nextOccurrence.at}。`
+        : '暂无后续计划。';
+    }
+
+    for (const state of data.states || []) {
+      const card = document.querySelector(`[data-state-card="${state.channel}"]`);
+      if (!card) continue;
+
+      const value = card.querySelector('[data-state-value]');
+      const lastSeen = card.querySelector('[data-last-seen]');
+      if (value) {
+        value.textContent = state.state;
+        value.classList.remove('state-on', 'state-off', 'state-unknown');
+        value.classList.add(`state-${String(state.state).toLowerCase()}`);
+      }
+      if (lastSeen) lastSeen.textContent = formatDateTime(state.last_seen_at);
+    }
+  }
+
+  async function refreshStatus() {
+    try {
+      const response = await fetch('/api/status', { cache: 'no-store' });
+      if (!response.ok) return;
+      const data = await response.json();
+      updateMqttStatus(data.mqttStatus);
+      updateHomeStatus(data);
+    } catch {
+      updateMqttStatus({ connected: false, message: '刷新失败' });
+    }
+  }
+
+  refreshStatus();
+  setInterval(refreshStatus, 5000);
+})();

+ 2 - 2
src/db.js

@@ -89,7 +89,7 @@ function seed() {
     mqtt_url: 'mqtt://192.168.1.109:38901',
     topic_version: 'v2',
     product_key: 'wss1',
-    device_id: '16F928',
+    device_id: 'b94300',
     server_port: '3000',
     log_retention_days: '60'
   };
@@ -126,7 +126,7 @@ function getTopicConfig() {
   const config = getConfig();
   const version = config.topic_version || 'v2';
   const productKey = config.product_key || 'wss1';
-  const deviceId = config.device_id || '16F928';
+  const deviceId = config.device_id || 'b94300';
   return {
     mqttUrl: config.mqtt_url || 'mqtt://192.168.1.109:38901',
     version,

+ 19 - 4
src/index.js

@@ -33,21 +33,36 @@ app.locals.holidayTypeLabel = (type) => ({ holiday: '节假日', adjusted_workda
 
 app.get('/', (req, res) => {
   const states = db.prepare('SELECT * FROM light_states ORDER BY channel').all();
-  const today = dayjs().startOf('week').add(1, 'day');
-  const nextWeek = today.add(7, 'day');
   res.render('index', {
     title: 'Office Light',
     config: getConfig(),
     topicConfig: getTopicConfig(),
     states,
     nextOccurrence: getNextOccurrence(),
-    thisWeek: listOccurrences(today, 7),
-    nextWeek: listOccurrences(nextWeek, 7),
     message: req.query.message,
     error: req.query.error
   });
 });
 
+app.get('/api/status', (req, res) => {
+  const states = db.prepare('SELECT * FROM light_states ORDER BY channel').all();
+  const nextOccurrence = getNextOccurrence();
+  res.json({
+    mqttStatus: mqttService.getStatus(),
+    states,
+    summary: {
+      on: states.filter((item) => item.state === 'ON').length,
+      off: states.filter((item) => item.state === 'OFF').length,
+      unknown: states.filter((item) => item.state === 'UNKNOWN').length
+    },
+    nextOccurrence: nextOccurrence ? {
+      action: nextOccurrence.action,
+      actionLabel: res.locals.actionLabel(nextOccurrence.action),
+      at: nextOccurrence.at
+    } : null
+  });
+});
+
 app.post('/control', async (req, res) => {
   try {
     await mqttService.sendCommand({

+ 18 - 0
src/mqttService.js

@@ -13,6 +13,12 @@ const ACTION_STATE = {
   close: 'OFF'
 };
 
+function logMqtt(direction, message, details = {}) {
+  const timestamp = dayjs().format('YYYY-MM-DD HH:mm:ss');
+  const suffix = Object.keys(details).length ? ` ${JSON.stringify(details)}` : '';
+  console.log(`[${timestamp}] [MQTT:${direction}] ${message}${suffix}`);
+}
+
 class MqttService {
   constructor() {
     this.client = null;
@@ -41,22 +47,29 @@ class MqttService {
         connectedAt: dayjs().format('YYYY-MM-DD HH:mm:ss')
       };
       this.client.subscribe([latestTopicConfig.statusTopic, latestTopicConfig.telemetryTopic]);
+      logMqtt('CONNECT', 'connected', {
+        url: latestTopicConfig.mqttUrl,
+        topics: [latestTopicConfig.statusTopic, latestTopicConfig.telemetryTopic]
+      });
     });
 
     this.client.on('reconnect', () => {
       this.status.message = '重连中';
       this.status.connected = false;
+      logMqtt('RECONNECT', 'reconnecting');
     });
 
     this.client.on('close', () => {
       this.status.connected = false;
       this.status.message = '连接已断开';
+      logMqtt('CLOSE', 'connection closed');
     });
 
     this.client.on('error', (error) => {
       this.status.connected = false;
       this.status.message = '连接错误';
       this.status.lastError = error.message;
+      logMqtt('ERROR', error.message);
     });
 
     this.client.on('message', (topic, payloadBuffer) => {
@@ -117,8 +130,10 @@ class MqttService {
 
       const publishAttempt = () => {
         attempts += 1;
+        logMqtt('SEND', 'publish command', { topic, payload, channel, action, attempt: attempts });
         this.client.publish(topic, payload, { qos: 0 }, (error) => {
           if (error) {
+            logMqtt('ERROR', 'publish failed', { topic, payload, error: error.message });
             cleanup();
             resolve({ ok: false, message: error.message });
           }
@@ -129,6 +144,7 @@ class MqttService {
             publishAttempt();
             return;
           }
+          logMqtt('TIMEOUT', 'wait response timeout', { topic, payload, channel, action, attempts });
           cleanup();
           resolve({ ok: false, message: '等待设备回执超时。' });
         }, 5000);
@@ -160,6 +176,7 @@ class MqttService {
   }
 
   handleMessage(topic, payload) {
+    logMqtt('RECV', 'message received', { topic, payload });
     const now = dayjs().toISOString();
     db.prepare('INSERT INTO mqtt_messages (created_at, topic, payload) VALUES (?, ?, ?)').run(now, topic, payload);
 
@@ -222,6 +239,7 @@ class MqttService {
       if (source === 'telemetry' && pending.action !== 'query') continue;
       if (this.matchesPending(parsed, pending)) {
         const prefix = source === 'telemetry' ? '已收到设备状态上报' : '设备已回执';
+        logMqtt('MATCH', prefix, { source, payload });
         pending.resolve(`${prefix}:${payload}`);
       }
     }

+ 5 - 5
views/index.ejs

@@ -16,8 +16,8 @@
       <p class="eyebrow"><%= topicConfig.productKey %> · <%= topicConfig.deviceId %></p>
       <h1>办公室灯光</h1>
       <p class="lead">
-        <%= states.filter((item) => item.state === 'ON').length %> 组开启,<%= states.filter((item) => item.state === 'OFF').length %> 组关闭。
-        <% if (nextOccurrence) { %>下一次:<%= actionLabel(nextOccurrence.action) %>,<%= nextOccurrence.at %>。<% } else { %>暂无后续计划。<% } %>
+        <span id="lightSummary"><%= states.filter((item) => item.state === 'ON').length %> 组开启,<%= states.filter((item) => item.state === 'OFF').length %> 组关闭。</span>
+        <span id="nextOccurrenceText"><% if (nextOccurrence) { %>下一次:<%= actionLabel(nextOccurrence.action) %>,<%= nextOccurrence.at %>。<% } else { %>暂无后续计划。<% } %></span>
       </p>
       <div class="hero-actions">
         <form method="post" action="/control"><input type="hidden" name="channel" value="0"><button class="button-primary" name="action" value="open">全部打开</button></form>
@@ -27,10 +27,10 @@
       <p class="status-note">设备每 5 分钟主动上报,也可以手动查询。</p>
       <div class="state-grid home-state-grid">
         <% states.forEach((state) => { %>
-          <article class="state-card">
+          <article class="state-card" data-state-card="<%= state.channel %>">
             <p class="eyebrow"><%= state.name %></p>
-            <h3 class="state-value state-<%= state.state.toLowerCase() %>"><%= state.state %></h3>
-            <p>最后更新:<%= state.last_seen_at ? dayjs(state.last_seen_at).format('YYYY-MM-DD HH:mm:ss') : '暂无' %></p>
+            <h3 class="state-value state-<%= state.state.toLowerCase() %>" data-state-value><%= state.state %></h3>
+            <p>最后更新:<span data-last-seen><%= state.last_seen_at ? dayjs(state.last_seen_at).format('YYYY-MM-DD HH:mm:ss') : '暂无' %></span></p>
             <div class="inline-actions">
               <form method="post" action="/control"><input type="hidden" name="channel" value="<%= state.channel %>"><button class="button-primary small" name="action" value="open">打开</button></form>
               <form method="post" action="/control"><input type="hidden" name="channel" value="<%= state.channel %>"><button class="button-secondary small" name="action" value="close">关闭</button></form>

+ 3 - 2
views/partials/nav.ejs

@@ -9,8 +9,9 @@
     <a class="<%= navPath.startsWith('/settings') ? 'active' : '' %>" href="/settings">设置</a>
   </div>
   <% const navMqttStatus = typeof getMqttStatus === 'function' ? getMqttStatus() : { connected: false, message: '未连接' }; %>
-  <div class="mqtt-status <%= navMqttStatus.connected ? 'connected' : 'disconnected' %>" title="<%= navMqttStatus.lastError || '' %>">
+  <div class="mqtt-status <%= navMqttStatus.connected ? 'connected' : 'disconnected' %>" id="mqttStatus" title="<%= navMqttStatus.lastError || '' %>">
     <span class="mqtt-dot"></span>
-    <span>MQTT <%= navMqttStatus.connected ? '已连接' : (navMqttStatus.message || '未连接') %></span>
+    <span id="mqttStatusText">MQTT <%= navMqttStatus.connected ? '已连接' : (navMqttStatus.message || '未连接') %></span>
   </div>
 </nav>
+<script src="/app.js" defer></script>