Skip to content

Improve Embedded Readiness, Runtime Portability, and Postcard-Ready Serialization#875

Open
jsmnbom wants to merge 13 commits intobuttplugio:devfrom
jsmnbom:embedded3
Open

Improve Embedded Readiness, Runtime Portability, and Postcard-Ready Serialization#875
jsmnbom wants to merge 13 commits intobuttplugio:devfrom
jsmnbom:embedded3

Conversation

@jsmnbom
Copy link
Copy Markdown
Contributor

@jsmnbom jsmnbom commented Apr 22, 2026

Summary

This PR is a grouped set of changes that makes buttplug significantly more embedded-ready in practice, while also improving stability and runtime portability for general upstream users.

High-level goals:

  1. Fix reliability issues that can lead to hangs or missing error propagation.
  2. Improve runtime compatibility and dependency footprint.
  3. Reduce avoidable cloning in device config loading.
  4. Prepare config types for postcard-compatible binary serialization.

This PR, together with already merged PRs #839 and #852, is intended to replace #833 with a cleaner, better-scoped set of changes.

It also makes buttplug work well for my use-case in https://github.com/jsmnbom/esp-butt, with only minor downstream fork deltas still needed:

  • a disconnect_device() method on ServerDeviceManager
  • rework of error serialization to avoid serde-json entirely

What Changed

Reliability Fixes

  • btleplug: prevent disconnect hangs by guarding disconnect with is_connected and timeout
  • server: forward device connection errors back to the client
  • lovense: add timeout in battery query path via select
  • device_task: switch batch deadline sleep to runtime abstraction

Runtime Portability and Dependency Footprint

  • buttplug_server: swap regex to regex-lite

Device Config Quality and Clone Reduction

  • rename update_with_configuration to with_defaults and simplify call sites
  • store base device definitions as Arc to avoid repeated cloning
  • make ServerDeviceFeature::new take owned values so clone points are explicit
  • derive Debug and Clone for ClientDeviceOutputCommand

Postcard-Compatible Serialization Groundwork

  • Endpoint: human-readable string serde plus compact binary serde representation
  • ServerDeviceDefinition: derive Serialize and Deserialize
  • BaseDeviceIdentifier: expose getters publicly for external inspection of returned identifiers
  • ServerDeviceFeature: remove conditional field omission where binary round-trip requires field presence

Why This Replaces PR #833

Compared to #833, this series is split into smaller, logical commits and is easier to review and validate incrementally.

It also aligns the upstream story around postcard-compatible serde support rather than embedded-only changes, while keeping the fork-only items isolated.

Scope and Non-Goals

Included:

  • stability fixes, runtime portability updates, device config cleanup, and postcard-compatibility groundwork

Not included:

  • fork-only items that are still downstream-only:
    • disconnect_device() method on ServerDeviceManager
    • error serialization rework to avoid serde-json entirely

Validation

  • built affected crates during each step
  • ran full test suite:
    • cargo test
    • result: pass (no failures)

Commit Groups

Reliability fixes:

  • ff3d1b9 btleplug: guard disconnect() against hang on already-disconnected devices
  • b38bc0d server: forward device connection errors to the client
  • 8dc2a76 device_task: use async_manager::sleep for batch deadline
  • 9e3b097 lovense: add timeout to handle_battery_level_cmd via select!

Runtime portability and dependency footprint:

  • 025c837 buttplug_server: swap regex -> regex-lite

Device config cleanup and clone reduction:

  • 5bdfe46 device_config: rename update_with_configuration -> with_defaults
  • e0d8d8d device_config: store base_device_definitions as Arc
  • 44f2561 device_config: make ServerDeviceFeature::new take owned values
  • 2704c6c buttplug_client: derive Debug and Clone for ClientDeviceOutputCommand

Postcard-compatible serialization groundwork:

  • 4d47572 device_config: add binary serde support for Endpoint
  • b83f890 device_config: derive serde for ServerDeviceDefinition
  • 3c62e18 device_config: make BaseDeviceIdentifier getters public
  • d3815d8 device_config: make ServerDeviceFeature binary-serde friendly

Follow-up Needed

We still need a proper memory-focused test pass similar to concerns raised in #833.

jsmnbom added 13 commits April 22, 2026 20:34
…ices

On BlueZ, calling peripheral.disconnect() on a device that has already
disconnected can hang indefinitely waiting for a D-Bus reply that will
never arrive.

Fix by checking is_connected() first and skipping the disconnect call if
the device is already gone. A 5-second tokio::time::timeout is still
applied around the actual disconnect in case the device drops between
the check and the call.
When build_device_handle() fails (e.g. protocol identification error,
hardware init failure), the error was only logged server-side. The
client received no notification and would be left waiting indefinitely.

Fix by constructing an ErrorV0 from the ButtplugError and broadcasting
it via server_sender, mirroring how other server-side errors are
reported.
tokio::time::sleep_until is tokio-specific and does not compile for
non-tokio runtimes (e.g. WASM). Replace with async_manager::sleep,
computing the remaining duration via saturating_duration_since, which
is consistent with how other sleeps in this crate are expressed.
Some Lovense devices treat battery reporting as a togglable subscription
rather than a request/response: they do not promptly reply to a Battery;
query but instead start streaming battery level at an interval, or do
not reply at all. Without a timeout, handle_battery_level_cmd hangs
indefinitely in those cases. See:
buttplugio#865

Refactor the response loop from 'while let Ok' to 'loop { select! {
biased; ... } }', racing the notification stream against a
LOVENSE_COMMAND_TIMEOUT_MS sleep. On timeout, return a
ProtocolSpecificError. Also handles Err(_) from recv() (channel closed)
in the same arm as explicit Disconnected events.

A fixed timeout is a known-bad workaround, but strictly better than
hanging forever.
The patterns used in the Lovense and VibCrafter protocol impls do not
require Unicode support, so the full regex crate is unnecessary here.

Note: regex is still pulled in transitively via
jsonschema -> fancy-regex -> regex in most common builds, so this does
not eliminate the dependency from the binary today. However:

1. Embedded builds that avoid jsonschema code paths will no longer pay
   for regex as a direct dependency.
2. Once Stranger6667/jsonschema#470 is
   resolved (jsonschema drops fancy-regex), removing this direct dep
   will actually shrink the binary.
The old API was caller.update_with_configuration(config) where caller
was the defaults and config was the device-specific entry. The semantics
were inverted: 'update with configuration' implied the arg was the
update source, but the receiver was actually the fallback.

Rename to with_defaults(defaults) and invert: self is now the
device-specific config (takes ownership), defaults is the optional
protocol-level fallback (borrowed). Optional fields in self take
priority; defaults fill in None slots via or_else.

Call site in load_main_config simplifies: the if/else on whether
defaults exist collapses to config.clone().with_defaults(default.as_ref()).into()
since with_defaults(None) is a no-op.
…ition>

Each ServerDeviceDefinition stored in the base map is now heap-allocated
once and reference-counted. Benefits:

- DeviceConfigurationManagerBuilder::finish() no longer clones every
  definition — it does cheap Arc::clone() instead.
- load_main_config() creates one Arc<ServerDeviceDefinition> per config
  entry and shares it across all identifiers for that entry (e.g.
  multiple BLE advertisement names for the same device model).
- ServerDeviceDefinitionBuilder::from_base() in device_definition()
  reads through the Arc without cloning the value.

API changes:
- DeviceConfigurationManagerBuilder::base_device_definition() now takes
  Arc<ServerDeviceDefinition> directly.
- base_device_definitions() getter is now pub (was pub(crate)).
- Add DeviceConfigurationManager::new() for constructing directly from
  pre-built maps (bypasses builder validation — see doc comment).
ServerDeviceFeature::new now takes String and owned SmallVecEnumMap values instead of &str and borrowed maps.

This makes ownership explicit at the call site and removes the hidden clones inside the constructor. For ConfigBaseDeviceFeature -> ServerDeviceFeature, the fields can now move directly into the new value. For ConfigUserDeviceFeature::with_base_feature(), only the reused base_feature fields are cloned, which makes the remaining cloning costs visible and local to the actual reuse case.
Endpoint now serializes as lowercase strings for human-readable formats and as repr(u8) values for binary formats.

This preserves the existing JSON/device-config representation while making Endpoint compatible with postcard-style binary serialization without string allocation.
ServerDeviceDefinition now derives Serialize and Deserialize.

This makes device definitions serializable for future config caching and postcard-compatible transport work.
BaseDeviceIdentifier now exposes public getters while keeping mutation crate-private.

This allows external code to inspect identifiers returned by public config-manager APIs.
ServerDeviceFeature and its output property types now serialize all fields needed for binary round trips.

This keeps postcard-style serialization from losing empty, false, or zero-valued fields that must remain present in non-self-describing formats.
ClientDeviceOutputCommand now derives Debug and Clone.

This matches the command-value helper type and makes the client output command easier to inspect and pass through higher-level client code.
@jsmnbom jsmnbom changed the title Embedded3 Improve Embedded Readiness, Runtime Portability, and Postcard-Ready Serialization Apr 22, 2026
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.

1 participant