diff --git a/.gitignore b/.gitignore index a2e04e4..b263584 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules -.env \ No newline at end of file +.env +!env*example \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a5aa07b --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +//registry.npmjs.org/:_authToken=${NPM_TOKEN} diff --git a/package.json b/package.json index cf49176..bfc0725 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,28 @@ { - "name": "ha-api", + "name": "@kevisual/ha-api", "version": "0.0.1", "description": "", - "main": "index.js", + "main": "src/index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, + "files": [ + "src" + ], "keywords": [], + "publishConfig": { + "access": "public" + }, "author": "abearxiong (https://www.xiongxiao.me)", "license": "MIT", "packageManager": "pnpm@10.24.0", "type": "module", "devDependencies": { "@types/bun": "^1.3.3", + "dotenv": "^17.2.3", "@types/node": "^24.10.1" }, "dependencies": { - "dotenv": "^17.2.3" + "fuse.js": "^7.1.0" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c15371..5d098af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: dotenv: specifier: ^17.2.3 version: 17.2.3 + fuse.js: + specifier: ^7.1.0 + version: 7.1.0 devDependencies: '@types/bun': specifier: ^1.3.3 @@ -34,6 +37,10 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + fuse.js@7.1.0: + resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} + engines: {node: '>=10'} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -53,4 +60,6 @@ snapshots: dotenv@17.2.3: {} + fuse.js@7.1.0: {} + undici-types@7.16.0: {} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..bb62f02 --- /dev/null +++ b/readme.md @@ -0,0 +1,27 @@ +# 灯光管理 + +该模块提供对Home Assistant中灯光设备的管理功能,包括获取灯光列表、搜索特定灯光设备以及控制灯光的开关状态。 + +```ts +import { haLight } from '@kevisual/ha-api'; + +const light = new haLight({ + host: 'http://your-home-assistant:8123', + token: 'your-long-lived-access-token', +}); +// 获取所有灯光设备 +const lights = await light.getLights(); +console.log(lights); + +const searchResult = await light.searchLight('living room'); +console.log(searchResult); + +if(searchResult.hasMore) { + console.log('多个灯光设备匹配该关键词,请进一步筛选。'); +} else if(searchResult.id) { + console.log(`唯一匹配的灯光设备ID为: ${searchResult.id}`); + light.toggleLight({ entity_id: searchResult.id, service: 'turn_on' }); +} else { + console.log('没有找到匹配的灯光设备。'); +} +``` diff --git a/src/auto.ts b/src/auto.ts new file mode 100644 index 0000000..b821a94 --- /dev/null +++ b/src/auto.ts @@ -0,0 +1,13 @@ +import Fuse from "fuse.js"; +import { HACore, HACoreOptions } from "./core"; + +export class AutoHA extends HACore { + constructor(options: HACoreOptions) { + super(options); + } + getAutos() { + const auto = this.getEntities((entity: any) => entity.entity_id.startsWith('automation.')); + return auto; + } + +} \ No newline at end of file diff --git a/src/core.ts b/src/core.ts index 9a5ea2f..3856a8a 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,11 +1,10 @@ -const homeassistantURL = 'http://home.mz.zxj.im:8123'; - -const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmMzk3ZDUwMWU3ODE0MTA4Yjk4ZjYwNDFjMzI3NzVkZSIsImlhdCI6MTc2NDg2NDUyMywiZXhwIjoyMDgwMjI0NTIzfQ.S2zO3DNzeVYgd1c_N9IkRc13zmtj2HGVq-n6IUmttRQ' - -type HACoreOptions = { +export type HACoreOptions = { token: string; homeassistantURL?: string; } +/** + * https://developers.home-assistant.io/docs/api/rest/ + */ export class HACore { token: string; homeassistantURL?: string; @@ -47,15 +46,56 @@ export class HACore { return response.json(); }); } + async getEntities(filter?: (entity: any) => boolean): Promise { + const entities = await this.get({ url: '/api/states' }); + if (filter) { + return entities.filter(filter); + } + return entities; + } + async getState(entity_id?: string): Promise { + return this.get({ url: `/api/states/${entity_id}` }); + } + async getEntityTypes(): Promise { + const entities = await this.getEntities(); + const types = new Set(); + entities.forEach((entity: any) => { + const [type] = entity.entity_id.split('.'); + types.add(type); + }); + return Array.from(types); + } } -export const getEntities = async (): Promise => { - const hacore = new HACore({ token, homeassistantURL }); - return hacore.get({ url: '/api/states' }); -} -export const getLights = async (): Promise => { - const hacore = new HACore({ token, homeassistantURL }); - const entities = await hacore.get({ url: '/api/states' }); - return entities.filter((entity: any) => entity.entity_id.startsWith('light.')); -} \ No newline at end of file + +export const entitiesTypes = [ + { "type": "conversation", "desc": "处理自然语言对话指令,如语音助手" }, + { "type": "event", "desc": "系统或设备触发的事件,用于自动化监听" }, + { "type": "sensor", "desc": "测量并报告数值型数据,如温度、湿度、电量" }, + { "type": "zone", "desc": "定义地理区域,用于判断人或设备是否在范围内" }, + { "type": "person", "desc": "代表家庭成员,关联位置与状态(在家/离家)" }, + { "type": "scene", "desc": "预设一组设备状态,一键切换场景" }, + { "type": "sun", "desc": "提供日出、日落等天文信息,用于时间触发" }, + { "type": "script", "desc": "可调用的自动化脚本,执行一系列操作步骤" }, + { "type": "binary_sensor", "desc": "报告二进制状态(开/关),如门磁、运动传感器" }, + { "type": "device_tracker", "desc": "追踪设备(如手机)的位置信息" }, + { "type": "media_player", "desc": "控制媒体播放设备,如音响、电视" }, + { "type": "todo", "desc": "管理待办事项列表,如购物清单或任务" }, + { "type": "update", "desc": "监控系统或设备的软件更新状态" }, + { "type": "tts", "desc": "文本转语音服务,用于语音播报" }, + { "type": "ai_task", "desc": "自定义AI任务(非官方标准,可能来自第三方AI集成)" }, + { "type": "automation", "desc": "定义自动化规则,触发条件与动作的组合" }, + { "type": "weather", "desc": "提供天气预报信息,如温度、降水、风速" }, + { "type": "remote", "desc": "发送红外或射频遥控信号,控制电视、空调等" }, + { "type": "switch", "desc": "控制开关类设备,如智能插座、继电器" }, + { "type": "button", "desc": "触发一次性操作,按下即执行(无状态)" }, + { "type": "text", "desc": "可编辑的文本字段,用于输入或显示文本信息" }, + { "type": "select", "desc": "从预定义选项中选择一个值,如模式、音源" }, + { "type": "climate", "desc": "控制温控设备,如空调、恒温器" }, + { "type": "cover", "desc": "控制遮蔽设备,如窗帘、百叶窗、车库门" }, + { "type": "fan", "desc": "控制风扇设备,支持风速、摇头等" }, + { "type": "light", "desc": "控制照明设备,支持开关、亮度、颜色、色温" }, + { "type": "notify", "desc": "发送通知,如推送、邮件、语音播报" }, + { "type": "number", "desc": "可调整的数值控件,有上下限,如温度设定、定时器" } +] \ No newline at end of file diff --git a/src/event.ts b/src/event.ts new file mode 100644 index 0000000..fe3b1f9 --- /dev/null +++ b/src/event.ts @@ -0,0 +1,9 @@ +import Fuse from "fuse.js"; +import { HACore, HACoreOptions } from "./core"; + +export class EventHA extends HACore { + getEvents() { + const events = this.getEntities((entity: any) => entity.entity_id.startsWith('event.')); + return events; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cf94d9c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +export * from './light.ts' +export * from './core.ts' + +export * from './auto.ts' + +export * from './event.ts' + +export * from './script.ts' \ No newline at end of file diff --git a/src/light.ts b/src/light.ts new file mode 100644 index 0000000..5dbebdb --- /dev/null +++ b/src/light.ts @@ -0,0 +1,85 @@ +import Fuse from "fuse.js"; +import { HACore, HACoreOptions } from "./core"; + +export class LightHA extends HACore { + constructor(options: HACoreOptions) { + super(options); + } + async getLights(): Promise { + return this.getEntities((entity: any) => entity.entity_id.startsWith('light.')); + } + + /** + * 切换灯的状态,若未指定 service,则根据当前状态切换 + * @param opts + * @returns + */ + async toggleLight(opts: { entity_id?: string, name?: string, service?: string, data?: Record }): Promise { + // e.g., service: 'turn_on', 'turn_off', 'toggle' + let { entity_id, service = '', data } = opts; + if (!entity_id && opts.name) { + const entities = await this.getLights(); + const target = entities.find((entity: any) => entity.attributes?.friendly_name.includes(opts.name || '')); + if (target) { + entity_id = target.entity_id; + } else { + throw new Error(`Light with name including "${opts.name}" not found.`); + } + } else if (!entity_id) { + throw new Error('实体 ID 或 名称 必须提供其一。'); + } + if (!service) { + const state = await this.getState(entity_id!); + service = state.state === 'on' ? 'turn_off' : 'turn_on'; + } + return this.post({ + url: `/api/services/light/${service}`, + body: { + entity_id, + ...data, + }, + }); + } + async getInfoList(): Promise { + const lights = await this.getLights(); + const infoList = lights.map((light: any) => { + return { + entity_id: light.entity_id, + name: light.attributes?.friendly_name || '', + state: light.state, + }; + }); + return infoList; + } + async searchLight(keyword: string): Promise<{ result: LightItem[], lights: LightItem[], id?: string, hasMore?: boolean }> { + const devices = await this.getInfoList(); + const fuse = new Fuse(devices, { + keys: ['name'], // 搜索字段 + threshold: 0.4, // 匹配宽松度:0~1,越小越严格 + includeScore: true, + minMatchCharLength: 1, // 允许单字匹配 + }); + const result = fuse.search(keyword); + const resultItems = result.map(r => r.item); + let id = '' + if (resultItems.length === 1) { + id = resultItems[0].entity_id; + } + const hasMore = resultItems.length > 1; + return { result: resultItems, lights: devices, id, hasMore }; + } + async closeAllLights(): Promise { + const lights = await this.getLights(); + for (const light of lights) { + if (light.state === 'on') { + this.toggleLight({ entity_id: light.entity_id, service: 'turn_off' }); + } + } + } +} + +type LightItem = { + entity_id: string; + name: string; + state: string; +} \ No newline at end of file diff --git a/src/script.ts b/src/script.ts new file mode 100644 index 0000000..f66debb --- /dev/null +++ b/src/script.ts @@ -0,0 +1,19 @@ +import { HACore, HACoreOptions } from "./core"; + +export class ScriptHA extends HACore { + static serviceName = 'script'; + getScripts() { + const scripts = this.getEntities((entity: any) => entity.entity_id.startsWith('script.')); + return scripts; + } + runScript(entity_id: string, data?: Record) { + const serviceName = ScriptHA.serviceName; + return this.post({ + url: `/api/services/${serviceName}/turn_on`, + body: { + entity_id, + ...data, + }, + }); + } +} \ No newline at end of file diff --git a/test/auto.ts b/test/auto.ts new file mode 100644 index 0000000..ffdd9b2 --- /dev/null +++ b/test/auto.ts @@ -0,0 +1,5 @@ +import { auto, showMore } from './common.ts' + +const autos = await auto.getAutos(); + +console.log(showMore(autos)); \ No newline at end of file diff --git a/test/common.ts b/test/common.ts index 678ef20..04db59f 100644 --- a/test/common.ts +++ b/test/common.ts @@ -1,113 +1,127 @@ -import { HACore } from "../src/core"; +import { LightHA, AutoHA, EventHA, ScriptHA } from "../src/index.ts"; import util from 'node:util'; import dotenv from 'dotenv'; dotenv.config(); +export const showMore = (obj: any) => { + return util.inspect(obj, { depth: null, colors: true }); +} +export const hacore = new LightHA({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL }); -const hacore = new HACore({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL }); -const enti = await hacore.get({ url: '/api/states' }); -// console.log(util.inspect(enti, { depth: null })); +export const auto = new AutoHA({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL }); +export const event = new EventHA({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL }); +export const script = new ScriptHA({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL }); + +// const enti = await hacore.getLights(); +// console.log(showMore(enti), enti.length); // const lightEntities = enti.filter((entity: any) => { // const hasLight = entity.entity_id.startsWith('light.'); // const name = entity.attributes?.friendly_name || ''; -// return hasLight && name.includes('次卧'); +// // return hasLight && name.includes('次卧'); +// return hasLight; // }); -// console.log(util.inspect(lightEntities, { depth: null })); -const lights = [ - { - entity_id: 'light.lemesh_wy0c14_f18d_light', - state: 'off', - attributes: { - min_color_temp_kelvin: 2700, - max_color_temp_kelvin: 6500, - min_mireds: 153, - max_mireds: 370, - effect_list: [ - 'WY', 'Day', - 'Night', 'Warmth', - 'Tv', 'Reading', - 'Computer', 'Hospitality', - 'Entertainment', 'Wakeup', - 'Dusk', 'Sleep' - ], - supported_color_modes: ['color_temp'], - effect: null, - color_mode: null, - brightness: null, - color_temp_kelvin: null, - color_temp: null, - hs_color: null, - rgb_color: null, - xy_color: null, - 'light.mode': 0, - 'light.on': false, - 'light.color_temperature': 6500, - 'light.brightness': 1, - friendly_name: '次卧灯 灯光', - supported_features: 4 - }, - last_changed: '2025-12-02T16:35:00.229216+00:00', - last_reported: '2025-12-02T16:36:00.314622+00:00', - last_updated: '2025-12-02T16:35:00.229216+00:00', - context: { - id: '01KBFYNN05ZYWZHTGC1W7N66Z5', - parent_id: null, - user_id: null - } - }, - { - entity_id: 'light.lemesh_cn_1099991426_wy0c14_s_2_light', - state: 'off', - attributes: { - min_color_temp_kelvin: 2700, - max_color_temp_kelvin: 6500, - min_mireds: 153, - max_mireds: 370, - effect_list: [ - '色温模式', - '日光', - '月光(夜间)模式', - '温馨', - '电视模式(影院模式)', - '阅读模式', - '电脑模式', - '会客模式', - '娱乐模式', - '清晨唤醒', - '黄昏明亮', - '夜晚助眠' - ], - supported_color_modes: ['color_temp'], - effect: null, - color_mode: null, - brightness: null, - color_temp_kelvin: null, - color_temp: null, - hs_color: null, - rgb_color: null, - xy_color: null, - friendly_name: '次卧灯 灯光', - supported_features: 4 - }, - last_changed: '2025-12-04T13:28:05.284635+00:00', - last_reported: '2025-12-04T13:28:09.331178+00:00', - last_updated: '2025-12-04T13:28:05.284635+00:00', - context: { - id: '01KBMRRTX4ZE10MGHTVVNC1K7Y', - parent_id: null, - user_id: null - } - } -] +// console.log(showMore(lightEntities)); -const res = await hacore.post({ - url: '/api/services/light/turn_off', - body: { - entity_id: 'light.lemesh_cn_1099991426_wy0c14_s_2_light' // 或第二个次卧灯的ID - } -}); -console.log(util.inspect(res, { depth: null })); \ No newline at end of file +// const infoList = await hacore.getInfoList(); +// console.log(showMore(infoList)); + +// const lights = [ +// { +// entity_id: 'light.lemesh_wy0c14_f18d_light', +// state: 'off', +// attributes: { +// min_color_temp_kelvin: 2700, +// max_color_temp_kelvin: 6500, +// min_mireds: 153, +// max_mireds: 370, +// effect_list: [ +// 'WY', 'Day', +// 'Night', 'Warmth', +// 'Tv', 'Reading', +// 'Computer', 'Hospitality', +// 'Entertainment', 'Wakeup', +// 'Dusk', 'Sleep' +// ], +// supported_color_modes: ['color_temp'], +// effect: null, +// color_mode: null, +// brightness: null, +// color_temp_kelvin: null, +// color_temp: null, +// hs_color: null, +// rgb_color: null, +// xy_color: null, +// 'light.mode': 0, +// 'light.on': false, +// 'light.color_temperature': 6500, +// 'light.brightness': 1, +// friendly_name: '次卧灯 灯光', +// supported_features: 4 +// }, +// last_changed: '2025-12-02T16:35:00.229216+00:00', +// last_reported: '2025-12-02T16:36:00.314622+00:00', +// last_updated: '2025-12-02T16:35:00.229216+00:00', +// context: { +// id: '01KBFYNN05ZYWZHTGC1W7N66Z5', +// parent_id: null, +// user_id: null +// } +// }, +// { +// entity_id: 'light.lemesh_cn_1099991426_wy0c14_s_2_light', +// state: 'off', +// attributes: { +// min_color_temp_kelvin: 2700, +// max_color_temp_kelvin: 6500, +// min_mireds: 153, +// max_mireds: 370, +// effect_list: [ +// '色温模式', +// '日光', +// '月光(夜间)模式', +// '温馨', +// '电视模式(影院模式)', +// '阅读模式', +// '电脑模式', +// '会客模式', +// '娱乐模式', +// '清晨唤醒', +// '黄昏明亮', +// '夜晚助眠' +// ], +// supported_color_modes: ['color_temp'], +// effect: null, +// color_mode: null, +// brightness: null, +// color_temp_kelvin: null, +// color_temp: null, +// hs_color: null, +// rgb_color: null, +// xy_color: null, +// friendly_name: '次卧灯 灯光', +// supported_features: 4 +// }, +// last_changed: '2025-12-04T13:28:05.284635+00:00', +// last_reported: '2025-12-04T13:28:09.331178+00:00', +// last_updated: '2025-12-04T13:28:05.284635+00:00', +// context: { +// id: '01KBMRRTX4ZE10MGHTVVNC1K7Y', +// parent_id: null, +// user_id: null +// } +// } +// ] + +// const res = await hacore.toggleLight({ +// // name: '次卧灯', +// // service: 'turn_off' +// name: '阳台灯' +// }); +// console.log(util.inspect(res, { depth: null })); + +// const entityType = await hacore.getEntityTypes(); +// console.log(showMore(entityType)); \ No newline at end of file diff --git a/test/light.ts b/test/light.ts new file mode 100644 index 0000000..2f4c063 --- /dev/null +++ b/test/light.ts @@ -0,0 +1,19 @@ +import { hacore, showMore } from "./common.ts"; +import Fuse from 'fuse.js'; + +const devices = await hacore.getInfoList(); +const fuse = new Fuse(devices, { + keys: ['name'], // 搜索字段 + threshold: 0.4, // 匹配宽松度:0~1,越小越严格 + includeScore: true, + minMatchCharLength: 1, // 允许单字匹配 +}); +// const searchKeyword = '次卧灯'; +// const searchKeyword = '阳台 灯'; +// const searchKeyword = '晾衣机的 灯'; +const searchKeyword = ' 阳台 灯'; +const result = fuse.search(searchKeyword); + +// 输出搜索结果 +console.log(`搜索关键词: "${searchKeyword}"`); +console.log(showMore(result.map(r => r.item))); \ No newline at end of file diff --git a/test/script.ts b/test/script.ts new file mode 100644 index 0000000..017764e --- /dev/null +++ b/test/script.ts @@ -0,0 +1,16 @@ +import { script, showMore } from './common.ts'; + +const scripts = await script.getScripts(); + +const values = scripts.map(e => { + return { + entity_id: e.entity_id, + state: e.state, + attributes: e.attributes, + last_changed: e.last_changed, + last_updated: e.last_updated, + }; +}); +console.log(showMore(values)); + +console.log(`实体数量: ${scripts.length}`); \ No newline at end of file