码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
index.js29.4 KB
/**
 * 插件:Home Assistant 智能家居
 * --------------------------------------------------
 * 对接 Home Assistant REST API,通过微信控制智能家居设备。
 *
 * 核心指令(直接文本触发):
 *   设备 / 设备 列表           → 列出所有实体(按域分组)
 *   状态 <实体ID>               → 查询指定实体状态
 *   开灯 / 关灯                 → 开关所有灯
 *   打开 <设备名>               → 打开指定设备
 *   关闭 <设备名>               → 关闭指定设备
 *   传感器                      → 列出传感器数据
 *   温度                        → 列出温度传感器
 *   遥控器 [客厅/卧室/...]      → 弹出常用设备快捷面板(需配置)
 *
 * 智能助手(自然语言):
 *   「把客厅灯关了」「卧室空调开到26度」「现在家里温度多少」
 *   由 smart 插件通过 function calling 自动调用。
 */

const axios = require('axios');
const db = require('../../lib/db');

// ==================== 常量 ====================
const PLUGIN_ID = 'home-assistant';
const HTTP_TIMEOUT = 10000;           // HA 请求超时(ms)
const ENTITY_CACHE_TTL = 30000;       // 实体列表缓存(30s)
const ICON_MAP = {
  light: '[ON] ', switch: '[Switch] ', climate: '[Climate] ', sensor: '[Sensor] ',
  binary_sensor: '[Alert] ', cover: '[Cover] ', lock: '[Lock] ', media_player: '[Media] ',
  fan: '[Fan] ', vacuum: '[Vacuum] ', camera: '[Camera] ', scene: '[Scene] ',
  automation: '[Auto] ', script: '[Script] ', input_boolean: '[Toggle] ',
  person: '[Person] ', zone: '[Zone] ', sun: '[Sun] ', weather: '[Weather] ',
  button: '[Btn] ', number: '[Num] ', select: '[Sel] ', text: '[Text] ',
  device_tracker: '[Tracker] ', update: '[Update] ', alarm_control_panel: '[Alarm] ',
};

// ==================== 实体列表缓存 ====================
let entityCache = { data: null, botId: null, ts: 0 };

function getDomain(entityId) {
  return (entityId || '').split('.')[0] || '';
}

function getDomainLabel(domain) {
  const labels = {
    light: '灯具', switch: '开关', climate: '空调/温控', sensor: '传感器',
    binary_sensor: '探测器', cover: '窗帘/卷帘', lock: '门锁',
    media_player: '媒体', fan: '风扇', vacuum: '扫地机',
    camera: '摄像头', scene: '场景', automation: '自动化',
    script: '脚本', person: '人员', zone: '区域',
    sun: '太阳', weather: '天气', button: '按钮',
    device_tracker: '设备追踪', update: '更新', alarm_control_panel: '安防',
    input_boolean: '开关', number: '数值', select: '选择器',
  };
  return labels[domain] || domain;
}

function getIcon(entityId) {
  return ICON_MAP[getDomain(entityId)] || '';
}

function friendlyState(state) {
  if (state == null || state === undefined) return '未知';
  const s = String(state);
  if (s === 'on') return '开';
  if (s === 'off') return '关';
  if (s === 'unavailable') return '[OFF] 不可用';
  if (s === 'unknown') return '未知';
  if (s === 'home') return '[HA] 在家';
  if (s === 'not_home') return '离家';
  if (s === 'locked') return '已锁';
  if (s === 'unlocked') return '未锁';
  if (s === 'open') return '开';
  if (s === 'closed') return '关';
  if (s === 'playing') return '播放中';
  if (s === 'paused') return '暂停';
  if (s === 'idle') return '空闲';
  return s;
}

// ==================== 配置 ====================
async function loadConfig(botId) {
  const rows = await db.rows(
    'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
    [botId, PLUGIN_ID]
  );
  const cfg = {};
  for (const r of rows) cfg[r.config_key] = r.config_value;
  return cfg;
}

function validateConfig(cfg) {
  const missing = [];
  if (!cfg.ha_url) missing.push('HA 地址 (ha_url)');
  if (!cfg.ha_token) missing.push('访问令牌 (ha_token)');
  return { valid: missing.length === 0, missing };
}

// ==================== HA API 客户端 ====================
function createClient(cfg) {
  const base = (cfg.ha_url || '').replace(/\/+$/, '');
  const headers = {
    Authorization: `Bearer ${cfg.ha_token}`,
    'Content-Type': 'application/json',
  };
  const http = axios.create({ baseURL: base, timeout: HTTP_TIMEOUT, headers });
  return {
    async get(path) {
      const res = await http.get(path);
      return res.data;
    },
    async post(path, data) {
      const res = await http.post(path, data);
      return res.data;
    },
  };
}

// ==================== 获取实体列表(带缓存) ====================
async function getEntities(cfg, botId, force = false) {
  if (!force && entityCache.botId === botId && entityCache.data && (Date.now() - entityCache.ts) < ENTITY_CACHE_TTL) {
    return entityCache.data;
  }
  const ha = createClient(cfg);
  const entities = await ha.get('/api/states');
  entityCache = { data: entities, botId, ts: Date.now() };
  return entities;
}

/** 模糊匹配实体 ID 或 friendly_name */
function findEntity(entities, query) {
  const q = query.toLowerCase().trim();
  if (!q) return null;

  // 精确匹配 entity_id
  let match = entities.find(e => e.entity_id.toLowerCase() === q);
  if (match) return match;

  // entity_id 包含
  match = entities.find(e => e.entity_id.toLowerCase().includes(q));
  if (match) return match;

  // friendly_name 精确匹配
  match = entities.find(e => e.attributes?.friendly_name?.toLowerCase() === q);
  if (match) return match;

  // friendly_name 包含
  match = entities.find(e => e.attributes?.friendly_name?.toLowerCase().includes(q));
  return match || null;
}

/** 在实体列表中搜索多个 */
function findEntities(entities, query, domain) {
  const q = query.toLowerCase().trim();
  let list = entities;
  if (domain) list = list.filter(e => getDomain(e.entity_id) === domain);
  if (!q) return list;
  return list.filter(e =>
    e.entity_id.toLowerCase().includes(q) ||
    (e.attributes?.friendly_name || '').toLowerCase().includes(q)
  );
}

// ==================== 构建回复 ====================
function formatEntityLine(e, showDomain = false) {
  const icon = getIcon(e.entity_id);
  const name = e.attributes?.friendly_name || e.entity_id;
  const state = friendlyState(e.state);
  const unit = e.attributes?.unit_of_measurement ? ` ${e.attributes.unit_of_measurement}` : '';
  const domainTag = showDomain ? ` [${getDomainLabel(getDomain(e.entity_id))}]` : '';
  return `${icon} ${name}:${state}${unit}${domainTag}`;
}

// ==================== 指令处理 ====================
async function handleList(cfg, botId, entityName) {
  const entities = await getEntities(cfg, botId);
  if (!entities || entities.length === 0) return '[OFF] 未获取到任何实体,请检查 HA 连接配置。';

  if (entityName) {
    // 搜索特定实体或域
    const domainOnly = !entityName.includes('.');
    let list = domainOnly
      ? findEntities(entities, entityName)
      : [findEntity(entities, entityName)].filter(Boolean);

    if (list.length === 0) return `[OFF] 未找到匹配「${entityName}」的设备。`;
    if (list.length === 1) {
      const e = list[0];
      const lines = [formatEntityLine(e, true)];
      if (e.attributes) {
        const attrs = { ...e.attributes };
        delete attrs.friendly_name;
        delete attrs.icon;
        delete attrs.supported_features;
        delete attrs.entity_picture;
        const keys = Object.keys(attrs);
        if (keys.length > 0) {
          lines.push('');
          const show = keys.slice(0, 8);
          for (const k of show) {
            const v = typeof attrs[k] === 'object' ? JSON.stringify(attrs[k]) : attrs[k];
            lines.push(`  ${k}:${v}`);
          }
          if (keys.length > 8) lines.push(`  … 还有 ${keys.length - 8} 个属性`);
        }
      }
      return lines.join('\n');
    }
    // 多结果,按域分组
    const grouped = {};
    for (const e of list) {
      const d = getDomain(e.entity_id);
      if (!grouped[d]) grouped[d] = [];
      grouped[d].push(e);
    }
    const lines = [`[Search] 搜索「${entityName}」(${list.length} 个结果):`];
    for (const [domain, items] of Object.entries(grouped)) {
      lines.push(`\n【${getDomainLabel(domain)}】`);
      for (const e of items) lines.push(formatEntityLine(e));
    }
    return lines.join('\n');
  }

  // 全量列表,按域分组
  const grouped = {};
  for (const e of entities) {
    const d = getDomain(e.entity_id);
    if (!grouped[d]) grouped[d] = [];
    grouped[d].push(e);
  }

  const lines = [`[HA] Home Assistant(${entities.length} 个实体):`];
  // 常用域排前面
  const order = ['light', 'switch', 'climate', 'sensor', 'binary_sensor', 'cover', 'lock', 'media_player', 'fan', 'vacuum'];
  const ordered = [...order.filter(d => grouped[d]), ...Object.keys(grouped).filter(d => !order.includes(d)).sort()];

  for (const domain of ordered) {
    const items = grouped[domain];
    if (items.length <= 5) {
      for (const e of items) lines.push(formatEntityLine(e));
    } else {
      lines.push(`\n【${getDomainLabel(domain)}】(${items.length} 个)`);
      for (const e of items.slice(0, 5)) lines.push(formatEntityLine(e));
      lines.push(`  … 还有 ${items.length - 5} 个,发送「设备 ${getDomainLabel(domain)}」查看全部`);
    }
  }
  return lines.join('\n');
}

async function handleTurnOn(cfg, botId, query) {
  let entities = await getEntities(cfg, botId);
  const ha = createClient(cfg);

  // 「灯 全开」→ 全部灯
  if (!query || query === '所有灯' || query === '全部灯') {
    const lights = entities.filter(e => getDomain(e.entity_id) === 'light');
    if (lights.length === 0) return '[OFF] 未找到任何灯具。';
    let done = 0;
    for (const l of lights) {
      try { await ha.post(`/api/services/light/turn_on`, { entity_id: l.entity_id }); done++; }
      catch (e) { /* skip individual failures */ }
    }
    entityCache.ts = 0; // 使缓存失效
    return `[ON] 已打开 ${done}/${lights.length} 个灯具`;
  }

  const match = findEntity(entities, query);
  if (!match) return `[OFF] 未找到「${query}」对应的设备。`;

  const domain = getDomain(match.entity_id);
  try {
    await ha.post(`/api/services/${domain}/turn_on`, { entity_id: match.entity_id });
    entityCache.ts = 0;
    return `[ON] 已打开 ${match.attributes?.friendly_name || match.entity_id}`;
  } catch (e) {
    if (e.response?.status === 404) return `[OFF] 设备「${match.entity_id}」不支持开启操作。`;
    return `[OFF] 操作失败:${e.response?.data?.message || e.message}`;
  }
}

async function handleTurnOff(cfg, botId, query) {
  let entities = await getEntities(cfg, botId);
  const ha = createClient(cfg);

  // 「关灯」「灯 全关」
  if (!query || query === '所有灯' || query === '全部灯') {
    const lights = entities.filter(e => getDomain(e.entity_id) === 'light');
    if (lights.length === 0) return '[OFF] 未找到任何灯具。';
    let done = 0;
    for (const l of lights) {
      try { await ha.post(`/api/services/light/turn_off`, { entity_id: l.entity_id }); done++; }
      catch (e) { /* skip individual failures */ }
    }
    entityCache.ts = 0;
    return `[ON] 已关闭 ${done}/${lights.length} 个灯具`;
  }

  const match = findEntity(entities, query);
  if (!match) return `[OFF] 未找到「${query}」对应的设备。`;

  const domain = getDomain(match.entity_id);
  try {
    await ha.post(`/api/services/${domain}/turn_off`, { entity_id: match.entity_id });
    entityCache.ts = 0;
    return `[ON] 已关闭 ${match.attributes?.friendly_name || match.entity_id}`;
  } catch (e) {
    if (e.response?.status === 404) return `[OFF] 设备「${match.entity_id}」不支持关闭操作。`;
    return `[OFF] 操作失败:${e.response?.data?.message || e.message}`;
  }
}

async function handleSensor(cfg, botId, query) {
  const entities = await getEntities(cfg, botId);
  let sensors = entities.filter(e => getDomain(e.entity_id) === 'sensor');
  if (query) {
    sensors = sensors.filter(e =>
      e.entity_id.toLowerCase().includes(query.toLowerCase()) ||
      (e.attributes?.friendly_name || '').toLowerCase().includes(query.toLowerCase())
    );
  }
  if (sensors.length === 0) return query ? `[OFF] 未找到匹配「${query}」的传感器` : '[OFF] 没有传感器数据。';

  // 排除一些不重要的传感器
  const excludeDomains = ['update.', 'sun.', 'zone.', 'person.'];
  sensors = sensors.filter(e => !excludeDomains.some(p => e.entity_id.startsWith(p)));

  // 优先显示数值类传感器
  const lines = [`[Sensor] 传感器数据(${sensors.length} 个):`];
  for (const s of sensors.slice(0, 20)) {
    lines.push(formatEntityLine(s));
  }
  if (sensors.length > 20) lines.push(`  … 还有 ${sensors.length - 20} 个传感器`);
  return lines.join('\n');
}

async function handleTemperature(cfg, botId) {
  const entities = await getEntities(cfg, botId);
  const tempEntities = entities.filter(e =>
    e.attributes?.unit_of_measurement === '°C' || e.attributes?.unit_of_measurement === '°F' ||
    e.entity_id.includes('temperature') || e.entity_id.includes('temp')
  );
  if (tempEntities.length === 0) return '[OFF] 未找到温度传感器。';

  const lines = ['[Temp] 温度数据:'];
  for (const t of tempEntities) {
    lines.push(formatEntityLine(t));
  }
  return lines.join('\n');
}

async function handleCallService(cfg, botId, domain, service, targetQuery, dataStr) {
  const ha = createClient(cfg);
  const serviceData = {};

  // 如果有 target,查找实体
  if (targetQuery) {
    const entities = await getEntities(cfg, botId);
    const match = findEntity(entities, targetQuery);
    if (!match) return `[OFF] 未找到「${targetQuery}」对应的设备。`;
    serviceData.entity_id = match.entity_id;
  }

  // 解析额外参数 {key:val,key2:val2}
  if (dataStr) {
    try {
      const parsed = dataStr.startsWith('{') ? JSON.parse(dataStr) : (() => {
        const obj = {};
        dataStr.split(',').forEach(pair => {
          const [k, v] = pair.split(':').map(s => s.trim());
          obj[k] = isNaN(v) ? v : Number(v);
        });
        return obj;
      })();
      Object.assign(serviceData, parsed);
    } catch (e) {
      return `[OFF] 参数格式错误:${e.message}`;
    }
  }

  try {
    const result = await ha.post(`/api/services/${domain}/${service}`, serviceData);
    entityCache.ts = 0;
    const target = serviceData.entity_id ? ` ${serviceData.entity_id}` : '';
    return `[ON] 已执行 ${domain}.${service}${target}`;
  } catch (e) {
    return `[OFF] 调用失败:${e.response?.data?.message || e.message}`;
  }
}

// ==================== AI 工具定义(对接 smart 插件) ====================
const aiTools = [
  {
    type: 'function',
    function: {
      name: 'ha_list_entities',
      description: '列出 Home Assistant 中的所有智能家居实体(设备/传感器等),可按域(light/switch/climate/sensor 等)或关键词过滤。',
      parameters: {
        type: 'object',
        properties: {
          domain: { type: 'string', description: '按域过滤,如 light(灯)、switch(开关)、climate(空调)、sensor(传感器)、cover(窗帘)、lock(锁),留空列出全部' },
          search: { type: 'string', description: '按名称/ID 模糊搜索,留空不搜索' },
        },
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'ha_get_state',
      description: '查询 Home Assistant 中指定实体的当前状态和详细属性。',
      parameters: {
        type: 'object',
        properties: {
          entity_id: { type: 'string', description: '实体 ID,如 light.ketint_deng、sensor.wen_du' },
        },
        required: ['entity_id'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'ha_turn_on',
      description: '打开/开启 Home Assistant 中的指定设备(灯、开关、风扇等)。实体 ID 可通过 ha_list_entities 查询。',
      parameters: {
        type: 'object',
        properties: {
          entity_id: { type: 'string', description: '要开启的设备实体 ID' },
          brightness: { type: 'number', description: '亮度 0-255(仅灯具支持),可选' },
          temperature: { type: 'number', description: '目标温度(仅温控支持),可选' },
        },
        required: ['entity_id'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'ha_turn_off',
      description: '关闭 Home Assistant 中的指定设备(灯、开关、风扇等)。',
      parameters: {
        type: 'object',
        properties: {
          entity_id: { type: 'string', description: '要关闭的设备实体 ID' },
        },
        required: ['entity_id'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'ha_call_service',
      description: '调用 Home Assistant 的任意服务(如设置空调模式、调节颜色等)。domain 和 service 由 ha_list_entities 结果确定。',
      parameters: {
        type: 'object',
        properties: {
          domain: { type: 'string', description: '服务域,如 light、climate、cover、media_player' },
          service: { type: 'string', description: '服务名,如 turn_on、turn_off、set_temperature、set_hvac_mode' },
          entity_id: { type: 'string', description: '目标实体 ID' },
          data: { type: 'object', description: '额外参数,如 {"brightness":128,"rgb_color":[255,0,0]}' },
        },
        required: ['domain', 'service'],
      },
    },
  },
];

// ==================== AI 工具处理入口 ====================
async function handleAiTool(name, args, ctx) {
  const botId = ctx.bot?.id || ctx.bot;
  if (typeof botId !== 'number') return '错误:无法获取机器人 ID';

  const cfg = await loadConfig(botId);
  const { valid, missing } = validateConfig(cfg);
  if (!valid) return `Home Assistant 未配置完成,缺少:${missing.join('、')}。请在后台设置中填写。`;

  try {
    switch (name) {
      case 'ha_list_entities': {
        let entities = await getEntities(cfg, botId);
        if (args.domain) entities = entities.filter(e => getDomain(e.entity_id) === args.domain);
        if (args.search) {
          const s = args.search.toLowerCase();
          entities = entities.filter(e => e.entity_id.includes(s) || (e.attributes?.friendly_name || '').toLowerCase().includes(s));
        }
        if (entities.length === 0) return '未找到任何实体。';
        return entities.slice(0, 30).map(e => ({
          entity_id: e.entity_id,
          name: e.attributes?.friendly_name || e.entity_id,
          state: e.state,
          domain: getDomain(e.entity_id),
        }));
      }

      case 'ha_get_state': {
        let entities = await getEntities(cfg, botId);
        const match = findEntity(entities, args.entity_id);
        if (!match) return `未找到实体「${args.entity_id}」`;
        return {
          entity_id: match.entity_id,
          name: match.attributes?.friendly_name || match.entity_id,
          state: match.state,
          domain: getDomain(match.entity_id),
          attributes: match.attributes,
          last_changed: match.last_changed,
          last_updated: match.last_updated,
        };
      }

      case 'ha_turn_on': {
        const ha = createClient(cfg);
        let entities = await getEntities(cfg, botId);
        const match = findEntity(entities, args.entity_id);
        if (!match) return `未找到实体「${args.entity_id}」`;
        const domain = getDomain(match.entity_id);
        const data = { entity_id: match.entity_id };
        if (args.brightness !== undefined) data.brightness = args.brightness;
        if (args.temperature !== undefined) data.temperature = args.temperature;
        await ha.post(`/api/services/${domain}/turn_on`, data);
        entityCache.ts = 0;
        return `已打开 ${match.attributes?.friendly_name || match.entity_id}`;
      }

      case 'ha_turn_off': {
        const ha = createClient(cfg);
        let entities = await getEntities(cfg, botId);
        const match = findEntity(entities, args.entity_id);
        if (!match) return `未找到实体「${args.entity_id}」`;
        const domain = getDomain(match.entity_id);
        await ha.post(`/api/services/${domain}/turn_off`, { entity_id: match.entity_id });
        entityCache.ts = 0;
        return `已关闭 ${match.attributes?.friendly_name || match.entity_id}`;
      }

      case 'ha_call_service': {
        const ha = createClient(cfg);
        const data = { ...(args.data || {}) };
        if (args.entity_id) data.entity_id = args.entity_id;
        await ha.post(`/api/services/${args.domain}/${args.service}`, data);
        entityCache.ts = 0;
        return `已执行 ${args.domain}.${args.service}${args.entity_id ? ' → ' + args.entity_id : ''}`;
      }

      default:
        return `未知 HA 工具:${name}`;
    }
  } catch (e) {
    return `HA 操作失败:${e.response?.data?.message || e.message}`;
  }
}

// ==================== 指令前缀 ====================
const PREFIX_RE = /^(设备|状态|传感器|温度|执行|遥控器|ha|hass|homeassistant|智能家居)\s*/i;

// ==================== 命令匹配(无前缀直接触发) ====================
/** 「打开/开/开启 XXX」或「XXX 打开/开」(排除太短的,避免误触) */
const TURN_ON_RE  = /^(?:打开|开|开启)\s*(.+)$/i;
/** 「关闭/关/关掉 XXX」(排除太短的) */
const TURN_OFF_RE = /^(?:关闭|关|关掉)\s*(.+)$/i;
/** 「开灯」/「关灯」单字词 */
const LIGHT_ON_RE  = /^开灯$/;
const LIGHT_OFF_RE = /^关灯$/;
/** 「灯 全开」「灯全开」「全开灯」等 */
const LIGHT_ALL_ON_RE  = /^(?:灯|灯具)?\s*全[开啟]/;
const LIGHT_ALL_OFF_RE = /^(?:灯|灯具)?\s*全(?:关|闭)/;

// ==================== onMessage ====================
async function onMessage(msg, ctx) {
  const text = (msg.content || '').trim();
  if (!text) return false;

  // ---- 第一步:匹配前缀指令 ----
  const prefixMatch = text.match(PREFIX_RE);
  let matched = false;
  let cmd = '';
  let prefix = '';

  if (prefixMatch) {
    cmd = text.slice(prefixMatch[0].length).trim();
    prefix = prefixMatch[1].toLowerCase();
    matched = true;
  }

  // ---- 第二步:匹配无前缀直接指令 ----
  const turnOnMatch = text.match(TURN_ON_RE);
  const turnOffMatch = text.match(TURN_OFF_RE);
  const isLightOn  = LIGHT_ON_RE.test(text);
  const isLightOff = LIGHT_OFF_RE.test(text);
  const isAllOn  = LIGHT_ALL_ON_RE.test(text);
  const isAllOff = LIGHT_ALL_OFF_RE.test(text);

  const isDirectCmd = turnOnMatch || turnOffMatch || isLightOn || isLightOff || isAllOn || isAllOff;

  if (!matched && !isDirectCmd) return false;

  // 确定 botId
  const botId = typeof ctx.bot === 'object' ? ctx.bot.id : ctx.bot;
  if (!botId) {
    await ctx.sendText('[OFF] 无法识别当前机器人。');
    return true;
  }

  // 加载配置
  const cfg = await loadConfig(botId);
  const { valid, missing } = validateConfig(cfg);
  if (!valid) {
    await ctx.sendText(`[OFF] Home Assistant 未配置完成\n缺少:${missing.join('、')}\n\n请在后台 → 插件设置 → Home Assistant 中填写 HA 地址和访问令牌。`);
    return true;
  }

  try {
    let result = '';

    // ========== 前缀指令 ==========
    if (matched) {
      // ---- 设备 / 列表 ----
      if (prefix === '设备' || prefix === 'ha' || prefix === 'hass' || prefix === 'homeassistant' || prefix === '智能家居') {
        if (!cmd || cmd === '列表') {
          result = await handleList(cfg, botId, null);
        } else if (cmd.startsWith('执行 ')) {
          const parts = cmd.replace(/^执行\s+/, '').match(/^(\S+)\s+(\S+)(?:\s+(\S+))?(?:\s*(.+))?$/);
          if (!parts) {
            result = '[OFF] 格式:设备 执行 <domain> <service> [entity] [参数]\n示例:设备 执行 climate set_temperature 客厅空调 temperature:26';
          } else {
            result = await handleCallService(cfg, botId, parts[1], parts[2], parts[3] || null, parts[4] || null);
          }
        } else {
          result = await handleList(cfg, botId, cmd);
        }
      }
      // ---- 状态 <entity_id> ----
      else if (prefix === '状态') {
        if (!cmd) { result = '[OFF] 格式:状态 <实体ID>\n示例:状态 light.ketint_deng'; }
        else result = await handleList(cfg, botId, cmd);
      }
      // ---- 传感器 ----
      else if (prefix === '传感器') {
        result = await handleSensor(cfg, botId, cmd || null);
      }
      // ---- 温度 ----
      else if (prefix === '温度') {
        result = await handleTemperature(cfg, botId);
      }
      // ---- 遥控器 ----
      else if (prefix === '遥控器') {
        result = '遥控器功能开发中,请直接发送「打开/关闭 <设备名>」控制设备。';
      }
      // ---- 执行 ----
      else if (prefix === '执行') {
        const parts = cmd.match(/^(\S+)\s+(\S+)(?:\s+(\S+))?(?:\s*(.+))?$/);
        if (!parts) {
          result = '[OFF] 格式:执行 <domain> <service> [entity] [参数]\n示例:执行 climate set_temperature 客厅空调 temperature:26';
        } else {
          result = await handleCallService(cfg, botId, parts[1], parts[2], parts[3] || null, parts[4] || null);
        }
      }
    }

    // ========== 无前缀直接指令 ==========
    if (!result && isDirectCmd) {
      if (isLightOn) {
        result = await handleTurnOn(cfg, botId, null);
      } else if (isLightOff) {
        result = await handleTurnOff(cfg, botId, null);
      } else if (isAllOn) {
        result = await handleTurnOn(cfg, botId, null);
      } else if (isAllOff) {
        result = await handleTurnOff(cfg, botId, null);
      } else if (turnOnMatch) {
        const name = (turnOnMatch[1] || turnOnMatch[2] || '').trim();
        if (name.length < 2) {
          result = '[OFF] 设备名太短,请写明要控制的设备\n示例:打开 客厅灯';
        } else {
          result = await handleTurnOn(cfg, botId, name);
        }
      } else if (turnOffMatch) {
        const name = (turnOffMatch[1] || turnOffMatch[2] || '').trim();
        if (name.length < 2) {
          result = '[OFF] 设备名太短,请写明要控制的设备\n示例:关闭 客厅灯';
        } else {
          result = await handleTurnOff(cfg, botId, name);
        }
      }
    }

    if (result) {
      await ctx.sendText(result);
      return true;
    }
    return false;
  } catch (e) {
    // 连接错误友好提示
    if (e.code === 'ECONNREFUSED' || e.code === 'ENOTFOUND' || e.code === 'ETIMEDOUT') {
      await ctx.sendText(`[OFF] 无法连接到 Home Assistant(${cfg.ha_url})\n请检查 HA 服务是否运行正常。`);
      return true;
    }
    if (e.response?.status === 401) {
      await ctx.sendText('[OFF] HA 认证失败,请检查访问令牌是否正确。');
      return true;
    }
    await ctx.sendText(`[OFF] Home Assistant 错误:${e.message}`);
    return true;
  }
}

// ==================== 导出 ====================
module.exports = {
  meta: {
    id: 'home-assistant',
    name: 'Home Assistant 智能家居',
    version: '1.0.0',
    author: '奶狗',
    category: '工具',
    description: '对接 Home Assistant 智能家居平台,通过微信控制灯光、空调、窗帘等设备,查询传感器数据。支持文本指令和 AI 自然语言对话。',
    entry: 'home-assistant/index.js',
    commandPrefix: ['设备', '状态', '传感器', '温度', '执行', '遥控器', '打开', '关闭', '开灯', '关灯', '开启'],
    configurable: true,
    settingsSchema: [
      {
        key: 'ha_url',
        label: 'Home Assistant 地址',
        type: 'text',
        placeholder: 'http://192.168.1.100:8123',
        help: 'Home Assistant 的访问地址(局域网 IP:端口 或域名),不要带末尾斜杠',
      },
      {
        key: 'ha_token',
        label: '长期访问令牌',
        type: 'password',
        placeholder: 'eyJhbGciOi...',
        help: '在 HA 个人资料 → 长期访问令牌 中创建。请保管好,此令牌拥有完整权限。',
      },
    ],
    usage: `【Home Assistant 智能家居控制】

基础指令(需带前缀):
  设备               → 列出所有设备(按类型分组)
  设备 <名称>        → 搜索特定设备
  状态 <实体ID>      → 查看设备详细状态
  传感器             → 查看所有传感器数据
  温度               → 查看温度传感器
  
[ON]  开关控制(直接说):
  开灯 / 关灯        → 开关所有灯具
  打开 <设备名>      → 打开指定设备
  关闭 <设备名>      → 关闭指定设备

[Error]  高级:
  执行 <domain> <service> [entity] [参数]
  示例:执行 climate set_temperature 客厅空调 temperature:26

智能助手(自然语言,无需加前缀):
  「把客厅灯关了」「卧室空调开到 26 度」
  「现在家里温度多少」「有没有窗没关」`,
  },

  aiTools,
  handleAiTool,
  onMessage,
};