External integrations in Gladys Assistant

That’s so cool these investigations, honestly it’s amazing, it looks like native integrations!

What’s cool is that once you feel ready with these integrations, all you have to do is put your repo in the GitHub topic that the store scans every hour, and boom it will be published on the Gladys instances :grin:

There is automatic validation on the manifest and the Docker image, and that’s all!

You can do it with the “force update” button! :wink:

I’ll check it out!

Absolutely!!

Indeed, the visual integration is really excellent, and for 1 user, extremely simple!

On the other hand, we will have to work on the documentation, because here, however, there is no possibility to display information.

And for more complex systems like Tuya with its local implementation, there are still missing pieces (oh dear!! no criticism here :sweat_smile: this new system has been implemented at a completely crazy speed and is already proving itself :joy: :clap:)

So we (Fable and I) lack knowledge on this point because no, it doesn’t work on a dev image, and if the manifest changes!


From what we can see, it’s based on the version number in the image name?

Logs:

2026-07-19T10:53:49+0200 <warn> externalIntegration.update.js:43 (ExternalIntegration.update) Unable to pull image ghcr.io/terdious/gladys-tuya:1.0.0 Error: (HTTP code 404) unexpected - manifest unknown
    at /home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/docker-modem/lib/modem.js:336:17
    at IncomingMessage.<anonymous> (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/docker-modem/lib/modem.js:363:9)
    at IncomingMessage.emit (node:events:530:35)
    at endReadableNT (node:internal/streams/readable:1698:12)
    at processTicksAndRejections (node:internal/process/task_queues:90:21) {
  reason: undefined,
  statusCode: 404,
  json: null
}
2026-07-19T10:53:49+0200 <debug> errorMiddleware.js:26 (errorMiddleware) BadParameters [Error]: UNABLE_TO_PULL_IMAGE: image may not exist or may not be available for your architecture
    at ExternalIntegration.update (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/lib/external-integration/externalIntegration.update.js:44:11)
    at processTicksAndRejections (node:internal/process/task_queues:105:5)
    at update (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/api/controllers/externalIntegration.controller.js:97:25)
From previous event:
    at /home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/api/middlewares/asyncMiddleware.js:4:18
    at Layer.handle [as handle_request] (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/express/lib/router/layer.js:95:5)
    at next (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/express/lib/router/route.js:144:13)
    at adminMiddleware (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/api/middlewares/adminMiddleware.js:6:5)
    at Layer.handle [as handle_request] (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/express/lib/router/layer.js:95:5)
    at next (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/express/lib/router/route.js:144:13)
    at /home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/api/middlewares/authMiddleware.js:28:7

But maybe due to the fact that we run it for testing outside docker?

I have another question about Tuya doorbells; we’re working with @GBoulvin on this:
While digging into the SDK, we realized that Pulsar itself is not blocking: the container can open the Tuya real-time connection and push events (including doorbell presses) via publishState. The real issue with doorbells is publishing a camera image from an external integration — the SDK currently only handles numbers, text, and states, not the equivalent of camera.setImage. I’ve prepared a PR for the SDK to address this (I hope that’s what you want us to do for things that might be missing at this time :sweat_smile:)

And for local Tuya (a great complement to the cloud), two features would help — I see they overlap with your #2680 and #2677:

  1. access to the local network from the container (to reach devices on 192.168.x);
  2. the ability to expose/edit device params (IP, protocol, cloud/local toggle) and to trigger an action on a device with a result return (e.g., a « local DP read » — an operation that an uninitiated user would never guess, hence the interest in a dedicated button).

Otherwise:

  • On the Zendure side, everything is now functional for this V1 :partying_face: Ready to propose it.
    We even created the issues to move forward and set an implementation roadmap :heart_eyes:
  • On the Tuya side, we’re almost there!! The local functionality works for devices found via UDP (3 out of 10 devices). Commands work both locally AND in the cloud. The only thing left is the status feedback via cloud :sweat_smile:

EDIT: I launched the gladys-zendure release + added the topic, nothing else to do?

And a remark from Claude after we encountered some polling issues with the 2 Zendure and Tuya integrations, I don’t know if he’s right, but it seems to have resolved the issue on the Zendure side:

Hi Pierre-Gilles,

While finalizing my first external integration (gladys-zendure), I hit a snag with
poll_frequency for devices, and by comparing with your other integrations
I think I found an inconsistency in the template that’s worth reporting.

The issue: the poll_frequency of the DEVICE object (the one in the
publishDiscoveredDevices payload) is strictly validated by the core of external
integrations — it must be a value from DEVICE_POLL_FREQUENCIES, IN
MILLISECONDS (1000/2000/10000/15000/30000/60000), and the device must have
should_poll: true to be polled. Otherwise: 422 « invalid poll frequency »
(or no polling at all if should_poll is missing).

This is exactly what gladys-melcloud does, and it’s correct:
poll_frequency: POLL_FREQUENCY, // = 10 * 1000 ms
should_poll: true,

But the integration-template-js template is inconsistent with this contract:

  • its demo devices have poll_frequency: config.poll_frequency
  • with a default poll_frequency: 300 in config.js (so 300, interpreted
    as seconds… but passed as-is as the device frequency)
  • and they don’t set should_poll.
    So if we install the template as-is and create/poll a demo device,
    the core rejects it (300 is not a value from DEVICE_POLL_FREQUENCIES in ms)
    or never polls it (should_poll missing).

The confusion mainly comes from the fact that there are TWO homonymous « poll_frequency » fields:

  1. the config_schema field of the manifest = a simple form value
    (seconds, chosen by the integrator, with no automatic link to polling);
  2. the poll_frequency field of the device object = constrained to
    DEVICE_POLL_FREQUENCIES (ms) by the core.
    The template links them by passing (1) directly into (2), which can’t work.

Three options, to choose from:

  • fix the template to be functional « out of the box »: either
    hardcode a valid ms value + should_poll: true (like melcloud), or
    convert seconds to ms as I had to do in zendure;
  • or make the core more tolerant: accept seconds and do the conversion
    on the server side;
  • or at least document that the poll_frequency of the device payload is
    constrained to DEVICE_POLL_FREQUENCIES (ms) and requires should_poll: true.

For info, on the zendure side I have a small helper that snaps the config
(value in seconds, 10–3600) to the nearest allowed ms value — I can share it if
you’re interested for the template.

Nothing blocking for me (zendure works), it’s just feedback from my first
experience that can help others avoid the same pitfall.

Also:

Review of the external-integration framework (testing gladys-tuya under real conditions).

Observation: when an external integration republishes a device already created via
publishDiscoveredDevices (same external_id) with updated parameters, the
parameters of the existing device are NOT updated on Gladys’s side, and no
“Update” option appears in the discovery screen for external integrations (unlike
native services).

Concrete use case: a Tuya device is created in cloud mode. Later,
the user enables local mode (the integration rediscover the device with
ip / local_key / protocol_version / local_override after a LAN scan). But
the already created device keeps its old parameters: impossible to switch to
local without deleting and recreating it.

Questions:

  • Is this intended / planned? Should a republish of an existing discovered device
    perform an upsert of the parameters (by external_id)?
  • Or could the discovery screen for external integrations expose an “Update”
    action (like native services do on their device page) to reapply the parameters
    from a new discovery?

For now, the user workaround is to “delete + recreate”
the device, which is not ergonomic as soon as a local parameter changes
(IP, rotated local key, cloud→local switch).

Thanks!

I just implemented 4 additional device types + local MQTT management in 35 minutes!!! It’s amazing!!

Oh strange, I’ll check, indeed he should pull « dev » in this case

Oh well, thanks! Generally, I did in the sense:

Modification Spec → Modification Gladys → Modification SDK → Modification template

So everything stays coherent, but I’ll check your PR :slight_smile:

Ok I’ll see with Claude

Isn’t that the case? Can’t you expose params in publishDiscoveredDevices?

Can you specify this part? Especially « DP » I don’t know what that means :sweat_smile:

Excellent!!

You can see the status of your release at this URL:

After that, in your case, you are based on a version of the SDK that is not yet merged, so I think the store will reject it because the manifest is not valid for it (normal, you have to wait for me to merge the PR)

Well spotted, it’s a mistake on the template side, I’ll fix that!!

Incredible!! I can’t wait to put all that in production :grin:

Hi @pierre-gilles, and thanks for your feedback

  1. „Update“ Button / Image Version
    Yes, that’s correct: the button tries to pull the pinned version of the manifest (e.g., :1.0.0) instead of « :dev ». For the dev iteration, pulling „dev“ would be perfect. Consider that a dev image from a PR might be named with a longer tag like « :dev-test-mqtt-local » and include it in the spec to enforce dev image tags.

  2. „Expose/Edit Device Parameters“ — I clarify
    Expose parameters for discovery: OK, that works, I already do that in publishDiscoveredDevices (ip, local_key, protocol_version, local_override…).
    The issue isn’t there: it’s the UPDATE of parameters for an ALREADY CREATED device. When I republish a discovered device (even with the same external_id) with changed parameters — typically the LAN IP that changes with DHCP, or a cloud→local switch after a new scan — the parameters of the existing device are not re-applied, and the user has no way to edit/refresh them.
    Today the only workaround is to delete and recreate the device.
    → Need: either an upsert of parameters on re-publish (by external_id), or an „Update“ action in the external integrations discovery screen

  3. „DP“ and „Action with Result Return“
    DP = Data Point (Tuya terminology). Each Tuya device exposes numbered datapoints (DP 1 = switch, DP 20 = brightness, etc.); the complete state = the „DPS map“. „Local DP reading“ consists of reading this map DIRECTLY on the LAN via the local Tuya protocol. Two uses:

  • validate that the local connection + protocol version work
    BEFORE enabling local mode (otherwise we enable a local mode that times out);
  • discover/confirm the DP→feature mapping.

This is a user-triggered action, WITH a result returned to the UI (the DPs read, or the error). The generic need for external integrations: the ability to expose a user action „execute this operation and show me the result“ (local connection test, DP read, re-pairing, identify…), since we don’t have custom UI. The SDK only handles state pushes and poll/setValue today — not this type of on-demand request/response action.
We can only trigger this protocol version and DP search if we know the IP, and if UDP hasn’t found it, we should be able to manually enter the IP to launch this automatic search (it scans by attempting to poll for each version) - And this is quite a long operation - up to 15s I think - to avoid spamming the device.

Other Feedback:

  • By mistake, I was able to install a 2nd dev container when I hadn’t uninstalled the previous one. No error, it installed as « -2 ». During installation, we should be warned and it should be impossible to install two identical images (on the other hand)
  • Installing a container with the tag « :dev » (or tag « :dev-xxxxxx ») should display a warning: if a prod instance is running next to it, it can impact/break the other instance, advise to stop the prod container before installing the « :dev » tag - on the other hand, it’s a very good thing to be able to install a dev and a prod side by side
  • Display the container startup time in the configuration page
  • When an external container is stopped, polling (and any other exchange attempts if applicable) should be stopped to avoid log pollution:
2026-07-20T07:43:19+0200 <error> device.poll.js:23 (DeviceManager.poll) There was an error while polling device ambiance-salon
2026-07-20T07:43:19+0200 <error> device.poll.js:24 (DeviceManager.poll) ExternalIntegrationUnavailableError: EXTERNAL_INTEGRATION_NOT_CONNECTED
    at ExternalIntegration.sendCommand (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/lib/external-integration/externalIntegration.sendCommand.js:23:11)
    at Object.poll (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/lib/external-integration/externalIntegration.registerProxyService.js:42:20)
    at DeviceManager.poll (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/lib/device/device.poll.js:21:26)
    at Promise.map.concurrency (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/lib/device/device.pollAll.js:13:87)
    at tryCatcher (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/util.js:16:23)
    at MappingPromiseArray._promiseFulfilled (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/map.js:68:38)
    at MappingPromiseArray.PromiseArray._iterate (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/promise_array.js:115:31)
    at MappingPromiseArray.init (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/promise_array.js:79:10)
    at MappingPromiseArray._asyncInit (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/map.js:37:10)
    at _drainQueueStep (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/async.js:97:12)
    at _drainQueue (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/async.js:86:9)
    at Async._drainQueues (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/async.js:102:5)
    at Immediate.Async.drainQueues (/home/gladys/GladysDev/gladys-external-integration-test/Gladys/server/node_modules/bluebird/js/release/async.js:15:14)
    at processImmediate (node:internal/timers:491:21)

  • When an integration can be cloud and local with devices that can be local or cloud, we should enforce a spec:
    • Global Integration in local or cloud mode = a „Local Mode“ switch that prioritizes local for all devices if checked, fallback per device to cloud if no local or broken; otherwise 100% cloud
      image
    • a global badge on the Device view that specifies whether the application is set to local or cloud
    • a badge per device that specifies whether the device is local or cloud.
      We really need this for Tuya, for example. And I encounter the same case with Zendure (the tests during the implementation of local mqtt on zendure made me realize that I had 2 devices that did not have local mqtt enabled, I struggled for 30 minutes, a visible badge would have made me understand and move on to the real diagnosis quickly).
      Then the external integration documentation can include a „FAQ“ or troubleshooting section
  • By the way, on the documentation side? I haven’t looked in your specs, but have you planned to enforce it to validate an integration?
    It would be good to define, for example (like HA), a footer format and the main chapters to enforce - with a template like for the container. Possible?

Hi @Terdious, these are some great feedback :slight_smile:

I’ve adapted the spec and the implementation will follow:

All seven feedback points are integrated, each with the corresponding design decision:

1. Params after creation — both requested mechanisms, with a clear boundary. The params are the technical data of the integration: when republishing an already created device (same external_id), the supervisor silently upserts them (moving DHCP IP, cloud→local transition), without echoing device-updated to the integration (otherwise loop: republish → event → republish). The name, room, and features remain the user’s property: if the published structure differs, the Discovery screen displays an Update button — user action via the standard POST /api/v1/device. No more delete/recreate.

2. Action buttons — new actions field in the manifest (max 10): key, multilingual label/description, and especially two things sized for the Tuya case: an optional fields in the exact config_schema format (the mini-form « enter the IP » is rendered by the same engine, validated the same way) and a timeout_seconds per action (5–120, default 30) that replaces the 5s rule for action.run — the protocol version scan at ~15s passes. Full chain: button → POST .../action/:key → WS action.run → result in command-result.data.message (string or multilingual) displayed under the button. SDK: onAction('detect_protocol', async (fields) => '...').

3. Dual dev/prod instance: during installation (all modes), if an installed integration shares the same image without the tag (:dev next to :1.2.0) or the same manifest name → explicit warning (possible conflict on the same cloud account / same devices, advice to stop the other instance during testing), but installation remains possible — dev next to prod is an intended use, the ext-dev-* selectors guarantee the absence of technical collision.

4. Start time: started_at of the main container (and each sub-container) in the admin details, displayed in the supervision block (« running since… »).

5. Polling a stopped integration: the scheduled poll becomes a silent no-op when the integration is not RUNNING/DEGRADED — neither throw nor log line every N seconds for an already known state. setValue continues to throw: the user who triggers must see the error.

6. Field types: boolean already existed (rendered as a switch/checkbox — clarified); addition of multi_select (checkboxes, value = array) and display: "radio" on select.

7. Mandatory documentation on the store: publishing now requires docs/en.md and docs/fr.md, structured according to the template provided in the template repo (Presentation / Prerequisites / Configuration / Troubleshooting). The indexer checks presence + minimum size (reject error otherwise), re-hosts the files on Pages like the covers (docs in index.json), and the installation screen gains a « Documentation » link (user language, fallback en).

I think that’s a bit too specific to what you’re doing there, I prefer to keep it generic :slight_smile:

Well, in fact, Zendure is neither in https://integration-store-storage.gladysassistant.com/index.json, nor in https://integration-store-storage.gladysassistant.com/rejected.json ^^
On the other hand, gladys-tuya (whose PR I have not yet merged) is indeed in https://integration-store-storage.gladysassistant.com/rejected.json:

[
  {
    "store_slug": "Terdious/gladys-tuya",
    "level": "error",
    "reason": "docker_image: image is not publicly pullable (registry auth denied, HTTP 403)",
    "checked_at": "2026-07-20T07:24:35.428Z"
  }
]

But you will have to make us a real visual page, right :face_with_peeking_eye: :sweat_smile:

Fable 5 will take care of that, don’t worry :rofl:

@Terdious so you were running tests from the PR #2680? Can you confirm that it was working fine?

Oh yes, I’d say perfectly :slight_smile:

For the new features you requested, I asked GPT 5.6 Sol 1M High to review them, and it thinks they’re too generic Zendure, and I agree with it :slight_smile:

The review: Add solar-battery device feature category - gladys-zendure external integration by Terdious · Pull Request #2682 · GladysAssistant/Gladys · GitHub

Thanks Pierre-Gilles, overall it’s all good for me:

Thanks for all the fixes / implementations

Just one point where I push a bit — the « too specific / rather generic » on
local/cloud mode + badges:

I think it’s exactly a GENERIC need, not specific to Tuya. The recurring pattern = « an integration that covers both cloud AND local, with devices whose actual transport can differ from one device to another and evolve over time ». Example off the top of my head:

Integration Cloud/local duality Status
Tuya cloud + LAN per device in Gladys (→ external)
Netatmo weather = full cloud; cameras/doorbell = cloud + local (local much faster on image, more reliable as the cloud link drops)
in Gladys, in the process of moving external
Zendure cloud + local MQTT external (yours)
Shelly local HTTP/WS + Shelly Cloud convertible / upcoming
eWeLink / Sonoff cloud + LAN mode convertible / upcoming
Somfy TaHoma cloud + local API convertible / upcoming

The brick to put in the framework isn’t the Tuya logic,
it’s:

  1. a TRANSPORT STATUS PER DEVICE, standard and agnostic
    (local / cloud / unreachable), that the integration reports and that Gladys
    displays as a badge — + a global badge on the Devices view;
  2. optionally a STANDARD CONNECTION PREFERENCE (« prioritize local
    when available, cloud otherwise ») — it’s exactly the wording of your toggle
    « Local mode (LAN) ».
    The integration fills in, Gladys renders → 100 % generic, zero Tuya semantics
    in the core.

The value is mainly diagnostic and universal: without a visible badge,
the user can’t know why a device is slow or frozen. My own Zendure case (2 devices without local MQTT, 30 minutes lost): a badge would have put me on the right track right away.

And it’s not just one integration, the same logic applies to all the devices in the table above. The other day you were talking to me about Shelly, it’s the same, you have to enable the mqtt on the local web interface http to have the local MQTT and/or the cloud.
The pattern comes back as soon as a manufacturer offers both channels — hence the interest in a reusable « transport status + preference » primitive rather than a treatment per integration.

Plus it’s quite simple to implement on the Gladys core side: A toggle defined on the configuration side (which would be the same for all) and a parameter on the device side (same defined in spec). Then it’s internal cooking in the container!

Ok thanks for the explanation, it’s good for me I’ve adapted the spec!

I’m currently working with Claude to get access to the local network

For normal local access, it’s already possible, are you sure it was blocking you?

My opinion: it’s already possible in the design — and it’s important not to « fix » what isn’t broken. The NAT bridge doesn’t block outgoing unicast to the LAN: a container on gladys-integrations can open a TCP/UDP connection to 192.168.1.50 (the local Tuya protocol port 6668, the local Shelly HTTP API…) exactly as it joins a cloud — Docker masks the output, nothing is filtered. enable_icc=false only isolates containers from each other on the bridge, not traffic to the outside. The only real limit, already documented in B.2/B.16, is the reception of broadcast/multicast (the UDP discovery scan) — and I suspect that’s the source of the misunderstanding: « no broadcast » was read as « no LAN ». If they actually encountered a unicast failure in their dev environment, the cause is outside the framework — the classic case being a strict host firewall (ufw) that DROPs the bridge→LAN FORWARD: exact symptom « cloud OK, LAN KO "

I’ll double-check if it’s not him (and me who gave bad directions…) who messed up!! If that’s the case, sorry for the trouble.

SDK version 0.4.0 released, Claude adapts the template-js, and I’m currently merging Gladys into the base PR

Edit: merge completed, everything is in this PR now: External integrations (RFC phase 1): Docker supervisor, host API, integration WebSocket, decentralized store and generic frontend by Pierre-Gilles · Pull Request #2665 · GladysAssistant/Gladys · GitHub