Skip to content

Add request deduplication support to Shelly emulator#333

Merged
tomquist merged 3 commits intodevelopfrom
claude/expose-homeassistant-configs-Y9EMu
Apr 19, 2026
Merged

Add request deduplication support to Shelly emulator#333
tomquist merged 3 commits intodevelopfrom
claude/expose-homeassistant-configs-Y9EMu

Conversation

@tomquist
Copy link
Copy Markdown
Owner

@tomquist tomquist commented Apr 19, 2026

Summary

Refactored request deduplication logic into a reusable RequestDeduplicator class and extended deduplication support to the Shelly emulator. Previously, deduplication was only available for CT002/CT003 devices and was hardcoded to use source IP addresses. Now both device types support configurable deduplication windows, with CT002/CT003 keyed by consumer ID and Shelly keyed by battery IP.

Key Changes

  • New RequestDeduplicator class (src/astrameter/request_dedupe.py):

    • Generic, reusable deduplication utility that tracks requests by key within a configurable time window
    • Supports dependency injection of clock for testability
    • Includes purge_older_than() method to clean up stale entries
    • Window of 0 disables deduplication entirely
  • CT002 refactoring (src/astrameter/ct002/ct002.py):

    • Replaced manual _last_response_time tracking with RequestDeduplicator instance
    • Changed deduplication key from source address to consumer ID (more semantically correct for multi-consumer scenarios)
    • Updated cleanup logic to use purge_older_than() instead of manual stale entry removal
  • Shelly deduplication (src/astrameter/shelly/shelly.py):

    • Added dedupe_time_window parameter to Shelly.__init__()
    • Implemented deduplication check keyed by battery IP address (first element of source address tuple)
    • Drops duplicate requests within the configured window
  • Configuration updates (src/astrameter/main.py):

    • Added global DEDUPE_TIME_WINDOW setting under [GENERAL] section
    • Shelly devices now respect the global deduplication window setting
    • CT002/CT003 devices can still override with section-specific settings
  • Test coverage:

    • Added comprehensive unit tests for RequestDeduplicator with fake clock injection
    • Added integration test for Shelly deduplication behavior
    • Added unit tests for CT002 deduplication with injected clock
  • Documentation and configuration:

    • Updated config.ini.example with DEDUPE_TIME_WINDOW documentation
    • Updated README.md to document Shelly deduplication support
    • Added Home Assistant add-on configuration schema and translations for the new setting

Implementation Details

  • The RequestDeduplicator uses a simple dictionary to track the last seen timestamp for each key
  • Time window comparison uses (now - last) < window to determine if a request should be dropped
  • The clock is injectable via constructor parameter, enabling deterministic testing without mocking time.monotonic
  • Deduplication is applied at the protocol handler level before any powermeter queries occur

https://claude.ai/code/session_01YZtwVEn4bic9TtfqLmDaZS

Summary by CodeRabbit

Release Notes

  • New Features

    • Added DEDUPE_TIME_WINDOW configuration option to suppress repeated requests from the same source within a configurable time window.
    • Added Home Assistant addon configuration options: smooth_target_alpha, max_smooth_step, and deadband.
  • Documentation

    • Updated configuration guide and examples to document new deduplication and smoothing options across all device types.

Add a shared RequestDeduplicator helper and wire it into both the CT002
emulator (keyed by parsed consumer id) and the Shelly emulator (keyed
by battery IP, since Shelly sources use ephemeral ports). DEDUPE_TIME_WINDOW
can now be set under [GENERAL] to apply regardless of which device type
is emulated; the existing [CT002]/[CT003] override still wins when
present.

Surface dedupe_time_window, smooth_target_alpha, max_smooth_step, and
deadband in the HA add-on UI so users don't need a custom config file
for common tuning.

https://claude.ai/code/session_01YZtwVEn4bic9TtfqLmDaZS
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Warning

Rate limit exceeded

@tomquist has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 42 minutes and 2 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 42 minutes and 2 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ece16985-1734-4537-b690-6145801aba9a

📥 Commits

Reviewing files that changed from the base of the PR and between f51da7d and 8e0c437.

📒 Files selected for processing (14)
  • README.md
  • src/astrameter/ct002/balancer.py
  • src/astrameter/ct002/ct002.py
  • src/astrameter/main.py
  • src/astrameter/request_dedupe.py
  • src/astrameter/request_dedupe_test.py
  • src/astrameter/shelly/shelly.py
  • src/astrameter/shelly/shelly_udp_test.py
  • src/astrameter/web_config.py
  • tests/smoke_efficiency_saturation.py
  • tests/test_balancer_probe_lockup.py
  • tests/test_ct002_active_control.py
  • tests/test_e2e_probe_lockup.py
  • tests/test_efficiency_e2e.py

Walkthrough

This PR introduces a generic request deduplication mechanism that drops burst-repeat requests from the same source within a configured time window. A new RequestDeduplicator utility class is added and applied to CT002 (keyed by consumer ID) and Shelly (keyed by battery IP), with global and per-device DEDUPE_TIME_WINDOW configuration options and comprehensive test coverage.

Changes

Cohort / File(s) Summary
Configuration Documentation
CHANGELOG.md, README.md, config.ini.example
Added and updated DEDUPE_TIME_WINDOW documentation across changelog, readme, and example config; clarified consumer identity terminology and [GENERAL] section placement.
Home Assistant Add-on Integration
ha_addon/config.yaml, ha_addon/translations/en.yaml, ha_addon/run.sh
Extended add-on configuration schema with four new optional float parameters (dedupe_time_window, smooth_target_alpha, max_smooth_step, deadband); added translation descriptions; modified run.sh to conditionally emit these config keys into generated config.ini.
Request Deduplication Core
src/astrameter/request_dedupe.py, src/astrameter/request_dedupe_test.py
Introduced new generic RequestDeduplicator[K] class accepting window duration and optional clock; implements should_process(key) to suppress requests within window and purge_older_than(max_age_seconds) for cleanup; includes comprehensive test coverage for window behavior and purging.
CT002 Device Deduplication
src/astrameter/ct002/ct002.py, src/astrameter/ct002/ct002_test.py
Replaced addr-based request deduplication with RequestDeduplicator[str] keyed by consumer_id; updated cleanup logic to call purge_older_than; added deterministic tests using FakeClock to verify dedup behavior per consumer and zero-window edge case.
Shelly Device Deduplication
src/astrameter/shelly/shelly.py, src/astrameter/shelly/shelly_udp_test.py
Added RequestDeduplicator[str] instance keyed by source IP address; inserted early-exit guard in _handle_request before decoding; integrated periodic purging in _inactive_check_loop; added async test verifying dedup window rejection and acceptance after window elapse.
Global Configuration Propagation
src/astrameter/main.py, src/astrameter/web_config.py
Modified run_device to read global GENERAL.DEDUPE_TIME_WINDOW (default 0.0) and propagate to CT002/CT003 and Shelly device constructors; added DEDUPE_TIME_WINDOW float parameter with min value 0 to SECTION_KEY_TYPES.

Sequence Diagram

sequenceDiagram
    participant Client as UDP/HTTP Client
    participant Handler as Device Handler<br/>(CT002/Shelly)
    participant Dedup as RequestDeduplicator
    participant Clock as Clock
    participant Device as Device Logic

    Client->>Handler: Request arrives
    Handler->>Dedup: should_process(source_id)
    Dedup->>Clock: Get current time
    Clock-->>Dedup: time_value
    
    alt Within Dedupe Window
        Dedup-->>Handler: false
        Handler->>Handler: Log suppressed request
        Handler-->>Client: (no response)
    else Outside Dedupe Window
        Dedup->>Clock: Get current time
        Clock-->>Dedup: time_value
        Dedup->>Dedup: Update last_seen[source_id]
        Dedup-->>Handler: true
        Handler->>Device: Process request
        Device-->>Client: Response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: adding request deduplication support to the Shelly emulator, which is the main feature introduced across multiple files and modules.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/expose-homeassistant-configs-Y9EMu

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tomquist tomquist marked this pull request as ready for review April 19, 2026 21:13
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
src/astrameter/request_dedupe_test.py (1)

1-60: Test coverage is solid.

Window=0 disabled path, within-window drop, post-window accept, key independence, purge semantics, and empty-purge safety are all covered. The FakeClock callable is a clean way to drive the injected clock.

One optional addition: a test confirming that a dropped request does not refresh the "last accepted" timestamp (i.e., window is measured from last accept, not last attempt) — this is an observable behavior worth locking in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/astrameter/request_dedupe_test.py` around lines 1 - 60, Add a test that
verifies a dropped request does not update the last-accepted timestamp: create a
FakeClock and RequestDeduplicator, call should_process("k") to accept once,
advance clock less than the window and call should_process("k") expecting False,
then advance the clock so that if the failed attempt had refreshed the timestamp
it would still be blocked but because it shouldn't refresh the second call the
third call after enough time should return True; reference RequestDeduplicator
and its should_process method and use FakeClock to advance time and assert the
expected True/False sequence.
src/astrameter/ct002/ct002.py (1)

146-148: Nit: redundant clock fallback.

RequestDeduplicator already defaults clock to time.monotonic when None. Passing clock or time.time here overrides that default with wall-clock time to stay consistent with the rest of CT002 (e.g., _cleanup_consumers uses time.time()), which is fine and intentional — just worth a short comment so future readers don't "fix" it to clock only and inadvertently mix monotonic/wall-clock time sources between _dedup.purge_older_than (clock) and _cleanup_consumers (time.time).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/astrameter/ct002/ct002.py` around lines 146 - 148, Add a short clarifying
comment where self._dedup is constructed to explain that passing clock or
time.time intentionally overrides RequestDeduplicator's default monotonic clock
so the deduplicator uses wall-clock time to stay consistent with the rest of
CT002 (e.g., _cleanup_consumers uses time.time()), preventing future maintainers
from changing it back to the default and mixing monotonic vs wall-clock for
_dedup.purge_older_than and _cleanup_consumers.
src/astrameter/request_dedupe.py (1)

10-41: Clean, focused utility — LGTM.

A couple of minor notes worth considering (not blockers):

  • Unbounded growth by design. _last only shrinks via purge_older_than. Both current callers (CT002 via _cleanup_consumers, Shelly per PR context) purge periodically, but it's worth a docstring note so future callers don't forget to call purge_older_than and leak memory on high-cardinality keys.
  • NaN window. max(0.0, float('nan')) is not well-defined (NaN comparisons are all False), so a NaN window_seconds could slip past the clamp. Unlikely from config in practice; an explicit if not math.isfinite(window_seconds) or window_seconds < 0: ... = 0.0 would be more defensive.
  • Dropped requests don't refresh the timestamp, so the window is measured from the last accepted request, not the last seen one. This is a reasonable choice (prevents a sustained burst from indefinitely extending suppression) — worth one line in the docstring to make it explicit for callers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/astrameter/request_dedupe.py` around lines 10 - 41, Update
RequestDeduplicator to (1) defensively handle NaN/inf window_seconds in
__init__: treat non-finite or negative values as 0.0 (use math.isfinite and
comparison) and assign to self._window; (2) document two behaviors in the class
docstring: that _last is unbounded unless callers invoke purge_older_than (so
callers should schedule periodic purges) and that should_process measures the
window from the last accepted request (dropped requests do not refresh the
timestamp). Refer to the RequestDeduplicator class, its __init__,
should_process, purge_older_than and the _last/_window attributes when making
these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ha_addon/config.yaml`:
- Around line 61-62: The config schema uses float types for watt-based CT fields
(max_smooth_step and deadband) but the runtime (src/astrameter/main.py) reads
DEADBAND with cfg.getint(...) which will break on fractional values; change the
UI/schema types for the DEADBAND and max_smooth_step keys to integer (or
otherwise match the runtime parser) so values like 20.5 are rejected and only
integers are allowed, and keep the schema name references (deadband,
max_smooth_step, and the runtime symbol DEADBAND / the cfg.getint call in
src/astrameter/main.py) aligned.

In `@ha_addon/run.sh`:
- Around line 149-157: The script in run.sh currently emits SMOOTH_TARGET_ALPHA,
MAX_SMOOTH_STEP and DEADBAND under the [HOMEASSISTANT] block, but
src/astrameter/main.py reads DEADBAND from the CT section (ct_section), so move
these three CT tuning variables into the CT section output instead of
HOMEASSISTANT: when constructing the config for each CT (the same place you
print the CT section header like “[CT002]”/“[CT003]” or use the ct_section
variable), emit lines "SMOOTH_TARGET_ALPHA=…", "MAX_SMOOTH_STEP=…", and
"DEADBAND=…" there (only if bashio::config.has_value for each), so the values
are available to the code that reads ct_section in src/astrameter/main.py.

In `@README.md`:
- Around line 306-309: The README's configuration examples are missing the
DEDUPE_TIME_WINDOW key in the [GENERAL] block; update the example so
DEDUPE_TIME_WINDOW appears under [GENERAL] (not only under [CT002]) so Shelly
users can apply the global dedupe window. Edit the README sample config to add
the DEDUPE_TIME_WINDOW setting in the [GENERAL] section with a brief comment
mirroring the existing description, ensuring the key name DEDUPE_TIME_WINDOW and
the bracketed section labels [GENERAL] and [CT002] are present for clarity.

In `@src/astrameter/shelly/shelly.py`:
- Line 54: The deduper is currently initialized as
RequestDeduplicator(dedupe_time_window) but later a cleanup loop purges entries
older than BATTERY_INACTIVE_TIMEOUT_SECONDS which can truncate any
DEDUPE_TIME_WINDOW > 120; update the implementation so the configured
dedupe_time_window is stored (e.g. self._dedupe_time_window) and pass the full
configured window into RequestDeduplicator or adjust the cleanup logic to use
max(BATTERY_INACTIVE_TIMEOUT_SECONDS, self._dedupe_time_window) when purging;
locate usages of RequestDeduplicator, self._dedup, dedupe_time_window and the
cleanup loop that references BATTERY_INACTIVE_TIMEOUT_SECONDS and change the
purge horizon to the larger retention value so configured windows >120s are
honored.

---

Nitpick comments:
In `@src/astrameter/ct002/ct002.py`:
- Around line 146-148: Add a short clarifying comment where self._dedup is
constructed to explain that passing clock or time.time intentionally overrides
RequestDeduplicator's default monotonic clock so the deduplicator uses
wall-clock time to stay consistent with the rest of CT002 (e.g.,
_cleanup_consumers uses time.time()), preventing future maintainers from
changing it back to the default and mixing monotonic vs wall-clock for
_dedup.purge_older_than and _cleanup_consumers.

In `@src/astrameter/request_dedupe_test.py`:
- Around line 1-60: Add a test that verifies a dropped request does not update
the last-accepted timestamp: create a FakeClock and RequestDeduplicator, call
should_process("k") to accept once, advance clock less than the window and call
should_process("k") expecting False, then advance the clock so that if the
failed attempt had refreshed the timestamp it would still be blocked but because
it shouldn't refresh the second call the third call after enough time should
return True; reference RequestDeduplicator and its should_process method and use
FakeClock to advance time and assert the expected True/False sequence.

In `@src/astrameter/request_dedupe.py`:
- Around line 10-41: Update RequestDeduplicator to (1) defensively handle
NaN/inf window_seconds in __init__: treat non-finite or negative values as 0.0
(use math.isfinite and comparison) and assign to self._window; (2) document two
behaviors in the class docstring: that _last is unbounded unless callers invoke
purge_older_than (so callers should schedule periodic purges) and that
should_process measures the window from the last accepted request (dropped
requests do not refresh the timestamp). Refer to the RequestDeduplicator class,
its __init__, should_process, purge_older_than and the _last/_window attributes
when making these changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9205694e-55dd-452f-90e4-4e3f2aa5a239

📥 Commits

Reviewing files that changed from the base of the PR and between a886dbd and f51da7d.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • README.md
  • config.ini.example
  • ha_addon/config.yaml
  • ha_addon/run.sh
  • ha_addon/translations/en.yaml
  • src/astrameter/ct002/ct002.py
  • src/astrameter/ct002/ct002_test.py
  • src/astrameter/main.py
  • src/astrameter/request_dedupe.py
  • src/astrameter/request_dedupe_test.py
  • src/astrameter/shelly/shelly.py
  • src/astrameter/shelly/shelly_udp_test.py
  • src/astrameter/web_config.py

Comment thread ha_addon/config.yaml
Comment thread ha_addon/run.sh
Comment thread README.md
Comment thread src/astrameter/shelly/shelly.py Outdated
claude added 2 commits April 19, 2026 21:23
- Make the Shelly dedup test deterministic by driving _handle_request
  directly with a fake transport and a fake clock injected into the
  deduplicator; no more real UDP sockets or sleep-based timing.
- Honor DEDUPE_TIME_WINDOW values greater than the 120s Shelly
  battery-inactivity horizon by purging with max(horizon, window).
- Clarify in CT002 why the deduplicator is constructed with time.time
  instead of defaulting to time.monotonic (shares a timebase with
  _cleanup_consumers).
- RequestDeduplicator: treat non-finite and negative windows as
  disabled, and document the "window-from-last-accepted" semantic and
  the caller responsibility to purge.
- Add tests for the new guards and for "dropped request must not
  refresh the timestamp."
- Add DEDUPE_TIME_WINDOW to the README [GENERAL] example so Shelly
  users see it without hunting through the CT section.

https://claude.ai/code/session_01YZtwVEn4bic9TtfqLmDaZS
BalancerConfig.deadband was wired through main.py -> CT002 -> BalancerConfig
but never actually read anywhere; the only "deadband" semantics that still
matter are BALANCE_DEADBAND (multi-battery imbalance floor) and the
per-powermeter DeadbandPowermeter wrapper. Drop the dead field, its clamp,
the constructor parameter, and the corresponding cfg.getint read. Purge
deadband=... from all BalancerConfig test constructions.

While here, fix two related mis-placements that were misleading users:
- web_config.py listed SMOOTH_TARGET_ALPHA / MAX_SMOOTH_STEP / DEADBAND
  under the [CT002] schema, but the runtime only reads them from
  powermeter sections or [GENERAL] (config_loader.py:161-252). Move them
  to GENERAL.
- README described the same three keys as "CT002/CT003 active-steering"
  options. They are powermeter-wrapper options. Move them into the
  per-powermeter options list alongside THROTTLE_INTERVAL.

https://claude.ai/code/session_01YZtwVEn4bic9TtfqLmDaZS
@tomquist tomquist merged commit ea5b628 into develop Apr 19, 2026
13 checks passed
@tomquist tomquist deleted the claude/expose-homeassistant-configs-Y9EMu branch April 19, 2026 21:35
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