Support user-defined ESPHome API services - #406
Conversation
Co-authored-by: DutchmanNL <7318445+DutchmanNL@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds support for ESPHome user-defined API services, addressing issue #117 where devices with custom api: services: YAML configurations were previously inaccessible without MQTT. The implementation leverages existing protobuf message types (ListEntitiesServicesResponse, ExecuteServiceRequest, ExecuteServiceArgument) from the @2colors/esphome-native-api library that weren't exposed as high-level methods, wiring them directly through low-level connection methods.
Changes:
- Adds service discovery by listening for
ListEntitiesServicesResponsemessages during device initialization - Creates a
UserDefinedServiceschannel hierarchy in ioBroker with writable argument states and a run button for each announced service - Implements service execution by constructing protobuf requests with typed arguments and sending via
sendCommandMessage()
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
| main.js | Adds pb import, service discovery listener, handleUserDefinedService() for state tree creation, executeUserDefinedService() for execution, parseServiceArrayArg() helper, and onStateChange routing for service states |
| README.md | Documents the new feature in the changelog under WORK IN PROGRESS section |
| clientDetails[host].client.connection.on('message.ListEntitiesServicesResponse', async serviceConfig => { | ||
| try { | ||
| this.log.info( | ||
| `${clientDetails[host].deviceFriendlyName} announced user-defined service "${serviceConfig.name}"`, | ||
| ); | ||
| await this.handleUserDefinedService(host, serviceConfig); | ||
| } catch (e) { | ||
| this.errorHandler(`[handleUserDefinedService]`, e); | ||
| } | ||
| }); |
There was a problem hiding this comment.
The message listener for 'message.ListEntitiesServicesResponse' is registered inside the device connection setup, but there's no corresponding cleanup when the connection is closed or during adapter unload. This could lead to memory leaks if devices reconnect multiple times, as listeners will accumulate. Consider removing the listener in the 'disconnected' event handler or storing a reference to remove it during cleanup.
There was a problem hiding this comment.
Fixed in commit 0bcd378. The listener callback is now stored as onServiceAnnouncement and removed with .off() at the start of the disconnected handler, preventing listener accumulation across reconnects.
| } else if ( | ||
| clientDetails[deviceIP][device[4]] && | ||
| clientDetails[deviceIP][device[4]].type === 'UserDefinedService' | ||
| ) { | ||
| if (device[5] === 'run') { | ||
| // Execute the service and reset the button | ||
| await this.executeUserDefinedService(deviceIP, device[2], device[4]); | ||
| await this.setStateAsync(id, { val: false, ack: true }); | ||
| } else { | ||
| // Argument value updated - just acknowledge the state | ||
| await this.setStateAsync(id, { val: state.val, ack: true }); | ||
| } |
There was a problem hiding this comment.
In onStateChange, there's no defensive check whether the device array has enough elements before accessing device[4] and device[5]. If a user-defined service state has an unexpected path structure, this could throw an error. Consider adding a length check before accessing these array indices, similar to the existing checks for device[3].
There was a problem hiding this comment.
Fixed in commit 0bcd378. The UserDefinedService branch now checks device.length >= 6 before accessing device[4]/device[5].
| // Create parent channel for all user-defined services | ||
| const servicesChannel = `${deviceName}.UserDefinedServices`; | ||
| await this.extendObjectAsync(servicesChannel, { | ||
| type: 'channel', | ||
| common: { name: 'User-Defined Services' }, | ||
| native: {}, | ||
| }); | ||
| if (!clientDetails[host].adapterObjects.channels.includes(`${this.namespace}.${servicesChannel}`)) { | ||
| clientDetails[host].adapterObjects.channels.push(`${this.namespace}.${servicesChannel}`); | ||
| } | ||
|
|
||
| // Create channel for this specific service | ||
| const serviceChannel = `${deviceName}.UserDefinedServices.${serviceKey}`; | ||
| await this.extendObjectAsync(serviceChannel, { | ||
| type: 'channel', | ||
| common: { name: serviceName }, | ||
| native: { key: serviceKey, name: serviceName }, | ||
| }); | ||
| if (!clientDetails[host].adapterObjects.channels.includes(`${this.namespace}.${serviceChannel}`)) { | ||
| clientDetails[host].adapterObjects.channels.push(`${this.namespace}.${serviceChannel}`); | ||
| } |
There was a problem hiding this comment.
The UserDefinedServices channel and service-specific channels are created but not tracked in the objectCleanup mechanism. If a service is removed from the ESPHome device configuration, the corresponding ioBroker objects will remain indefinitely. Consider tracking these channels in clientDetails[host].adapterObjects.channels (which is already done) but also ensure they're properly cleaned up when the device reconnects and the service is no longer announced.
There was a problem hiding this comment.
Applied in commit 39aba4a. On the connected event, userDefinedServices is reset to {} and all UserDefinedServices channel paths are removed from adapterObjects.channels. This means objectCleanup (which runs on the initialized event) will delete any ioBroker channels for services that were removed from the device config between reconnects.
| const { Client, Discovery } = require('@2colors/esphome-native-api'); | ||
| const { pb } = require('@2colors/esphome-native-api/lib/utils/messages'); |
There was a problem hiding this comment.
The import path '@2colors/esphome-native-api/lib/utils/messages' directly accesses internal library structure (lib/utils/) which is typically not part of the public API. This could break if the library reorganizes its internal structure in a future version. Consider requesting the library maintainers to export these protobuf constructors from the main package entry point, or document this dependency risk.
| const { Client, Discovery } = require('@2colors/esphome-native-api'); | |
| const { pb } = require('@2colors/esphome-native-api/lib/utils/messages'); | |
| const esphomeNativeApi = require('@2colors/esphome-native-api'); | |
| const { Client, Discovery, pb } = esphomeNativeApi; |
There was a problem hiding this comment.
There was a problem hiding this comment.
@DutchmanNL
Maybe use your fork until the PR is merged? So we avoid fragile code from the start.
| * (DutchmanNL) | ||
| --> | ||
| ### __WORK IN PROGRESS__ | ||
| * (@copilot) **NEW**: Support user-defined ESPHome services - devices with `api: services:` YAML now expose a `UserDefinedServices` channel in ioBroker with writable argument states and a `run` button to trigger each service (fixes #251) |
| clientDetails[host][serviceKey] = { | ||
| type: 'UserDefinedService', | ||
| name: serviceName, | ||
| config: serviceConfig, | ||
| }; |
There was a problem hiding this comment.
The serviceKey is stored as a numeric key directly in clientDetails[host], but this could collide with existing entity IDs which are also numeric. ESPHome entities and services may share the same key space. Consider using a dedicated sub-object like clientDetails[host].userDefinedServices[serviceKey] to avoid potential collisions with entity data structures.
There was a problem hiding this comment.
Fixed in commit 0bcd378. Services are now stored in clientDetails[host].userDefinedServices[serviceKey] (a dedicated sub-object declared in DeviceInfo as userDefinedServices = {}). All reads in executeUserDefinedService and onStateChange use the new path, and the reconnect cleanup resets it with clientDetails[host].userDefinedServices = {}.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: DutchmanNL <7318445+DutchmanNL@users.noreply.github.com>
Co-authored-by: DutchmanNL <7318445+DutchmanNL@users.noreply.github.com>
…p, key isolation, device path guard, README) Co-authored-by: DutchmanNL <7318445+DutchmanNL@users.noreply.github.com>
SimonFischer04
left a comment
There was a problem hiding this comment.
see comment to thread.
Not sure if it actually works but code doesnt look too bad.
| const { Client, Discovery } = require('@2colors/esphome-native-api'); | ||
| const { pb } = require('@2colors/esphome-native-api/lib/utils/messages'); |
There was a problem hiding this comment.
@DutchmanNL
Maybe use your fork until the PR is merged? So we avoid fragile code from the start.
|
PR raised to original lib repository to make function available for easier adoption awaiting implementation which is preferred on top of workaround accessing lib directly |
onStateChangewith argument validationUserDefinedServiceschannels fromadapterObjects.channelsand resetuserDefinedServices = {}on theconnectedeventonServiceAnnouncementreference and call.off()in thedisconnectedhandler to prevent listener accumulation across reconnectsdevice.length >= 6guard (comment 2836041835): added length check inonStateChangebefore accessingdevice[4]/device[5]for UserDefinedService statesclientDetails[host].userDefinedServices[serviceKey](dedicated sub-object) instead ofclientDetails[host][serviceKey]to avoid collisions with entity keys;userDefinedServices = {}added toDeviceInfoclass inhelpers.js#117and#251Original prompt
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.