Skip to content

Navigation Menu

Sign in
Sign up

Generic device drivers through the JavaScript extension system #749

tadelv started this conversation in Ideas
Discussion options

Generic device drivers through the JavaScript extension system

The brief

We want Decaid to be able to support new devices as they become available, without requiring every device integration to become part of Decaid core.

The idea is to expose a generic device API to JavaScript extensions/plugins and let the extension implement the device-specific protocol and behavior.

An extension could, for example, declare capabilities such as:

  • driver.grinder
  • driver.sensor
  • potentially other device classes in the future

and request the transport it needs:

  • transport.ble
  • transport.usb
  • transport.wifi
  • transport.websocket
  • etc.

Decaid would provide the generic transport, lifecycle and device primitives, while the extension would contain the actual device-specific implementation.

The main challenge is defining an API that is generic enough to accommodate devices with very different requirements without gradually turning into a collection of device-specific exceptions.

This is already showing up in several places

There are a few existing discussions/issues that point toward the same architectural boundary:

I think these are really different instances of the same question:

Where should Decaid stop, and where should an extension begin?

My preference would be:

Decaid
 ├── device discovery/lifecycle
 ├── permission model
 ├── BLE / USB / network transport primitives
 ├── generic device capability interfaces
 └── JS bridge
 ↓
Extension
 ├── device protocol
 ├── packet encoding/decoding
 ├── device-specific state machine
 └── mapping onto Decaid device capabilities

This is very similar to the direction already emerging in #146 for networking: Decaid owns the safe native capability, while JavaScript owns the protocol-specific behavior.

Questions to answer

Some things worth discussing:

  • What should the generic device abstraction actually expose?
  • How much should Decaid know about device classes such as grinders or sensors?
  • Should a driver declare something like driver.grinder, with a standard set of grinder capabilities exposed back to Decaid?
  • How do we represent optional capabilities? A grinder might support start/stop, RPM, grind-by-weight, burr position, presets, or only some subset of these.
  • Should plugins work primarily with raw transport primitives, or should Decaid provide higher-level helpers as well?
  • How should discovery, connection lifecycle, reconnects and errors be exposed?
  • How should plugins publish device state back to Decaid?
  • How should commands from Decaid/UI be routed back to the driver?
  • What permissions should a driver extension require?
  • Should BLE, USB and network-driven devices all ultimately present the same device-facing API to the rest of Decaid?

The transport itself probably should not dictate the device abstraction. BLE/GATT, USB and WebSockets are quite different underneath, but a grinder should still look like a grinder to the rest of Decaid.

Why now?

The current MOTTO80 work is a useful example.

The implementation itself is valuable, but I would prefer that we ultimately implement the MOTTO80 protocol through this mechanism rather than maintaining both a native device implementation and a JavaScript one.

That gives us a good first real-world driver with which to validate the abstraction.

Similarly, the grinder discussion in #701 should ideally not result in every supported grinder getting its own Dart implementation in core. If we can define the grinder contract once, individual device integrations could live as extensions.

The same principle applies beyond grinders. Sensors, scales, other coffee hardware and network services should be able to reuse the same plugin-host architecture.

The long-term goal would be for adding support for a new device to mean:

write a JavaScript driver, declare its capabilities and required transports, and install it — rather than modify and release Decaid itself.

So the question for this discussion is:

What is the smallest useful generic device + transport API Decaid needs to expose for JavaScript extensions to reliably drive arbitrary external devices?

You must be logged in to vote

Replies: 6 comments 3 replies

Comment options

I think the main goal here should be that Decaid provides enough generic platform capabilities that people can build integrations without requiring the core team to babysit every device and every implementation step.

So I would not optimize #749 purely for the smallest possible API. At the same time, I also would not try to implement every possible transport and device class in #749 itself.

I think the right target is a broad platform contract with an incremental implementation.

If the extension surface is too narrow, every new grinder, scale, sensor, transport, or protocol eventually needs another Decaid core change. That defeats much of the purpose of having plugins/extensions.

The important distinction for me is between broad platform primitives and device-specific implementations.

I would like an extension to be able to declare what it contributes, for example:

  • grinder driver
  • scale driver
  • sensor driver
  • potentially other device classes later

and separately request the privileged native transports it needs:

  • transport.ble
  • transport.serial
  • transport.tcp
  • transport.tls
  • transport.websocket
  • potentially discovery/network primitives such as mDNS where useful
  • raw USB only if/when an integration actually requires USB semantics beyond serial

Those are two different concepts.

A driver declaration tells Decaid what the extension contributes to the application.

A transport permission tells Decaid which privileged native capability the extension is allowed to use.

Conceptually a manifest could look something like:

{
 "permissions": [
 "transport.ble"
 ],
 "drivers": [
 {
 "id": "bookoo.motto80",
 "type": "grinder",
 "match": {
 "names": ["BOOKOO MT80", "MOTTO80 BLE"],
 "services": [
 "4d543830-0001-4b80-8f00-424f4f4b4f4f"
 ]
 }
 }
 ]
}

I would keep the driver declaration out of the permission namespace. type: "grinder" is a contribution declaration, while transport.ble is a security grant.

I would also avoid giving every extension its own unmanaged BLE scanner.

Decaid already has to coordinate BLE scanning and connection ownership for the DE1, scales and other devices. The extension should describe what it is interested in, while Decaid owns the actual scanning/lifecycle and hands matching candidates to the extension.

Something along these lines:

export async function attach(device) {
 await host.ble.subscribe(
 device,
 SERVICE_UUID,
 STATUS_UUID,
 onStatus
 );
 await host.ble.write(
 device,
 SERVICE_UUID,
 COMMAND_UUID,
 handshake
 );
}

The device here should be an opaque host-owned handle, not unrestricted access to the underlying platform BLE implementation.

I also think matching should probably be treated as a two-stage operation rather than assuming advertisement metadata is always sufficient.

The manifest can cheaply pre-filter candidates using things such as:

  • advertised names
  • service UUIDs
  • manufacturer data
  • serial descriptors
  • VID/PID where appropriate
  • mDNS service type
  • other transport-specific discovery metadata

Then the extension could optionally perform a bounded probe() using the opaque device handle before claiming the device.

That gives Decaid a place to arbitrate cases where multiple drivers match the same candidate, and avoids forcing all device identity logic into the central matcher.

The same general driver model can then work for other transports.

A serial grinder extension should not require us to invent another grinder architecture; it requests transport.serial.

A network device may request TCP/TLS or WebSocket.

A BLE sensor requests BLE.

The protocol implementation changes, but Decaid's device model does not have to.

I would define transport capabilities according to their actual programming semantics rather than only physical medium. BLE/GATT, serial streams, TCP streams and WebSocket messages are meaningfully different APIs. Something like generic transport.usb is probably too vague until there is a real requirement for raw USB control/bulk/HID access beyond the existing serial path.

The host should own the difficult shared parts:

  • permission enforcement
  • discovery/scanning coordination
  • candidate matching and claim arbitration
  • connection ownership
  • reconnect/disconnect policy
  • resource limits
  • cleanup when an extension unloads
  • stale-generation/event fencing
  • remembered/preferred devices
  • exposing registered devices to the rest of Decaid

The extension should own the protocol:

  • framing and parsing
  • handshakes
  • characteristic UUIDs
  • device-specific commands
  • presets
  • firmware quirks
  • mappings such as MOTTO80 bladeGap → generic grindSetting
  • protocol-specific diagnostics

There is one lifecycle distinction I think is important here: transport connected is not necessarily driver ready.

For example, BLE may report that a MOTTO80 connection is established, but the extension may still need to subscribe, perform a handshake, query initial state and verify the device before Decaid should consider the grinder usable.

So the generic driver lifecycle probably needs some equivalent of:

discovered
→ claimed
→ connecting
→ initializing
→ ready
→ disconnected / failed

rather than treating an open transport connection as the end of initialization.

I think we should also go one step further and give drivers a generic way to describe the functionality they expose, rather than forcing every new product to grow another product-specific Dart interface.

I would not make this completely free-form, though.

Decaid should still define stable semantics for known device classes. A grinder should have a set of well-known grinder capabilities with documented meaning, units and behavior, while still allowing namespaced extension-specific capabilities for unusual hardware.

For example a grinder could expose capabilities roughly like:

{
 "properties": {
 "grindSetting": {
 "type": "number",
 "min": 0,
 "max": 1000,
 "writable": true
 },
 "grindRpm": {
 "type": "number",
 "unit": "rpm",
 "min": 0,
 "max": 1050,
 "writable": true
 },
 "feedingRpm": {
 "type": "number",
 "unit": "rpm",
 "min": 0,
 "max": 65,
 "writable": true
 },
 "humidity": {
 "type": "number",
 "unit": "%",
 "writable": false
 }
 },
 "actions": {
 "start": {},
 "stop": {},
 "loadPreset": {
 "parameters": {
 "presetId": {
 "type": "string"
 }
 }
 }
 }
}

I would separate properties/state, actions, and potentially events rather than trying to represent everything as a writable property.

grindRpm = 800 maps naturally to a property.

start, stop, tare, measure, calibrate, or loadPreset are actions and need different semantics, including success/failure and potentially parameters/results.

Then Decaid can provide generic plumbing around those capabilities:

  • API exposure
  • state updates
  • command dispatch
  • basic debug/control UI
  • logging
  • automation hooks
  • perhaps generic settings surfaces

without knowing anything about the MOTTO80 wire protocol.

The important point is not that Decaid should have no domain model.

I think Decaid should understand that something is a grinder, scale or sensor, and should define stable semantics for the common operations it wants to integrate with.

What I would avoid is making the core understand every product.

In other words:

stable device-class semantics in core; product-specific implementations in extensions.

That is where I think a broader contract in #749 is justified, even if the implementation is delivered incrementally.

We should build enough of the platform once so that the next contributor can implement a device largely inside an extension rather than opening another PR that adds a new controller, matcher, settings path, lifecycle path, REST handler, WebSocket handler and debug screen to Decaid core.

The MOTTO80 work is actually a useful test case for this.

It currently touches a lot of core areas because Decaid does not yet provide these extension points.

Instead of treating each of those changes as a reason to permanently add a first-class MOTTO80/grinder stack to core, I would use them as a checklist for what the extension platform is missing.

For example:

  • it needs BLE discovery/matching → make that a generic driver registration facility;
  • advertisement matching may not be sufficient → support optional driver probing/claiming;
  • it needs BLE read/write/subscribe → expose permission-gated BLE/GATT transport;
  • it needs auto-connect → let registered drivers participate in the existing device lifecycle;
  • it needs protocol initialization → distinguish transport connected from driver ready;
  • it needs state → provide a generic driver state/capability channel;
  • it needs commands/settings → provide generic writable properties and actions;
  • it needs debugging → provide generic transport/driver logging where possible rather than a 700-line MOTTO80-specific debug view;
  • it needs REST/WS access → expose registered driver state/actions through a generic device API instead of adding a new endpoint family for every future product.

This also gives us a clearer security boundary.

An extension contributing a grinder driver should not automatically gain Bluetooth, filesystem, arbitrary TCP, etc.

It only receives the transports explicitly requested and approved.

Likewise, transport handles should belong to a particular plugin/extension ID and generation and be automatically closed when that extension is unloaded. Late callbacks from a previous generation should be discarded.

So the test I would use for #749 is:

Could somebody implement the next BLE grinder without changing Decaid core?

And then, once the model exists:

Could somebody implement a serial grinder, BLE sensor, or network-connected peripheral using the same driver model and only requesting a different transport?

I do not think #749 has to implement all of those transports immediately in order to pass that test.

It does need to establish a contract that does not prevent them.

A reasonable implementation sequence could be:

  1. define the generic driver registration/matching/claim/lifecycle contract;
  2. expose BLE/GATT through opaque permission-gated handles;
  3. implement the generic grinder state/property/action model;
  4. use MOTTO80 as the first vertical proof;
  5. reuse the generic network transport work for a WebSocket/TCP-based driver;
  6. add additional transports when real devices require them.

If the second grinder or sensor requires no new product-specific Decaid architecture, then we know the abstraction is doing useful work.

If every new integration still requires us to add another FooController, FooHandler, preferred-device path, custom WebSocket, custom debug screen, etc., then we have moved the protocol code around but have not actually solved the maintainer problem.

In short, I would deliberately make the extension toolbox broad, while keeping the implementation rollout incremental and the device implementations outside core:

Decaid owns safe native capabilities, lifecycle and generic device integration. Extensions own protocols and products.

That gives contributors enough power to build things independently without turning Decaid core into the place where every grinder, scale and sensor protocol has to live.

You must be logged in to vote
0 replies
Comment options

tadelv
Aug 31, 2026
Maintainer Author

Two more points from my side:

  • driver manifest should provide all the means for DeviceMatcher implementation to be able to match a device. I think we shouldn't hand off generic devices to extension asking 'is this the one you want?' at all. Most likely this is already written above, I just want it emphasised.

  • Javascript doesn't get host.ble to play with, unless that functionality is limited to device related functionality only. Again, most likely this is covered by the text above, just needed to write it out.

You must be logged in to vote
0 replies
Comment options

A suggestion to throw into the mix: you might get everything this thread is after without moving drivers into JS at all - keep them in Dart, in core, but make them cheap. Basically the Linux kernel model: drivers live in-tree, and the driver API is what makes them small.

The MOTTO80 checklist above actually makes the case for this. Almost none of that cost was protocol code - it was wiring. The matcher entry, the controller, the settings path, the REST/WS endpoint family, the debug screen. That wiring is exactly what the generic device model described here would eliminate. And once it's gone, a native driver PR shrinks to roughly what a JS driver would have contained anyway: the protocol, plus a registration saying what the device is and how to match it.

class BookooGrinder extends Grinder {
 static const registration = DriverRegistration(
 id: 'bookoo.motto80',
 type: DeviceType.grinder,
 match: DeviceMatch(names: ['BOOKOO MT80'], services: [SERVICE_UUID]),
 capabilities: mottoCapabilities,
 );
 // protocol only: framing, handshake, quirks
}

Everything else - API exposure, state plumbing, settings, debug UI — comes from the generic model. And staying native keeps a lot of things you'd otherwise have to rebuild or give up: one language, real stack traces in crash reporting, CI against simulated devices, no bridge latency to worry about when a 10Hz weight stream is feeding grind-by-weight, and code review as the quality gate instead of a sandbox and a permission model.

The costs are real and worth naming honestly: contributors need core review, and users need an app release to get a new device. But two things make that trade better than it looks. The espresso-hardware universe is maybe 20–50 devices total, not Home Assistant's thousands — at that scale, in-tree drivers on a good abstraction may simply be cheaper in aggregate than building and forever maintaining a driver platform, its schema versioning, and its distribution story. And the boilerplate cost of a Dart driver has dropped a lot now that this repo is deliberately agent-friendly; the tedious part of a driver PR mostly writes itself these days.

None of this argues against #146, to be clear. MQTT-style integrations are where JS plugins genuinely shine — outbound, latency-tolerant, and able to reuse existing JS libraries instead of reimplementing protocols in Dart. The boundary that falls out feels clean: plugins observe and integrate, core drives hardware.

And nothing gets foreclosed. If in-tree contribution ever becomes the real bottleneck — review queue, release latency, contributors drifting away — the JS bridge can still be added later, on top of a device model that's proven and versioned by then. Building the model first and deciding on the runtime later is the reversible order; building both at once, validated by a single device, is the risky one.

You must be logged in to vote
1 reply
Comment options

tadelv Aug 31, 2026
Maintainer Author

thanks @giladger , but I have put a strict requirement on the fact that implementation can be added without touching the core, just by installing a .reaplugin or something.
I know I saw dart_eval somewhere, but that one might cause trouble with Apple, which is why we're pushing for JS. And also - if for some reason we decide to rewrite the core in Zig, we can keep the extensions working. Although I'm not sure there is a JS runtime for Zig done already. :D

Comment options

tadelv
Sep 1, 2026
Maintainer Author

could we also use #739 to prove a scale can be added? Maybe that can be done even before the grinder implementation? I'll pick up #146 most likely so we get that foundation work moving.

You must be logged in to vote
1 reply
Comment options

The other option would be moving an existing scale implementation into a extension, which IMO would be easier to fully verify (as tests should already exist)

Comment options

One uncovered part IMO is the plan regarding existing devices, what stays in core, what moves to extensions and are there extensions just maintained with the core and shipped by default (which would make the most sense for me)

You must be logged in to vote
1 reply
Comment options

tadelv Sep 4, 2026
Maintainer Author

This is a valid question, but there is also a question of overriding an existing driver, that might be integrated into the core.
The simple initial response would be: Decent devices live in dart core, the rest moves (slowly) to javascript. But for people to actually support the transition to JS, we would need a simple way to disable the dart implementations.

Comment options

tadelv
Sep 7, 2026
Maintainer Author

I think we have enough signal now to turn this into a staged implementation rather than trying to define the entire future driver system upfront.

So far we have proven two useful pieces:

  • generic permission-gated network transports can be exposed to plugins;
  • a plugin can register a device as a sensor and have it participate in the normal Decaid device and sensor APIs.

I propose we proceed in the following order.

1. Generic BLE-backed plugin drivers

The next transport should be BLE, but I think we should refine the contract before implementing it.

The important boundary would be:

plugin manifest declares driver + BLE matcher
 ↓
Decaid owns discovery / scan arbitration
 ↓
Decaid matches a physical device to the driver
 ↓
Decaid owns the physical BLE connection
 ↓
plugin receives an opaque handle to that device
 ↓
JS implements GATT protocol + device-specific behavior

In particular, I don't think plugins should get arbitrary host.ble.scan() access.

The matcher should also be independent of the domain device type. The same BLE mechanism should work for:

sensor
scale
grinder
future device types

This immediately gives us another useful validation path: a Bluetooth sensor should be able to use the existing plugin-backed sensor implementation without requiring another sensor API.

2. Add plugin-backed scale

Before porting an existing scale, I think we should add scale as a real plugin driver type.

A scale represented as a generic sensor would prove that BLE/GATT works, but it would not prove the more important integration boundary: participating in ScaleController, the normal scale API, tare/timer behavior, preferred-scale handling, shot logic, etc.

The existing Scale abstraction is already reasonably small and established, so this seems like a good second device type with which to extend host.devices.

The intention would be:

BLE driver
 ↓
JS scale protocol
 ↓
plugin-backed Scale
 ↓
existing Decaid scale controllers/APIs

rather than adding plugin-specific scale endpoints.

3. Port one existing BLE scale as the verification case

Once those two pieces exist, port one of the existing native scale drivers to JavaScript and compare the two implementations.

I like the suggestion of using an existing scale because we already know its behavior and have tests against the native implementation.

Bookoo Mini Scale looks like a particularly useful first candidate: its protocol is small enough that the experiment stays focused on the architecture rather than the complexity of the hardware.

The test shouldn't only be "we can decode weight over BLE". Ideally we prove equivalent behavior for:

  • discovery and matching;
  • connection lifecycle;
  • weight/battery snapshots;
  • commands such as tare/timer where supported;
  • normal Scale API exposure;
  • disconnect/reconnect;
  • stable identity;
  • eventually preferred/remembered device behavior.

I don't think we need to remove the native implementation during this experiment. Having both implementations available gives us a useful reference.

4. Define the connected grinder type/API after that

Once BLE + sensor + scale have exercised the driver system, we should have much better information for defining the grinder abstraction.

There is also a distinction we should settle there between the existing persisted Grinder model and a connected physical grinder device.

Then MOTTO80 can become the first real grinder-driver proof rather than also being responsible for proving BLE, discovery, plugin lifecycle and the grinder API all at once.

5. Defer other transports

I would defer serial, raw USB and other discovery/transport mechanisms until we have a concrete driver that needs them.

The important thing now is getting the driver ↔ physical device ↔ Decaid boundary right with one transport rather than trying to enumerate every possible transport in advance.

I've opened #809 to track steps 1 and 2. I think that issue should be refined separately before implementation, especially around the BLE matcher/binding contract and the plugin-backed Scale API.

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
Ideas
Labels
enhancement New feature or request

AltStyle によって変換されたページ (->オリジナル) /