Skip to content

Use timeout-triggered reconnect with command queue and replay - #2001

Open
Mark-Hetherington wants to merge 1 commit into
lbbrhzn:mainfrom
Mark-Hetherington:feat/timeout-reconnect-queue
Open

Use timeout-triggered reconnect with command queue and replay#2001
Mark-Hetherington wants to merge 1 commit into
lbbrhzn:mainfrom
Mark-Hetherington:feat/timeout-reconnect-queue

Conversation

@Mark-Hetherington

@Mark-Hetherington Mark-Hetherington commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

On asyncio.TimeoutError place failed commands in queue. Newer commands replace commands of the same type. On reconnect replay queued commands

Changes:

  • New CommandQueue class with FIFO ordering and coalescing logic
  • New _call_with_timeout_handling() wrapper in ChargePoint to catch timeouts
  • Modified reconnect() to drain queue via _replay_queue() before re-establishing connection
  • Add _command_queue instance to ChargePoint.init

Integration:

  • ocppv16.py: wrap set_charge_rate() (4 calls) and set_availability() with timeout handler
  • ocppv201.py: wrap set_charge_rate() (2 calls), set_availability() (3 calls), start_transaction(), and stop_transaction() with timeout handler
  • Add asyncio import to ocppv16.py for exception handling

Fixes: #1999

Summary by CodeRabbit

  • New Features

    • Added a per–ChargePoint FIFO command queue to preserve timed-out OCPP calls and replay them after reconnection.
    • Enhanced timeout-aware handling for charging profile updates, availability changes, and remote start/stop actions.
  • Bug Fixes

    • Prevented missed OCPP requests by queueing failed calls on timeout, then replaying them sequentially after reconnect.
    • Improved command coalescing for charging profile updates using charging-profile purpose to avoid incorrectly dropping distinct requests.
  • Tests

    • Added async pytest coverage for queue order, coalescing rules, and timeout-to-replay behavior.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a FIFO OCPP command queue that captures timed-out calls, coalesces selected commands, triggers reconnects, and replays queued commands. OCPP 1.6 and 2.0.1 request paths now use shared timeout handling with coverage for queue and reconnect behavior.

Changes

Timeout queue and replay

Layer / File(s) Summary
Command queue contract and coalescing
custom_components/ocpp/command_queue.py, tests/test_ocpp_timeout_and_queue.py
Queued commands preserve call metadata and FIFO order; selected command types coalesce by connector and, for charging profiles, profile purpose. Queue draining, clearing, and coalescing are tested.
ChargePoint timeout and reconnect replay
custom_components/ocpp/chargepoint.py, tests/test_ocpp_timeout_and_queue.py
Timed-out calls are queued and replayed sequentially after reconnection, with reconnect task handling and cleanup updated. Timeout enqueue behavior is tested.
OCPP protocol call wiring
custom_components/ocpp/ocppv16.py, custom_components/ocpp/ocppv201.py
Charging-profile, availability, and transaction requests use _call_with_timeout_handling with call type and connector metadata.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChargePoint
  participant CommandQueue
  participant ChargerConnection
  ChargePoint->>ChargerConnection: call(request)
  ChargerConnection-->>ChargePoint: asyncio.TimeoutError
  ChargePoint->>CommandQueue: enqueue(QueuedCommand)
  ChargePoint->>ChargerConnection: reconnect()
  ChargePoint->>CommandQueue: dequeue_all()
  ChargePoint->>CommandQueue: QueuedCommand.execute()
Loading

Possibly related PRs

  • lbbrhzn/ocpp#2000: Adds overlapping exponential-backoff retry handling for SetChargingProfile calls.
  • lbbrhzn/ocpp#1708: Also modifies charging-profile and connector handling in OCPP 1.6.
  • lbbrhzn/ocpp#1718: Also modifies the OCPP 1.6 set_charge_rate charging-profile flow.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR handles SetChargingProfile timeouts with queue/replay, but it does not implement the requested exponential backoff or broader transient-exception retry behavior [#1999]. Add retry logic with exponential backoff for transient timeouts/exceptions and keep immediate failure for active charger rejections.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately reflects the main change: timeout-triggered reconnect with queued command replay.
Out of Scope Changes check ✅ Passed All code changes and tests stay focused on timeout handling, command queuing, replay, and related OCPP call paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@Mark-Hetherington
Mark-Hetherington force-pushed the feat/timeout-reconnect-queue branch from 6a0c036 to 4d3aa2a Compare July 24, 2026 23:41
@Mark-Hetherington
Mark-Hetherington force-pushed the feat/timeout-reconnect-queue branch from 4d3aa2a to d96b48e Compare July 24, 2026 23:42

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🧹 Nitpick comments (1)
tests/test_ocpp_timeout_and_queue.py (1)

139-171: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise the reconnect-and-replay path.

This test verifies queueing only; it never calls reconnect() or executes the queued command. Add coverage for a first-call timeout followed by a successful replay, plus a replay timeout that remains queued for the next reconnect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_ocpp_timeout_and_queue.py` around lines 139 - 171, Extend
test_timeout_triggers_reconnect to invoke reconnect() after the initial timeout
and execute the queued command with a successful mocked call, asserting the
replay completes and the queue is emptied; then simulate a timeout during
replay, assert it raises and remains queued for the next reconnect. Preserve the
existing stop assertion and use the ChargePoint reconnect-and-queue symbols
already exercised by the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/ocpp/chargepoint.py`:
- Around line 614-621: Update the reconnect handling around
_reconnect_in_progress so a timeout for a command queued for replay does not
propagate as the normal terminal failure. Return or raise the established
distinct queued/pending outcome, and ensure OCPP 1.6 callers handle that outcome
without returning False or sending a permanent-failure notification.
- Around line 574-580: Remove the final _command_queue.clear() call from the
command replay flow after dequeue_all() and command execution. Preserve commands
requeued by _call_with_timeout_handling() or added concurrently while replaying,
while retaining the existing dequeue and execution behavior.
- Line 631: Update reconnect() so super().start() activates the OCPP receiver
before _replay_queue() processes queued commands. Run the replay only after the
receiver is active, using a separate task or equivalent sequencing, while
preserving queued command execution through self.call().

In `@custom_components/ocpp/command_queue.py`:
- Around line 41-47: Update the coalescing logic in the command queue’s
call_type handling to distinguish charging-profile commands by their profile
purpose, preserving separate TxProfile and TxDefaultProfile entries for the same
connector. Extend the coalescing key with the relevant purpose field while
retaining coalescing for identical command types, connectors, and purposes.

In `@custom_components/ocpp/ocppv16.py`:
- Around line 423-425: Update start_transaction() and stop_transaction() so
their RemoteStartTransaction and RemoteStopTransaction requests use
_call_with_timeout_handling() instead of self.call(), passing the appropriate
connector_id metadata as done for SetChargingProfile. Preserve each method’s
existing request and response handling.

---

Nitpick comments:
In `@tests/test_ocpp_timeout_and_queue.py`:
- Around line 139-171: Extend test_timeout_triggers_reconnect to invoke
reconnect() after the initial timeout and execute the queued command with a
successful mocked call, asserting the replay completes and the queue is emptied;
then simulate a timeout during replay, assert it raises and remains queued for
the next reconnect. Preserve the existing stop assertion and use the ChargePoint
reconnect-and-queue symbols already exercised by the test.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 53fbfb98-1a82-421c-acda-4539bcb160f2

📥 Commits

Reviewing files that changed from the base of the PR and between c61c1f4 and 6a0c036.

📒 Files selected for processing (5)
  • custom_components/ocpp/chargepoint.py
  • custom_components/ocpp/command_queue.py
  • custom_components/ocpp/ocppv16.py
  • custom_components/ocpp/ocppv201.py
  • tests/test_ocpp_timeout_and_queue.py

Comment thread custom_components/ocpp/chargepoint.py Outdated
Comment thread custom_components/ocpp/chargepoint.py Outdated
Comment on lines +614 to +621
if not self._reconnect_in_progress:
self._reconnect_in_progress = True
try:
await self.stop()
finally:
self._reconnect_in_progress = False

raise

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not expose queued timeouts as terminal failures.

After queueing, Line 621 re-raises the timeout. OCPP 1.6 callers then return False and may notify a failure, even though the command is pending replay. Return or raise a distinct queued/pending outcome and handle it without a permanent-failure notification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/chargepoint.py` around lines 614 - 621, Update the
reconnect handling around _reconnect_in_progress so a timeout for a command
queued for replay does not propagate as the normal terminal failure. Return or
raise the established distinct queued/pending outcome, and ensure OCPP 1.6
callers handle that outcome without returning False or sending a
permanent-failure notification.

Comment thread custom_components/ocpp/chargepoint.py Outdated
Comment thread custom_components/ocpp/command_queue.py Outdated
Comment thread custom_components/ocpp/ocppv16.py
@Mark-Hetherington
Mark-Hetherington force-pushed the feat/timeout-reconnect-queue branch from d96b48e to af1d5ed Compare July 24, 2026 23:47

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
custom_components/ocpp/command_queue.py (1)

41-54: 🎯 Functional Correctness | 🟠 Major

Preserve charging-profile purpose when coalescing queued commands.

The queue currently treats TxProfile and TxDefaultProfile as interchangeable when they target the same connector, so replay can silently lose an active-session charging update.

  • custom_components/ocpp/command_queue.py#L41-L54: include charging-profile purpose in the coalescing key.
  • custom_components/ocpp/ocppv16.py#L423-L425: pass purpose metadata for custom profiles.
  • custom_components/ocpp/ocppv16.py#L544-L546: identify this request as TxProfile.
  • custom_components/ocpp/ocppv16.py#L571-L573: identify this request as TxDefaultProfile.
  • tests/test_ocpp_timeout_and_queue.py#L44-L74: test same-purpose replacement and different-purpose retention.
  • tests/test_ocpp_timeout_and_queue.py#L175-L209: update current-value coalescing fixtures with explicit purpose metadata.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/command_queue.py` around lines 41 - 54, The command
queue must coalesce charging-profile commands by both connector and profile
purpose, so update the filtering logic around the queue coalescing block in
custom_components/ocpp/command_queue.py. In custom_components/ocpp/ocppv16.py,
pass purpose metadata for custom profiles and mark the relevant requests as
TxProfile or TxDefaultProfile. Update tests/test_ocpp_timeout_and_queue.py to
verify same-purpose replacement, different-purpose retention, and explicit
purpose metadata in current-value fixtures.
🧹 Nitpick comments (4)
custom_components/ocpp/command_queue.py (1)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use collections.abc.Callable for the annotation.

Ruff UP035 flags the typing.Callable import; update it, or verify that the repository’s target/lint configuration intentionally permits the alias.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/command_queue.py` at line 5, Update the Callable
import in command_queue.py to use collections.abc.Callable instead of
typing.Callable, unless the repository configuration explicitly permits the
typing alias; keep the existing annotations and Any import unchanged.

Source: Linters/SAST tools

custom_components/ocpp/ocppv16.py (1)

605-605: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use one timeout exception spelling consistently.

Ruff UP041 flags asyncio.TimeoutError; replace it with builtin TimeoutError where the declared Python target supports it, or verify the target and lint configuration before changing both sites.

  • custom_components/ocpp/ocppv16.py#L605-L605: update the production exception handler.
  • tests/test_ocpp_timeout_and_queue.py#L149-L162: update the timeout fixture and assertion.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/ocppv16.py` at line 605, Use the builtin TimeoutError
consistently for the production handler in custom_components/ocpp/ocppv16.py
(605-605) and the timeout fixture and assertion in
tests/test_ocpp_timeout_and_queue.py (149-162), after confirming the declared
Python target and lint configuration support it.

Source: Linters/SAST tools

tests/test_ocpp_timeout_and_queue.py (1)

137-173: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover replay, not only enqueue.

This test verifies that a timeout queues a command and triggers stop(), but never exercises QueuedCommand.execute() or reconnect draining. Add coverage for FIFO replay, successful execution after reconnect, requeue behavior on a second timeout, and suppression of duplicate stop() calls while reconnect is already in progress.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_ocpp_timeout_and_queue.py` around lines 137 - 173, Expand
TestTimeoutHandling.test_timeout_triggers_reconnect or add focused tests that
invoke QueuedCommand.execute() during reconnect draining, verifying queued
commands replay in FIFO order and complete successfully after reconnect. Also
cover a second timeout requeueing the command and ensure
_call_with_timeout_handling does not call stop() again when
_reconnect_in_progress is already true.
custom_components/ocpp/chargepoint.py (1)

599-599: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist QueuedCommand import to module level.

CommandQueue is already imported at the top of the file (Line 39); import QueuedCommand alongside it instead of re-importing inside the exception branch on every timeout.

Proposed fix
-from .command_queue import CommandQueue
+from .command_queue import CommandQueue, QueuedCommand
-            from .command_queue import QueuedCommand
-
             cmd = QueuedCommand(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/chargepoint.py` at line 599, Move the QueuedCommand
import from the timeout exception branch in the relevant chargepoint
command-handling method to the module-level imports, alongside CommandQueue.
Remove the now-redundant local import while preserving the existing
QueuedCommand usage and timeout behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/ocpp/chargepoint.py`:
- Around line 601-621: Update CommandQueue.enqueue’s command coalescing list to
include the OCPP 2.0.1 call types RequestStartTransaction and
RequestStopTransaction alongside the existing transaction start/stop commands,
so repeated timeouts replace stale queued replies rather than accumulating them.

---

Duplicate comments:
In `@custom_components/ocpp/command_queue.py`:
- Around line 41-54: The command queue must coalesce charging-profile commands
by both connector and profile purpose, so update the filtering logic around the
queue coalescing block in custom_components/ocpp/command_queue.py. In
custom_components/ocpp/ocppv16.py, pass purpose metadata for custom profiles and
mark the relevant requests as TxProfile or TxDefaultProfile. Update
tests/test_ocpp_timeout_and_queue.py to verify same-purpose replacement,
different-purpose retention, and explicit purpose metadata in current-value
fixtures.

---

Nitpick comments:
In `@custom_components/ocpp/chargepoint.py`:
- Line 599: Move the QueuedCommand import from the timeout exception branch in
the relevant chargepoint command-handling method to the module-level imports,
alongside CommandQueue. Remove the now-redundant local import while preserving
the existing QueuedCommand usage and timeout behavior.

In `@custom_components/ocpp/command_queue.py`:
- Line 5: Update the Callable import in command_queue.py to use
collections.abc.Callable instead of typing.Callable, unless the repository
configuration explicitly permits the typing alias; keep the existing annotations
and Any import unchanged.

In `@custom_components/ocpp/ocppv16.py`:
- Line 605: Use the builtin TimeoutError consistently for the production handler
in custom_components/ocpp/ocppv16.py (605-605) and the timeout fixture and
assertion in tests/test_ocpp_timeout_and_queue.py (149-162), after confirming
the declared Python target and lint configuration support it.

In `@tests/test_ocpp_timeout_and_queue.py`:
- Around line 137-173: Expand
TestTimeoutHandling.test_timeout_triggers_reconnect or add focused tests that
invoke QueuedCommand.execute() during reconnect draining, verifying queued
commands replay in FIFO order and complete successfully after reconnect. Also
cover a second timeout requeueing the command and ensure
_call_with_timeout_handling does not call stop() again when
_reconnect_in_progress is already true.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c7fed8e-c716-4c1e-be76-44b6843f70f9

📥 Commits

Reviewing files that changed from the base of the PR and between 6a0c036 and d96b48e.

📒 Files selected for processing (5)
  • custom_components/ocpp/chargepoint.py
  • custom_components/ocpp/command_queue.py
  • custom_components/ocpp/ocppv16.py
  • custom_components/ocpp/ocppv201.py
  • tests/test_ocpp_timeout_and_queue.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • custom_components/ocpp/ocppv201.py

Comment thread custom_components/ocpp/chargepoint.py
@Mark-Hetherington
Mark-Hetherington force-pushed the feat/timeout-reconnect-queue branch from af1d5ed to 0f410c7 Compare July 25, 2026 01:04

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

♻️ Duplicate comments (3)
custom_components/ocpp/chargepoint.py (3)

572-580: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Trailing clear() still wipes commands re-queued during replay.

dequeue_all() (line 574) already empties _command_queue. If a replayed cmd.execute() times out again, _call_with_timeout_handling() re-enqueues it — then line 580 immediately deletes it, along with anything enqueued concurrently while replay was running. This is unresolved from a prior review round.

Proposed fix
     async def _replay_queue(self) -> None:
         """Replay queued commands from last timeout/disconnect."""
         commands = await self._command_queue.dequeue_all()
         for cmd in commands:
             try:
                 await cmd.execute()
             except Exception as ex:
                 _LOGGER.warning("Replay of %s failed: %s", cmd.call_type, ex)
-        await self._command_queue.clear()

Given the ordering issue flagged below (replay runs before the receiver is listening), every replayed command will time out again on first reconnect attempt, get re-queued, and then be wiped by this clear() — meaning the whole retry mechanism this PR adds is currently a no-op after the first timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/chargepoint.py` around lines 572 - 580, The trailing
_command_queue.clear() in _replay_queue must be removed so commands re-enqueued
by cmd.execute() and commands added concurrently remain queued for later retry.
Preserve dequeue_all() and the existing replay loop/error logging.

623-633: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Replay still runs before the OCPP receive loop is active.

_replay_queue() (line 631) executes queued calls via self.call(), which awaits a response, but super().start() — the loop that reads incoming websocket frames and resolves those awaits — only starts afterward via self.run(...) (line 633). Every replayed command will therefore time out again (sequentially, compounding the queue's configured timeout per item) before the connection even starts listening. Combined with the trailing clear() issue above, replayed commands are effectively guaranteed to be lost. This is unresolved from a prior review round.

Proposed fix
         self._metrics[(0, cstat.reconnects.value)].value += 1
-        await self._replay_queue()
         # post connect now handled on receiving boot notification or with backstop in monitor connection
-        await self.run([super().start(), self.monitor_connection()])
+        await self.run(
+            [super().start(), self.monitor_connection(), self._replay_queue()]
+        )

Running replay as a gathered task alongside the receiver ensures responses can actually be matched to the queued calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/chargepoint.py` around lines 623 - 633, Update
reconnect so the OCPP receive loop from super().start() runs concurrently with
_replay_queue(), rather than awaiting the replay before starting the loop. Use
the existing self.run orchestration to start both tasks together, while
preserving monitor_connection and the current reconnect setup order.

619-621: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Queued timeout still surfaces as a terminal failure to callers.

After enqueuing for replay, line 621 re-raises the TimeoutError unchanged. OCPP 1.6/2.0.1 callers (e.g. set_availability, set_charge_rate) catch this and return False/notify failure, even though the command is pending replay. This is unresolved from a prior review round; a distinct queued/pending outcome is still needed so callers don't report a permanent failure for a command that will be retried.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/chargepoint.py` around lines 619 - 621, The timeout
path in the surrounding reconnect/replay logic must return a distinct queued or
pending outcome after successfully enqueuing the command, rather than re-raising
TimeoutError. Update the caller-facing handling used by OCPP 1.6/2.0.1
operations such as set_availability and set_charge_rate to recognize that
outcome without reporting permanent failure, while preserving exception
propagation for timeouts that are not queued.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/ocpp/chargepoint.py`:
- Around line 613-619: Update the reconnect/timeout handling around self.stop()
and the surrounding call() flow so connection failures from sibling in-flight
OCPP operations are caught and queued for replay before teardown, or ensure
stopping the connection does not cancel unrelated self.tasks. Preserve replay
behavior for both TimeoutError and connection-closed/WebSocketException-style
failures.

---

Duplicate comments:
In `@custom_components/ocpp/chargepoint.py`:
- Around line 572-580: The trailing _command_queue.clear() in _replay_queue must
be removed so commands re-enqueued by cmd.execute() and commands added
concurrently remain queued for later retry. Preserve dequeue_all() and the
existing replay loop/error logging.
- Around line 623-633: Update reconnect so the OCPP receive loop from
super().start() runs concurrently with _replay_queue(), rather than awaiting the
replay before starting the loop. Use the existing self.run orchestration to
start both tasks together, while preserving monitor_connection and the current
reconnect setup order.
- Around line 619-621: The timeout path in the surrounding reconnect/replay
logic must return a distinct queued or pending outcome after successfully
enqueuing the command, rather than re-raising TimeoutError. Update the
caller-facing handling used by OCPP 1.6/2.0.1 operations such as
set_availability and set_charge_rate to recognize that outcome without reporting
permanent failure, while preserving exception propagation for timeouts that are
not queued.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b4813901-f5e0-44f5-bae3-7d405286c864

📥 Commits

Reviewing files that changed from the base of the PR and between d96b48e and 0f410c7.

📒 Files selected for processing (5)
  • custom_components/ocpp/chargepoint.py
  • custom_components/ocpp/command_queue.py
  • custom_components/ocpp/ocppv16.py
  • custom_components/ocpp/ocppv201.py
  • tests/test_ocpp_timeout_and_queue.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • custom_components/ocpp/ocppv16.py
  • custom_components/ocpp/command_queue.py
  • custom_components/ocpp/ocppv201.py
  • tests/test_ocpp_timeout_and_queue.py

Comment thread custom_components/ocpp/chargepoint.py Outdated
Comment on lines +613 to +619

if not self._reconnect_in_progress:
self._reconnect_in_progress = True
try:
await self.stop()
finally:
self._reconnect_in_progress = False

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the installed ocpp library's call() implementation to confirm what
# exception type is raised when the underlying connection closes mid-await.
python - <<'PY'
try:
    import ocpp
    print(ocpp.__file__)
except Exception as e:
    print("ocpp not installed in sandbox:", e)
PY
fd -a 'charge_point\.py$' 2>/dev/null | xargs -r grep -n "async def call" -A 30

Repository: lbbrhzn/ocpp

Length of output: 204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository files matching chargepoint/ocpp =="
git ls-files | rg '(^|/)chargepoint\.py$|(^|/)charge_point\.py$|requirements.*|pyproject\.toml|setup\.cfg|setup\.py|README'

echo
echo "== chargepoint.py context around lines 560-660 =="
sed -n '560,660p' custom_components/ocpp/chargepoint.py | nl -ba -v560

echo
echo "== ocpp dependency versions == "
for f in requirements*.txt requirements/*.txt requirements/*.* requirements-custom.txt manifest.json pyproject.toml setup.py setup.cfg poetry.lock tasks.yaml; do
  [ -f "$f" ] && { echo "--- $f"; rg -n "ocpp|websockets" "$f" || true; }
done

echo
echo "== imports/usages of call_with_timeout_handling/tasks/_tasks/call =="
rg -n "_call_with_timeout_handling|call_with_timeout_handling|self\.tasks|self\._tasks|QueueExecutor|reconnect_in_progress|async def call\(|def call\(" custom_components/ocpp -C 3

Repository: lbbrhzn/ocpp

Length of output: 402


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== chargepoint.py context around timeout teardown =="
awk 'NR>=560 && NR<=680 {printf "%6d\t%s\n", NR, $0}' custom_components/ocpp/chargepoint.py

echo
echo "== queue/replay helpers and call definitions =="
awk 'NR>=1 && NR<=560 {printf "%6d\t%s\n", NR, $0}' custom_components/ocpp/chargepoint.py

echo
echo "== dependency files =="
sed -n '1,220p' requirements.txt
if [ -f custom_components/ocpp/manifest.json ]; then sed -n '1,220p' custom_components/ocpp/manifest.json; fi
if [ -f setup.cfg ]; then sed -n '1,220p' setup.cfg; fi

echo
echo "== exact dependency mentions =="
rg -n "ocpp|websockets" requirements.txt docs/requirements.txt custom_components/ocpp/manifest.json setup.cfg 2>/dev/null || true

Repository: lbbrhzn/ocpp

Length of output: 30941


🌐 Web query:

OCPP library v1.6 Python call function websocket exception raises when connection closed asyncio timeout

💡 Result:

When using the ocpp library, connection closures—whether due to network issues, keepalive timeouts, or graceful shutdowns—are commonly surfaced as websockets.exceptions.ConnectionClosed (or its subclasses, such as ConnectionClosedError) [1][2][3]. Because the ChargePoint.start method runs an internal loop that awaits incoming messages, these exceptions will propagate to your code if not caught, often leading to unhandled errors or application crashes [2][4]. To handle these exceptions, you must wrap the call to ChargePoint.start in a try/except block [1][2][4]. Handling Connection Closures: Wrap your start routine in the connection handler: async def on_connect(websocket): charge_point_id = websocket.path.strip("/") cp = ChargePoint(charge_point_id, websocket) try: await cp.start except websockets.exceptions.ConnectionClosed: # Handle the disconnection (e.g., log, cleanup, or trigger reconnection) print(f"Connection for {charge_point_id} closed.") finally: # Perform necessary cleanup pass If you are experiencing premature timeouts, it is often due to the websockets library's default keepalive mechanism, which sends pings every 20 seconds and expects a response within 20 seconds [3][5]. If the client (Charging Station) fails to respond, the server will raise a ConnectionClosedError [3][5]. You can adjust this by configuring the ping_interval and ping_timeout arguments when setting up the websocket server [3]. If you are encountering errors when attempting to close the connection manually, ensure you are properly awaiting the close coroutine: await websocket.close Additionally, note that recent versions of the websockets library have changed how the path is accessed. If you encounter a TypeError regarding missing arguments, access the path directly from the websocket object (e.g., websocket.path or websocket.request.path) rather than expecting it as a function argument [6].

Citations:


🌐 Web query:

OCPP Python ocpp 2.1.0 Call call async close websocket exception ConnectionClosedError

💡 Result:

In the Python ocpp library (including version 2.1.0), websockets.exceptions.ConnectionClosedError occurs when the underlying WebSocket connection terminates unexpectedly, often due to network issues, timeouts, or the peer closing the connection without a proper handshake [1][2][3]. Since ocpp relies on the websockets library for transport, this exception bubbles up when you attempt to send or receive messages over a stale or broken connection [4][5][2]. To handle this exception, you must wrap the ChargePoint.start() call (or any high-level communication loop) in a try...except block [6][7]. ### Implementation Pattern When implementing a Charge Point (client) or Central System (server), you should structure your connection logic to catch the ConnectionClosedError to initiate graceful recovery or clean-up [6][4][7]. python import asyncio import websockets from ocpp.v201 import ChargePoint as cp # Example for OCPP 2.0.1 async def on_connect(websocket, path): charge_point_id = path.strip('/') charge_point = ChargePoint(charge_point_id, websocket) try: await charge_point.start() except websockets.exceptions.ConnectionClosedError as e: # Log the error and perform cleanup print(f"Connection closed abnormally: {e}") finally: # Ensure resources are released await websocket.close() ### Common Causes 1. Keepalive Timeout: The websockets library has a default ping_interval and ping_timeout (often 20 seconds each) [8][1]. If the peer (e.g., a physical Charge Point) stops responding to these pings, the library will raise a ConnectionClosedError with an internal error code [8][1]. 2. Network Instability: Unstable network connections often lead to TCP resets, which the websockets library perceives as an abnormal connection closure [1]. 3. Server/Client Resource Cleanup: If you manually call .close() on a websocket, ensure it is awaited correctly [6]. In custom integration logic, failing to set the connection object to None after a failure can lead to AttributeError or repeated attempts to use a dead connection [9]. ### Best Practices * Use a robust reconnection strategy: Wrap your start() method in a loop that handles retries, potentially with exponential backoff, rather than simply exiting [4]. * Logging: Configure the ocpp logger to DEBUG level to capture detailed transport-layer messages which can help distinguish between protocol-level errors and transport-level connection failures [8]. * Clean Shutdown: Always use a finally block to ensure that await websocket.close() is called and internal state is reset, preventing the application from attempting to route messages through a closed socket [9][7].

Citations:


🌐 Web query:

OCPP python library call method implementation self._connection.close send wait_for unique id exception ConnectionClosedError

💡 Result:

The ConnectionClosedError (or ConnectionClosed) exception in the Python ocpp library typically occurs because the underlying WebSocket connection has been terminated unexpectedly—such as through a network failure, a timeout (e.g., keepalive ping timeout), or a deliberate closure by the peer [1][2][3]. When using the ocpp library, the ChargePoint.call() method (which uses _send and wait_for internally) relies on an active WebSocket connection. If the connection drops during this process, the websockets library raises a ConnectionClosedError [4][5]. ### Key Implementation and Debugging Considerations: 1. Handle Exceptions at the Call Site Because ChargePoint.start() or your individual call() operations may encounter this error, you should wrap your high-level communication logic in a try...except block to catch websockets.exceptions.ConnectionClosedError [6][7][8]. python import websockets # ... try: response = await cp.call(boot_notification_payload) except websockets.exceptions.ConnectionClosedError as e: # Handle the disconnection, log it, and trigger a reconnection logic logger.error(f"Connection lost: {e}") 2. Avoiding Deadlocks Early versions of the ocpp library had issues where self._call_lock would not release if a ConnectionClosedError occurred during a call(), preventing subsequent messages from being sent [4]. Ensure you are using an up-to-date version of the library. If you are overriding internal methods, ensure that any lock or resource cleanup is placed in a finally block [4][7]. 3. Graceful Closure If you intend to close the connection manually, always ensure you await self._connection.close() properly [6][7][8]. If your application needs to handle reconnections, it is common to: * Set a flag or event indicating the connection is down [6]. * Clean up the existing _connection (set it to None) [7]. * Re-initialize or re-instantiate the ChargePoint logic once a new WebSocket connection is established [6][7]. 4. Identifying the Cause If you see ConnectionClosedError frequently: * Check for keepalive/ping timeouts: The default websockets timeout is often 20 seconds. If your network or environment has high latency, you may need to adjust ping_interval and ping_timeout in your WebSocket configuration [1][3]. * Enable logging: Configure the ocpp logger to DEBUG level to see raw messages and detect if the server is actively sending close frames [1]. If you are encountering this during wait_for specifically, it indicates the response for the unique ID associated with the request was never received because the socket closed before the promise could be fulfilled [4]. You must treat the communication as failed and decide whether to retry the operation after re-establishing the connection [6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== command queue implementation =="
rg -n "class CommandQueue|async def __init__|async def enqueue|async def dequeue_all|async def run|async def start|reconnect" custom_components/ocpp/command_queue.py -C 4

echo
echo "== command_queue.py =="
awk '{printf "%6d\t%s\n", NR, $0}' custom_components/ocpp/command_queue.py

Repository: lbbrhzn/ocpp

Length of output: 4511


Preserve in-flight OCPP messages during timeout teardown.

One call dropping into the TimeoutError branch calls self.stop(), which cancels self.tasks; sibling OCPP operations awaiting self.call() then fail with a connection-closed/WebSocketException-style error instead and are never queued for replay. Catch connection failures around call() as well and enqueue them before teardown, or otherwise avoid canceling unrelated in-flight messages while stopping the connection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/chargepoint.py` around lines 613 - 619, Update the
reconnect/timeout handling around self.stop() and the surrounding call() flow so
connection failures from sibling in-flight OCPP operations are caught and queued
for replay before teardown, or ensure stopping the connection does not cancel
unrelated self.tasks. Preserve replay behavior for both TimeoutError and
connection-closed/WebSocketException-style failures.

@Mark-Hetherington
Mark-Hetherington force-pushed the feat/timeout-reconnect-queue branch from 0f410c7 to 9da7f1d Compare July 25, 2026 01:21
@Mark-Hetherington

Copy link
Copy Markdown
Contributor Author

I've applied changes based on Coderabbit feedback, and I'm running them on my own Home Assistant set up to see if any issues crop up.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/ocpp/ocppv16.py`:
- Around line 499-501: The SetChargingProfile handler must stop issuing sibling
profile calls after _call_with_timeout_handling() times out and disconnects.
Update the surrounding TxProfile and TxDefaultProfile flows, including the
corresponding calls at the referenced locations, to return immediately on the
timeout path or queue the remaining profiles without using the stopped
transport; preserve normal sequential calls when no timeout occurs.
- Around line 667-669: Update start_transaction() and stop_transaction() to
prevent timed-out RemoteStartTransaction and RemoteStopTransaction calls from
being queued for _replay_queue() execution. Exclude these non-idempotent
commands from generic replay, or reconcile transaction state after reconnect
before retrying, while preserving timeout handling for other calls.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c69fdb1a-c7cf-48e9-b842-45fb6a74d028

📥 Commits

Reviewing files that changed from the base of the PR and between 0f410c7 and 9da7f1d.

📒 Files selected for processing (5)
  • custom_components/ocpp/chargepoint.py
  • custom_components/ocpp/command_queue.py
  • custom_components/ocpp/ocppv16.py
  • custom_components/ocpp/ocppv201.py
  • tests/test_ocpp_timeout_and_queue.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • custom_components/ocpp/command_queue.py
  • tests/test_ocpp_timeout_and_queue.py
  • custom_components/ocpp/ocppv201.py
  • custom_components/ocpp/chargepoint.py

Comment thread custom_components/ocpp/ocppv16.py
Comment thread custom_components/ocpp/ocppv16.py
@Mark-Hetherington
Mark-Hetherington force-pushed the feat/timeout-reconnect-queue branch from 9da7f1d to 492986c Compare July 25, 2026 10:04
@Mark-Hetherington
Mark-Hetherington temporarily deployed to continuous-integration July 25, 2026 10:04 — with GitHub Actions Inactive
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.28%. Comparing base (c61c1f4) to head (6ca40a3).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2001      +/-   ##
==========================================
+ Coverage   94.96%   95.28%   +0.31%     
==========================================
  Files          12       13       +1     
  Lines        2962     3032      +70     
==========================================
+ Hits         2813     2889      +76     
+ Misses        149      143       -6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/ocpp/chargepoint.py`:
- Around line 617-629: Update the coalesced-command list in command_queue.py to
include RequestStartTransaction and RequestStopTransaction alongside the
existing transaction commands. Ensure the helper queuing via
_call_with_timeout_handling uses these entries so repeated OCPP 2.0.1
transaction requests replace stale queued replies.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5534668f-a4e4-42fe-84e7-0d978edc5e37

📥 Commits

Reviewing files that changed from the base of the PR and between 9da7f1d and 492986c.

📒 Files selected for processing (5)
  • custom_components/ocpp/chargepoint.py
  • custom_components/ocpp/command_queue.py
  • custom_components/ocpp/ocppv16.py
  • custom_components/ocpp/ocppv201.py
  • tests/test_ocpp_timeout_and_queue.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • custom_components/ocpp/command_queue.py
  • custom_components/ocpp/ocppv16.py
  • tests/test_ocpp_timeout_and_queue.py
  • custom_components/ocpp/ocppv201.py

Comment on lines +617 to +629
cmd = QueuedCommand(
call_type=call_type,
call_fn=self._call_with_timeout_handling,
args=(req,),
kwargs={
"call_type": call_type,
"connector_id": connector_id,
**call_kwargs,
},
connector_id=connector_id,
profile_purpose=profile_purpose,
)
await self._command_queue.enqueue(cmd)

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'RequestStartTransaction|RequestStopTransaction|RemoteStartTransaction|RemoteStopTransaction' custom_components/ocpp/command_queue.py

Repository: lbbrhzn/ocpp

Length of output: 239


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== command_queue.py relevant outlines =="
ast-grep outline custom_components/ocpp/command_queue.py --view expanded || true

echo
echo "== command_queue.py around coalescing =="
sed -n '1,120p' custom_components/ocpp/command_queue.py

echo
echo "== chargepoint.py helper context =="
sed -n '580,645p' custom_components/ocpp/chargepoint.py

echo
echo "== ocppv201.py transaction helpers =="
rg -n 'def (start|stop)_transaction|RequestStartTransaction|RequestStopTransaction|_call_with_timeout_handling' custom_components/ocpp/ocppv201.py custom_components/ocpp/chargepoint.py

Repository: lbbrhzn/ocpp

Length of output: 8410


Add OCPP 2.0.1 transaction messages to the coalesce list

command_queue.py still coalesces only SetChargingProfile, RemoteStartTransaction, and RemoteStopTransaction, while ocppv201.py queues OCPP 2.0.1 RequestStartTransaction/RequestStopTransaction through this helper. Repeat transaction timeouts can therefore queue duplicate stale replies for replay; include both RequestStartTransaction and RequestStopTransaction in the coalesced list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/chargepoint.py` around lines 617 - 629, Update the
coalesced-command list in command_queue.py to include RequestStartTransaction
and RequestStopTransaction alongside the existing transaction commands. Ensure
the helper queuing via _call_with_timeout_handling uses these entries so
repeated OCPP 2.0.1 transaction requests replace stale queued replies.

@Mark-Hetherington
Mark-Hetherington force-pushed the feat/timeout-reconnect-queue branch from 492986c to 315fdd0 Compare July 25, 2026 10:41
@Mark-Hetherington
Mark-Hetherington temporarily deployed to continuous-integration July 25, 2026 10:41 — with GitHub Actions Inactive
On asyncio.TimeoutError place failed commands in queue. Newer commands replace commands of the same type. On reconnect replay queued commands

Changes:
- New CommandQueue class with FIFO ordering and coalescing logic
- New _call_with_timeout_handling() wrapper in ChargePoint to catch timeouts
- Modified reconnect() to drain queue via _replay_queue() before re-establishing connection
- Add _command_queue instance to ChargePoint.__init__

Integration:
- ocppv16.py: wrap set_charge_rate() (4 calls) and set_availability() with timeout handler
- ocppv201.py: wrap set_charge_rate() (2 calls), set_availability() (3 calls),
  start_transaction(), and stop_transaction() with timeout handler
- Add asyncio import to ocppv16.py for exception handling

Fixes: lbbrhzn#1999
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.

SetChargingProfile should retry on transient timeout failures

1 participant