Skip to content

Read device settings and active profile via BLE - #234

Open
Koky05 wants to merge 13 commits into
Arbuzov:masterfrom
Koky05:feature/settings-readback
Open

Read device settings and active profile via BLE#234
Koky05 wants to merge 13 commits into
Arbuzov:masterfrom
Koky05:feature/settings-readback

Conversation

@Koky05

@Koky05 Koky05 commented Apr 17, 2026

Copy link
Copy Markdown

Summary

  • Settings readback: Discover and implement BLE commands to read device settings from the machine (previously write-only)
    • Query active profile via 0xA5+0xF0 command
    • Read per-profile settings via 0x90+0xF0 with profile index: switches (eco mode, sounds, cup light), auto power off, water temperature, water hardness
    • Parse 0x90 and 0xA5 responses to update device state
  • Switch/select entities now reflect actual machine values instead of hardcoded defaults
  • Fix coffee with milk total: Combine stat IDs 3001+3003 to match machine display value
  • Improve BLE reliability: Wrap stats requests in try/except so a single timeout doesn't block subsequent requests

Protocol Discovery

Settings are stored per-profile on the machine. The read command format:

Request:  [0x0D, 0x0B, 0x90, 0xF0, PROFILE_ID, GROUP, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
Response: [0xD0, 0x08, 0x90, 0xF0, PROFILE_ID, GROUP, VALUE, CRC, CRC]

Groups: 0x3F=switches, 0x3E=auto-off, 0x3D=temperature, 0x32=hardness

Active profile query: [0x0D, 0x05, 0xA5, 0xF0, CRC, CRC] → returns profile ID at byte[4]

Depends on

Test plan

  • Verified settings read returns correct values matching machine display
  • Eco mode ON, auto-off 30min, temperature Medium, hardness Medium — all match
  • Active profile correctly detected as Profile 1 (Koky)
  • Coffee with milk total matches machine display (3001+3003)
  • BLE timeouts on individual stats don't block settings read

Tested on DeLonghi Dinamica PLUS ECAM 370.95.S

Summary by Sourcery

Add dynamic, model-based beverage handling and BLE-backed settings/profile readout to better reflect the machine’s actual state and capabilities.

New Features:

  • Support starting and stopping beverages using dynamic recipe definitions from the machine model rather than a fixed beverage enum.
  • Expose active profile, auto power-off, water hardness, and water temperature settings by reading them from the device via new BLE commands.

Bug Fixes:

  • Correct the total coffee-with-milk statistic by combining the relevant counters to match the machine’s own display.

Enhancements:

  • Initialize device metadata such as model name, image URL, and default status from the machine model where available.
  • Have select entities (profiles, energy save mode, water hardness, water temperature) and switches reflect current device settings instead of only stored or default values.
  • Improve BLE statistics and settings requests to be throttled, resilient to individual request failures, and to populate derived statistics like total water and combined beverage counters.
  • Refine alarm/status mappings and sensor icons for clearer, more user-friendly representation.
  • Adjust switch parsing and monitor data parsing for better compatibility with different monitor data versions.

Peter Kovaľ added 7 commits April 17, 2026 10:58
…atus text

- Use model name from MachinesModels.json instead of hardcoded "Prima Donna"
- Load profile names from machine via 0xA4 command, default to profile 1
- Build dynamic beverage list from machine model recipes (17+ beverages)
  instead of hardcoded 9-item enum, with generic start/stop command builder
- Fix parse_switches byte offset: use data[5]|data[6]<<8 to match
  parse_monitor_data (was incorrectly using data[7] for high byte)
- Make DEVICE_STATUS values human-readable ("Descaling needed" not
  "descale_alarm")
- Default initial status to "Ready" instead of "coffee_beans_empty"
- Fix ImageEntity by setting image_last_updated timestamp
- Track active profile via 0xA9 response handler
- Update service schema to accept dynamic beverage names
- Add status icon that changes based on machine state
- Remove dead legacy fallback code in beverage_start/beverage_cancel
  where string-vs-enum comparison would never match
- Add warning log for unknown beverage names
- Validate beverage service schema against device's available_beverages
  list instead of accepting any string
Replace magic string 'none' with BEVERAGE_NONE constant from const.py
across device.py and select.py to avoid typos and keep components in sync.
- Break long lines to comply with 79-char limit (E501)
- Fix blank line counts (E302, E303, E305)
- Add two spaces before inline comments (E261)
- Add whitespace around arithmetic operators (E226)
- Remove unused datetime import from image.py (F401)
- Remove duplicate async_added_to_hass definition (F811)
- Clean up verbose comments
Read device settings from the machine via BLE protocol discovery:
- Query active profile via 0xA5+0xF0 command
- Read per-profile settings via 0x90+0xF0: switches (eco, sounds,
  cup light), auto power off, water temperature, water hardness
- Parse 0x90 responses to update device state in real-time
- Switch and select entities now reflect actual machine settings
- Settings are read once after first successful statistics update

Fix coffee with milk total:
- Combine stat IDs 3001+3003 (matching machine display value)

Improve statistics reliability:
- Wrap individual stats requests in try/except so a single BLE
  timeout doesn't prevent subsequent requests or settings read
@sourcery-ai

sourcery-ai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements BLE settings readback and active profile detection, wires parsed values into switches/selects, improves statistics aggregation (including coffee-with-milk total), makes beverage handling recipe-aware, and hardens BLE statistics/settings polling against timeouts.

Sequence diagram for BLE statistics and settings polling

sequenceDiagram
    participant HA as HomeAssistant
    participant Device as DelongiPrimadonna
    participant BLE as BLEStack
    participant Machine as CoffeeMachine

    HA->>Device: update_statistics()
    alt stats lock held
        Device-->>HA: return
    else
        Device->>Device: check throttle window
        alt too soon
            Device-->>HA: return
        else
            loop stats_ranges
                Device->>BLE: get_statistics(start,count)
                BLE->>Machine: A1 stats request
                alt timeout or error
                    Machine--x BLE: no response
                    BLE--x Device: exception
                    Device->>Device: catch Exception
                else success
                    Machine-->>BLE: A2 stats response
                    BLE-->>Device: notification(value)
                    Device->>Device: _handle_data(answer_id=0xA2)
                    Device->>Device: _parse_statistics(data)
                end
                Device->>Device: asyncio.sleep(0.3)
            end
            opt settings not yet loaded
                Device->>Device: _read_settings()
                Device->>BLE: send_command([0x0D,0x05,0xA5,0xF0,...])
                BLE->>Machine: active profile request (A5)
                Machine-->>BLE: A5 response (profile_id)
                BLE-->>Device: notification(value)
                Device->>Device: _handle_data(answer_id=0xA5)
                Device->>Device: active_profile_id = value[4]

                loop settings_groups (3F,3E,3D,32)
                    Device->>BLE: send_command([0x0D,0x0B,0x90,0xF0,pid,group,...])
                    BLE->>Machine: 90 settings request
                    Machine-->>BLE: 90 settings response
                    BLE-->>Device: notification(value)
                    Device->>Device: _handle_data(answer_id=0x90)
                    Device->>Device: _parse_settings_response(data)
                    Device->>Device: asyncio.sleep(0.3)
                end
                Device->>Device: _settings_loaded = True
            end
        end
    end
Loading

Sequence diagram for recipe-aware beverage start and cancel

sequenceDiagram
    actor User
    participant HA as HomeAssistant
    participant Device as DelongiPrimadonna
    participant Machine as CoffeeMachine

    User->>HA: call make_beverage(beverage_name)
    HA->>Device: beverage_start(beverage_name)
    alt beverage_name == BEVERAGE_NONE
        Device-->>HA: return
    else
        Device->>Device: recipe = _recipe_map.get(name)
        alt recipe found
            Device->>Device: rid = recipe.id
            Device->>Device: legacy = RECIPE_ID_TO_BEVERAGE.get(rid)
            alt legacy exists and command available
                Device->>Machine: send_command(BEVERAGE_COMMANDS[legacy].on)
            else dynamic
                Device->>Device: cmd = _build_start_command(rid,coffee_qty,milk_qty)
                Device->>Machine: send_command(cmd)
            end
            Device->>Device: cooking = beverage_name
        else recipe missing
            Device-->>HA: log warning Unknown beverage
        end
    end

    User->>HA: cancel beverage
    HA->>Device: beverage_cancel()
    alt cooking == BEVERAGE_NONE
        Device-->>HA: return
    else
        Device->>Device: recipe = _recipe_map.get(cooking)
        alt recipe found
            Device->>Device: cmd = _build_stop_command(recipe.id)
            Device->>Machine: send_command(cmd)
        else
            Device-->>HA: log warning Cannot cancel unknown beverage
        end
        Device->>Device: cooking = BEVERAGE_NONE
    end
Loading

Class diagram for updated Delonghi device, sensors, selects, and switches

classDiagram
    class DelongiPrimadonna {
        +str mac
        +str name
        +str product_code
        +str model
        +str image_url
        +str friendly_name
        +str status
        +str cooking
        +bool connected
        +bool notify
        +str steam_nozzle
        +int service
        +DeviceSwitches switches
        +list~MachineSwitch~ active_switches
        +bool sync_time
        +int _n_profiles
        +list~str~ profiles
        +bool _profiles_loaded
        +int active_profile_id
        +bool _settings_loaded
        +int auto_off_index
        +int water_hardness_index
        +int water_temperature_index
        +dict~str, dict~ _recipe_map
        +list~str~ available_beverages
        +dict~int, int~ statistics
        +float _last_stats_request
        +Lock _stats_lock
        +__init__(config, hass)
        +disconnect()
        +_handle_data(sender, value)
        +_parse_settings_response(data)
        +_read_settings()
        +_parse_statistics(data)
        +update_statistics()
        +get_statistics(start_index, count)
        +beverage_start(beverage)
        +beverage_cancel()
    }

    class DelongiPrimadonnaStatisticsSensor {
        -int _param_id
        -str _attr_name
        -str _attr_unique_id
        -str _attr_translation_key
        -str _attr_native_unit_of_measurement
        -str _attr_icon
        -float _attr_native_value
        +__init__(device, hass, sensor_type, param_id, name, native_unit_of_measurement, icon)
        +async_added_to_hass()
        +native_value
        +icon
        +async_update()
    }

    class ProfileSelect {
        -str _attr_current_option
        +options() list~str~
        +current_option() str
        +async_select_option(option)
        +entity_category
    }

    class BeverageSelect {
        -str _attr_current_option
        +options() list~str~
        +async_added_to_hass()
        +async_select_option(option)
    }

    class EnergySaveModeSelect {
        -str _attr_current_option
        +current_option() str
        +async_added_to_hass()
        +async_select_option(option)
    }

    class WaterHardnessSelect {
        -str _attr_current_option
        +current_option() str
        +async_added_to_hass()
        +async_select_option(option)
    }

    class WaterTemperatureSelect {
        -str _attr_current_option
        +current_option() str
        +async_added_to_hass()
        +async_select_option(option)
    }

    class EnergySaveModeSwitch {
        -bool _attr_is_on
        +async_added_to_hass()
        +is_on bool
    }

    class SoundsSwitch {
        -bool _attr_is_on
        +async_added_to_hass()
        +is_on bool
    }

    class CommandsBuilder {
        +_build_start_command(recipe_id, coffee_qty, milk_qty) list~int~
        +_build_stop_command(recipe_id) list~int~
    }

    DelongiPrimadonna "1" o-- "many" DelongiPrimadonnaStatisticsSensor : provides_statistics_for
    DelongiPrimadonna "1" o-- "1" ProfileSelect : exposes_profiles
    DelongiPrimadonna "1" o-- "1" BeverageSelect : exposes_beverages
    DelongiPrimadonna "1" o-- "1" EnergySaveModeSelect : exposes_auto_off
    DelongiPrimadonna "1" o-- "1" WaterHardnessSelect : exposes_hardness
    DelongiPrimadonna "1" o-- "1" WaterTemperatureSelect : exposes_temperature
    DelongiPrimadonna "1" o-- "1" EnergySaveModeSwitch : exposes_energy_save
    DelongiPrimadonna "1" o-- "1" SoundsSwitch : exposes_sounds
    DelongiPrimadonna ..> CommandsBuilder : uses_for_dynamic_commands
Loading

File-Level Changes

Change Details Files
Add BLE protocol support to read active profile and per-profile settings and store them in device state.
  • Handle 0xA5 responses in _handle_data to update active_profile_id
  • Implement _parse_settings_response for 0x90 replies to map groups to switches and indices
  • Add _read_settings which queries active profile then issues 0x90 reads for switches, auto-off, temperature, and hardness
  • Track active_profile_id, auto_off_index, water_temperature_index, water_hardness_index, and _settings_loaded in DelongiPrimadonna
  • Trigger initial settings read from update_statistics with error handling
custom_components/delonghi_primadonna/device.py
Make HA entities (selects and switches) reflect real device configuration using the newly-read settings.
  • ProfileSelect.current_option now derives from device.active_profile_id instead of only stored state
  • EnergySaveModeSelect, WaterHardnessSelect, and WaterTemperatureSelect expose current_option from device indices
  • EnergySaveSwitch and related switches return their state directly from device.switches.*
custom_components/delonghi_primadonna/select.py
custom_components/delonghi_primadonna/switch.py
custom_components/delonghi_primadonna/device.py
Improve beverage handling to be recipe-aware and decouple from the static AvailableBeverage enum.
  • Introduce BEVERAGE_NONE constant and use it instead of the NONE enum member
  • Derive model name and image_url from get_machine_model and build a dynamic recipe-based available_beverages list
  • Map recipe IDs to legacy beverage commands and add _build_start_command/_build_stop_command helpers
  • Change beverage_start/beverage_cancel to accept/be driven by beverage names and route via recipe map or legacy commands
  • Update service schema and BeverageSelect to use device.available_beverages and persist last selection if still valid
custom_components/delonghi_primadonna/const.py
custom_components/delonghi_primadonna/device.py
custom_components/delonghi_primadonna/select.py
custom_components/delonghi_primadonna/__init__.py
Adjust statistics parsing and sensors, including proper "coffee with milk" total and robust BLE polling.
  • Extend _parse_statistics to compute combined coffee-with-milk total (-3001) from 3001 + 3003
  • Continue computing total coffee (-3077) and water liters (10106) with minor refactor for readability
  • Change statistics update loop to iterate ranges inside try/except so a failed chunk doesn’t block others
  • Expose new -3001 stat via DelongiPrimadonnaStatisticsSensor and keep other statistics sensors but reformat
  • Rework statistics sensor state restoration to always restore numeric native_value and keep async_update delegating to throttled device.update_statistics
custom_components/delonghi_primadonna/device.py
custom_components/delonghi_primadonna/sensor.py
Minor protocol/UX cleanups and bug fixes in monitor and switch parsing and status reporting.
  • Fix parse_switches bit offsets and mask usage to match real monitor packets
  • Normalize MonitorData parsing formatting and comments without changing behavior
  • Make DEVICE_STATUS strings human-readable and default device.status to "Ready" so sensor icon can reflect OK vs alarm
  • Update the main status sensor icon to switch between thumb-up and alert-circle based on device.status
  • Ensure image entity has image_last_updated set when image_url is determined
custom_components/delonghi_primadonna/machine_switch.py
custom_components/delonghi_primadonna/device.py
custom_components/delonghi_primadonna/const.py
custom_components/delonghi_primadonna/sensor.py
custom_components/delonghi_primadonna/image.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot 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.

Hey - I've found 1 issue, and left some high level feedback:

  • In update_statistics, the broad except Exception: pass around get_statistics calls will silently hide protocol or parsing issues; consider at least logging the exception (with the start/count range) at debug level so intermittent failures can be diagnosed.
  • The fixed asyncio.sleep(0.3) delays in _read_settings and before reading the active profile are effectively magic timing constants; it would be helpful to centralize or document why 300 ms is required here and whether it should be adjustable for different devices/connection conditions.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `update_statistics`, the broad `except Exception: pass` around `get_statistics` calls will silently hide protocol or parsing issues; consider at least logging the exception (with the start/count range) at debug level so intermittent failures can be diagnosed.
- The fixed `asyncio.sleep(0.3)` delays in `_read_settings` and before reading the active profile are effectively magic timing constants; it would be helpful to centralize or document why 300 ms is required here and whether it should be adjustable for different devices/connection conditions.

## Individual Comments

### Comment 1
<location path="custom_components/delonghi_primadonna/device.py" line_range="954-960" />
<code_context>
-            
-            # Optional: Request tea/other beverages if needed
-            # await self.get_statistics(3025, 1)  # Tea counter
+            for start, count in [
+                (100, 10), (110, 10), (3000, 10), (3077, 4),
+            ]:
+                try:
+                    await self.get_statistics(start, count)
+                    await asyncio.sleep(0.3)
+                except Exception:  # noqa: BLE001
+                    pass
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Swallowing all exceptions when fetching statistics can hide real issues and make debugging harder.

The `update_statistics` loop catches `Exception` and then `pass`es, so repeated failures (e.g. protocol or transport issues) will never be visible and statistics may remain stale. Please either log the exception (including the `start`/`count` range) or restrict the `except` to the specific, expected error types.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread custom_components/delonghi_primadonna/device.py Outdated
Peter Kovaľ added 6 commits April 17, 2026 11:12
Release the BLE connection after completing each operation batch
(statistics update, settings read, beverage commands, etc.) instead
of maintaining a persistent connection. This prevents the DeLonghi
integration from blocking other BLE integrations (Sonicare, Tuya,
Shelly, etc.) from connecting.

- Add disconnect() call after update_statistics, get_device_name,
  and all individual command methods
- Change device_tracker to use BLE advertisement detection instead
  of persistent connection polling
- Log failed statistics requests at debug level with start/count
  range instead of silently swallowing exceptions
- Extract BLE_CMD_DELAY constant (0.3s) to document and centralize
  the inter-command delay required by the machine
Return restored state as fallback when the device statistics dict
has not yet been populated (machine off or BLE unreachable).
Previously native_value always returned None when stats were empty,
causing sensors to show "unknown" despite having a valid restored state.
The connected flag is always False now since we disconnect after
each operation. Remove the guard so update_statistics is triggered
on every sensor poll cycle (throttled internally to 60 seconds).
- Replace raw BleakClient with establish_connection() from
  bleak_retry_connector for proper BLE slot management
- Add SCAN_INTERVAL = 120s to sensor.py to reduce adapter
  contention with other BLE integrations (was default 30s)

These changes fix BLE adapter conflicts when running alongside
other BLE integrations (e.g. Philips Sonicare, Tuya).
@Arbuzov

Arbuzov commented May 14, 2026

Copy link
Copy Markdown
Owner

Ready to merge after the conflict resolution

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.

2 participants