Skip to content

Support user-defined ESPHome API services - #406

Draft
DutchmanNL with Copilot wants to merge 8 commits into
mainfrom
copilot/support-user-defined-services
Draft

Support user-defined ESPHome API services#406
DutchmanNL with Copilot wants to merge 8 commits into
mainfrom
copilot/support-user-defined-services

Conversation

Copilot AI commented Feb 21, 2026

Copy link
Copy Markdown
Contributor
  • Add service discovery, state tree creation, and execution for user-defined ESPHome services (fixes Support user-defined services #117, fixes Support HA (Sensor) Platform / Dedicated iobroker platform. #251)
  • Handle UserDefinedService in onStateChange with argument validation
  • Update README.md with WORK IN PROGRESS entry (fixes Support user-defined services #117, fixes Support HA (Sensor) Platform / Dedicated iobroker platform. #251)
  • Fix ESLint/prettier violations that were breaking CI
  • Cleanup stale services on reconnect: purge UserDefinedServices channels from adapterObjects.channels and reset userDefinedServices = {} on the connected event
  • Remove listener on disconnect (comment 2836041833): store onServiceAnnouncement reference and call .off() in the disconnected handler to prevent listener accumulation across reconnects
  • device.length >= 6 guard (comment 2836041835): added length check in onStateChange before accessing device[4]/device[5] for UserDefinedService states
  • Key isolation (comment 2836041857): services stored in clientDetails[host].userDefinedServices[serviceKey] (dedicated sub-object) instead of clientDetails[host][serviceKey] to avoid collisions with entity keys; userDefinedServices = {} added to DeviceInfo class in helpers.js
  • README (comment 2836041852): changelog entry now references both #117 and #251
  • All lint checks pass, all tests pass
Original prompt

This section details on the original issue you should resolve

<issue_title>Support user-defined services</issue_title>
<issue_description>As for now, it‘s only possible to send data back to esphome devices via mqtt listener?
My use case is reset and set specific counter values.

This could be done without mqtt with a user-defined api.service:
https://esphome.io/components/api.html#user-defined-services

IoBroker can show a esphome.services as a service object tree where data can be transmitted by json or yaml
</issue_description>

<agent_instructions>Explore 2colors/esphome-native-api lib if this kind of functionality is possible.

I don’t see functions in the lib talking to services?</agent_instructions>

Comments on the Issue (you are @copilot in this section)


🔒 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.

Co-authored-by: DutchmanNL <7318445+DutchmanNL@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for user-defined services Support user-defined ESPHome API services Feb 21, 2026
Copilot AI requested a review from DutchmanNL February 21, 2026 09:47
@DutchmanNL
DutchmanNL marked this pull request as ready for review February 21, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ListEntitiesServicesResponse messages during device initialization
  • Creates a UserDefinedServices channel 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

Comment thread main.js Outdated
Comment on lines +581 to +590
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);
}
});

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread main.js
Comment on lines +2276 to +2287
} 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 });
}

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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].

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 0bcd378. The UserDefinedService branch now checks device.length >= 6 before accessing device[4]/device[5].

Comment thread main.js Outdated
Comment thread main.js
Comment on lines +2340 to +2360
// 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}`);
}

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread main.js Outdated
Comment thread main.js
Comment on lines 13 to +14
const { Client, Discovery } = require('@2colors/esphome-native-api');
const { pb } = require('@2colors/esphome-native-api/lib/utils/messages');

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DutchmanNL
Maybe use your fork until the PR is merged? So we avoid fragile code from the start.

Comment thread main.js
Comment thread README.md Outdated
* (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)

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue number referenced in the README (#251) appears to be incorrect. The PR description states "Fixes #117" but the README changelog says "fixes #251". These should match to ensure proper issue tracking and closure. Update the README to reference #117 instead of #251.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot the PR resolves #117 and #251, update readme accordingly

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in commit 0bcd378. The README changelog entry now reads (fixes #117, fixes #251).

Comment thread main.js
Comment thread main.js Outdated
Comment on lines +2334 to +2338
clientDetails[host][serviceKey] = {
type: 'UserDefinedService',
name: serviceName,
config: serviceConfig,
};

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = {}.

DutchmanNL and others added 5 commits February 21, 2026 17:58
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 SimonFischer04 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment to thread.

Not sure if it actually works but code doesnt look too bad.

Comment thread main.js
Comment on lines 13 to +14
const { Client, Discovery } = require('@2colors/esphome-native-api');
const { pb } = require('@2colors/esphome-native-api/lib/utils/messages');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DutchmanNL
Maybe use your fork until the PR is merged? So we avoid fragile code from the start.

@DutchmanNL

Copy link
Copy Markdown
Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support HA (Sensor) Platform / Dedicated iobroker platform. Support user-defined services

4 participants