From c998adf6830aef5b95b52c5cab6f3280170f6337 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:41:53 +0000 Subject: [PATCH 1/7] Initial plan From 3ec079345762997c26cdd6a0413b988f72a52a21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:46:27 +0000 Subject: [PATCH 2/7] Expose user-defined services as functions Co-authored-by: DutchmanNL <7318445+DutchmanNL@users.noreply.github.com> --- index.d.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++-- index.js | 17 +++++++++++++-- lib/connection.js | 42 ++++++++++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index 0d173a2..6d78c98 100644 --- a/index.d.ts +++ b/index.d.ts @@ -222,6 +222,43 @@ declare module "@2colors/esphome-native-api" { mode: TextMode; }; + export enum ServiceArgType { + Bool = 0, + Int = 1, + Float = 2, + String = 3, + BoolArray = 4, + IntArray = 5, + FloatArray = 6, + StringArray = 7, + } + + export type ListEntitiesServicesArgument = { + name: string; + type: ServiceArgType; + }; + + export type ListEntitiesServicesResponse = { + name: string; + key: number; + argsList: ListEntitiesServicesArgument[]; + }; + + export type ExecuteServiceArgument = + | { type: ServiceArgType.Bool; value: boolean } + | { type: ServiceArgType.Int; value: number } + | { type: ServiceArgType.Float; value: number } + | { type: ServiceArgType.String; value: string } + | { type: ServiceArgType.BoolArray; value: boolean[] } + | { type: ServiceArgType.IntArray; value: number[] } + | { type: ServiceArgType.FloatArray; value: number[] } + | { type: ServiceArgType.StringArray; value: string[] }; + + export type ExecuteServiceCommandData = { + key: number; + args?: ExecuteServiceArgument[]; + }; + type Components = | "BinarySensor" | "Cover" @@ -238,7 +275,8 @@ declare module "@2colors/esphome-native-api" { | "Lock" | "Button" | "MediaPlayer" - | "Text"; + | "Text" + | "Services"; type Entities = | ListEntitiesEntityResponse @@ -255,7 +293,8 @@ declare module "@2colors/esphome-native-api" { | ListEntitiesLockResponse | ListEntitiesButtonResponse | ListEntitiesMediaPlayerResponse - | ListEntitiesTextResponse; + | ListEntitiesTextResponse + | ListEntitiesServicesResponse; export type EntityList = { component: Components; @@ -532,6 +571,7 @@ declare module "@2colors/esphome-native-api" { switchCommandService(data: SwitchCommandData): void; mediaPlayerCommandService(data: MediaPlayerCommandData): void; textCommandService(data: TextCommandData): void; + executeServiceService(data: ExecuteServiceCommandData): void; subscribeBluetoothAdvertisementService(): void; unsubscribeBluetoothAdvertisementService(): void; @@ -764,6 +804,15 @@ declare module "@2colors/esphome-native-api" { listener: (message: ListEntitiesTextResponse) => void ): this; + on( + event: "message.ListEntitiesServicesResponse", + listener: (message: ListEntitiesServicesResponse) => void + ): this; + off( + event: "message.ListEntitiesServicesResponse", + listener: (message: ListEntitiesServicesResponse) => void + ): this; + on(event: string, listener: (...args: any[]) => void): this; off(event: string, listener: (...args: any[]) => void): this; } diff --git a/index.js b/index.js index 220e884..5cae617 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,23 @@ const Connection = require('./lib/connection'); const Client = require('./lib/client'); const Discovery = require('./lib/discovery'); +const { pb } = require('./lib/utils/messages'); + +const ServiceArgType = { + Bool: pb.ServiceArgType.SERVICE_ARG_TYPE_BOOL, + Int: pb.ServiceArgType.SERVICE_ARG_TYPE_INT, + Float: pb.ServiceArgType.SERVICE_ARG_TYPE_FLOAT, + String: pb.ServiceArgType.SERVICE_ARG_TYPE_STRING, + BoolArray: pb.ServiceArgType.SERVICE_ARG_TYPE_BOOL_ARRAY, + IntArray: pb.ServiceArgType.SERVICE_ARG_TYPE_INT_ARRAY, + FloatArray: pb.ServiceArgType.SERVICE_ARG_TYPE_FLOAT_ARRAY, + StringArray: pb.ServiceArgType.SERVICE_ARG_TYPE_STRING_ARRAY, +}; module.exports = { Connection, Client, Discovery, - discovery: Discovery -} \ No newline at end of file + discovery: Discovery, + ServiceArgType +} diff --git a/lib/connection.js b/lib/connection.js index 7a740b9..780be2b 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -257,7 +257,8 @@ class EsphomeNativeApiConnection extends EventEmitter { 'ListEntitiesLockResponse', 'ListEntitiesButtonResponse', 'ListEntitiesMediaPlayerResponse', - 'ListEntitiesTextResponse' + 'ListEntitiesTextResponse', + 'ListEntitiesServicesResponse' ] const entitiesList = []; const onMessage = (type, message) => { @@ -489,6 +490,45 @@ class EsphomeNativeApiConnection extends EventEmitter { textCommandService(data) { Entities.Text.commandService(this, data); } + executeServiceService({ key, args = [] }) { + if (!this.connected) throw new Error(`Not connected`); + if (!this.authorized) throw new Error(`Not authorized`); + const message = new pb.ExecuteServiceRequest(); + message.setKey(key); + for (const { type, value } of args) { + const arg = new pb.ExecuteServiceArgument(); + switch (type) { + case pb.ServiceArgType.SERVICE_ARG_TYPE_BOOL: + arg.setBool(value); + break; + case pb.ServiceArgType.SERVICE_ARG_TYPE_INT: + arg.setInt(value); + break; + case pb.ServiceArgType.SERVICE_ARG_TYPE_FLOAT: + arg.setFloat(value); + break; + case pb.ServiceArgType.SERVICE_ARG_TYPE_STRING: + arg.setString(value); + break; + case pb.ServiceArgType.SERVICE_ARG_TYPE_BOOL_ARRAY: + arg.setBoolArrayList(value); + break; + case pb.ServiceArgType.SERVICE_ARG_TYPE_INT_ARRAY: + arg.setIntArrayList(value); + break; + case pb.ServiceArgType.SERVICE_ARG_TYPE_FLOAT_ARRAY: + arg.setFloatArrayList(value); + break; + case pb.ServiceArgType.SERVICE_ARG_TYPE_STRING_ARRAY: + arg.setStringArrayList(value); + break; + default: + throw new Error(`Unknown service arg type: ${type}`); + } + message.addArgs(arg); + } + this.sendCommandMessage(message); + } } module.exports = EsphomeNativeApiConnection; From 190c34153dc111ef7c6758e626a508c66e5c5f3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:54:40 +0000 Subject: [PATCH 3/7] docs: add user-defined services section to README Co-authored-by: DutchmanNL <7318445+DutchmanNL@users.noreply.github.com> --- README.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/README.md b/README.md index 708bd03..0846d8d 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,51 @@ client.on('logs', ({ message }) => { console.log(message); }); ``` + +### User-defined services +[User-defined services](https://esphome.io/components/api.html#user-defined-services) let an ESPHome device expose custom callable functions. +Use `listEntitiesService()` to discover them and `executeServiceService()` to invoke them. + +```javascript +const { Connection, ServiceArgType } = require('@2colors/esphome-native-api'); + +const connection = new Connection({ + host: '', + port: 6053, +}); + +connection.connect(); + +connection.on('authorized', async () => { + // Discover all entities, including user-defined services + const entities = await connection.listEntitiesService(); + + const services = entities.filter(e => e.component === 'Services'); + console.log('User-defined services:', services); + /* + [ + { + name: 'my_service', + key: 1234567890, + argsList: [ + { name: 'brightness', type: 1 }, // ServiceArgType.Int + { name: 'message', type: 3 } // ServiceArgType.String + ] + } + ] + */ + + // Call a user-defined service by key with typed arguments + connection.executeServiceService({ + key: services[0].key, + args: [ + { type: ServiceArgType.Int, value: 100 }, + { type: ServiceArgType.String, value: 'hello' }, + ], + }); +}); +``` + ## Documantation ### Discovery @@ -283,6 +328,81 @@ Only base functionality - `state` - REQUIRED. string. See `minLength`, `maxLength` attrs in config +### User-defined Services +[User-defined services](https://esphome.io/components/api.html#user-defined-services) are custom callable functions declared in the ESPHome device's YAML configuration. They are discovered automatically when you call `listEntitiesService()` and are invoked with `executeServiceService()`. + +#### ServiceArgType +The `ServiceArgType` export maps argument type names to the numeric values used in the protocol: + +| Name | Value | Description | +|---------------|-------|-----------------------| +| `Bool` | 0 | Single boolean | +| `Int` | 1 | Single integer | +| `Float` | 2 | Single float | +| `String` | 3 | Single string | +| `BoolArray` | 4 | Array of booleans | +| `IntArray` | 5 | Array of integers | +| `FloatArray` | 6 | Array of floats | +| `StringArray` | 7 | Array of strings | + +```javascript +const { ServiceArgType } = require('@2colors/esphome-native-api'); +``` + +#### Discovering services with `listEntitiesService()` +Services are returned alongside regular entities. Filter by `component === 'Services'` to get only service definitions: + +```javascript +const entities = await connection.listEntitiesService(); +const services = entities.filter(e => e.component === 'Services'); +/* +[ + { + name: 'my_service', + key: 1234567890, + argsList: [ + { name: 'brightness', type: 1 }, // ServiceArgType.Int + { name: 'enable', type: 0 } // ServiceArgType.Bool + ] + } +] +*/ +``` + +Each service entry has: +- `name` - string. The service name as declared in ESPHome YAML +- `key` - number. Unique key used to invoke the service +- `argsList` - array of `{ name: string, type: ServiceArgType }` argument descriptors + +#### Executing a service with `executeServiceService(data)` +`connection.executeServiceService({ key, args })` + +- `key` - REQUIRED. number. The service `key` from the discovery response +- `args` - optional. Array of `{ type: ServiceArgType, value }` objects, one per argument in the order declared by `argsList` + +```javascript +const { Connection, ServiceArgType } = require('@2colors/esphome-native-api'); + +connection.executeServiceService({ + key: 1234567890, + args: [ + { type: ServiceArgType.Int, value: 100 }, + { type: ServiceArgType.Bool, value: true }, + { type: ServiceArgType.String, value: 'hello' }, + ], +}); +``` + +#### Listening for service definitions +You can also react to service definitions as they arrive using the connection event: + +```javascript +connection.on('message.ListEntitiesServicesResponse', (service) => { + console.log('Service discovered:', service.name, 'key:', service.key); +}); +``` + + ### Connection ```javascript const { Connection } = require('@2colors/esphome-native-api'); @@ -347,6 +467,9 @@ const connection = new Connection({ - `sirenCommandService(data)` - `switchCommandService(data)` - `mediaplayerCommandService(data)` +- `executeServiceService({ key, args })` - executes a [user-defined service](https://esphome.io/components/api.html#user-defined-services). See [User-defined Services](#user-defined-services) for full details. + - `key` - REQUIRED. number. Service key obtained from `listEntitiesService()` + - `args` - optional. Array of `{ type: ServiceArgType, value }` objects #### Connection events - `message.` - when valid message from esphome device is received. First arg is message. The event is called before `message` event(more genetal analogue) From 84b18eb41304c463969393bc8ebc75aeea259522 Mon Sep 17 00:00:00 2001 From: Rafal Drozda Date: Sat, 21 Feb 2026 11:04:28 +0100 Subject: [PATCH 4/7] Update lib/connection.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/connection.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/connection.js b/lib/connection.js index 780be2b..e05c1d9 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -503,6 +503,7 @@ class EsphomeNativeApiConnection extends EventEmitter { break; case pb.ServiceArgType.SERVICE_ARG_TYPE_INT: arg.setInt(value); + arg.setLegacyInt(value); break; case pb.ServiceArgType.SERVICE_ARG_TYPE_FLOAT: arg.setFloat(value); From 9485a1824065ebb7ee0883fa52c9efcd0004e8a8 Mon Sep 17 00:00:00 2001 From: Rafal Drozda Date: Sat, 21 Feb 2026 11:05:06 +0100 Subject: [PATCH 5/7] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0846d8d..18081e7 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,9 @@ connection.on('authorized', async () => { // Discover all entities, including user-defined services const entities = await connection.listEntitiesService(); - const services = entities.filter(e => e.component === 'Services'); + const services = entities + .filter(e => e.component === 'Services') + .map(e => e.entity); console.log('User-defined services:', services); /* [ From f558e228834fe8664a0319db7239f5d4c8181165 Mon Sep 17 00:00:00 2001 From: Rafal Drozda Date: Sat, 21 Feb 2026 11:05:32 +0100 Subject: [PATCH 6/7] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 18081e7..e05f15f 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ client.on('logs', ({ message }) => { }); ``` -### User-defined services +### User-defined services (Synopsis) [User-defined services](https://esphome.io/components/api.html#user-defined-services) let an ESPHome device expose custom callable functions. Use `listEntitiesService()` to discover them and `executeServiceService()` to invoke them. From 77ba813f5cbc184d9bd226190d848c670fc0eb5f Mon Sep 17 00:00:00 2001 From: Rafal Drozda Date: Sat, 21 Feb 2026 11:05:47 +0100 Subject: [PATCH 7/7] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e05f15f..dfc4931 100644 --- a/README.md +++ b/README.md @@ -356,7 +356,9 @@ Services are returned alongside regular entities. Filter by `component === 'Serv ```javascript const entities = await connection.listEntitiesService(); -const services = entities.filter(e => e.component === 'Services'); +const services = entities + .filter(e => e.component === 'Services') + .map(e => e.entity); /* [ {