Skip to content

fix: Correct FC5/FC6 Modbus response to echo requested value#90

Merged
thiagoralves merged 1 commit into
developmentfrom
fix/modbus-fc5-fc6-response-echo
Jan 28, 2026
Merged

fix: Correct FC5/FC6 Modbus response to echo requested value#90
thiagoralves merged 1 commit into
developmentfrom
fix/modbus-fc5-fc6-response-echo

Conversation

@thiagoralves
Copy link
Copy Markdown
Contributor

Summary

  • Fixes Modbus FC5 (Write Single Coil) and FC6 (Write Single Register) responses to correctly echo the requested value per Modbus specification
  • Introduces OpenPLCDeviceContext class that caches write values for proper response echo
  • Uses asyncio task ID to isolate concurrent requests, preventing race conditions

Problem

When using libmodbus to write a coil via FC5, the coil was written successfully but libmodbus reported an error because the response didn't match the request:

  • Client sends 0xFF00 (ON)
  • Server responds with 0x0000 (the previous value)

This happened because:

  1. pymodbus implements FC5/FC6 by calling setValues() then getValues() and using the read-back value
  2. OpenPLC uses a journal-based write system where writes are applied at the next PLC scan cycle
  3. The getValues() read-back returned the old buffer value before the journal write was applied

Solution

Created OpenPLCDeviceContext that:

  1. Caches values when setValues() is called with FC5 or FC6
  2. Returns cached values when getValues() is called with FC5 or FC6
  3. Uses asyncio task ID as part of the cache key to isolate concurrent requests

This ensures FC5/FC6 responses correctly echo the requested value without introducing a double-buffer synchronization mechanism.

Test plan

  • Test FC5 (Write Single Coil) with libmodbus - verify no error reported
  • Test FC6 (Write Single Register) with libmodbus - verify no error reported
  • Test concurrent FC5 requests to same coil address
  • Test FC1 (Read Coils) still reads actual buffer values
  • Test FC15 (Write Multiple Coils) still works (doesn't use echo)

🤖 Generated with Claude Code

Per Modbus specification, FC5 (Write Single Coil) and FC6 (Write Single
Register) responses should echo the requested value. However, pymodbus
implements these by calling setValues() then getValues(), using the
read-back value in the response.

OpenPLC uses a journal-based write system where writes are queued and
applied at the next PLC scan cycle. This caused getValues() to return
the old buffer value instead of the just-written value, making clients
like libmodbus report errors due to request/response mismatch.

This fix introduces OpenPLCDeviceContext that caches FC5/FC6 write
values and returns them on the subsequent getValues() call. The cache
uses asyncio task ID to isolate concurrent requests, preventing race
conditions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@thiagoralves thiagoralves requested a review from Copilot January 28, 2026 19:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes Modbus FC5 (Write Single Coil) and FC6 (Write Single Register) responses to correctly echo the requested value per Modbus specification, resolving an issue where libmodbus reported errors due to response/request mismatches.

Changes:

  • Introduces OpenPLCDeviceContext class that caches write values for FC5/FC6 operations
  • Uses asyncio task ID to isolate concurrent requests and prevent race conditions
  • Replaces ModbusDeviceContext with OpenPLCDeviceContext in device initialization

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"""Initialize with an empty pending writes cache."""
super().__init__(*args, **kwargs)
# Cache for FC5/FC6 echo values: (task_id, func_code, address) -> value
self._pending_writes = {}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The _pending_writes dictionary is not thread-safe and could lead to race conditions if multiple asyncio tasks access it concurrently. Consider using asyncio.Lock() to protect access to this shared state, or use a thread-safe data structure.

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +138
results.append(self._pending_writes.pop(cache_key))
else:
all_cached = False
break

if all_cached:
return results

# If not all values were cached, clean up any partial cache entries
# and fall through to normal read
for i in range(len(results), count):
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

Cache entries are removed immediately upon read, which could cause issues if getValues() is called multiple times for the same request. Consider whether the cache should persist until the request is complete or implement a more robust cache invalidation strategy.

Suggested change
results.append(self._pending_writes.pop(cache_key))
else:
all_cached = False
break
if all_cached:
return results
# If not all values were cached, clean up any partial cache entries
# and fall through to normal read
for i in range(len(results), count):
# Read cached value without removing it yet; we'll clear the cache
# for the full address range once we've confirmed all values exist.
results.append(self._pending_writes[cache_key])
else:
all_cached = False
break
if all_cached:
# All values were cached: now invalidate the entire range for this task.
for i in range(count):
cache_key = (task_id, func_code, address + i)
self._pending_writes.pop(cache_key, None)
return results
# If not all values were cached, clean up any partial cache entries
# and fall through to normal read
for i in range(count):

Copilot uses AI. Check for mistakes.
"""Initialize with an empty pending writes cache."""
super().__init__(*args, **kwargs)
# Cache for FC5/FC6 echo values: (task_id, func_code, address) -> value
self._pending_writes = {}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The _pending_writes cache grows unbounded and never clears stale entries if tasks fail or don't complete normally. Consider implementing cache expiration or cleanup logic to prevent memory leaks from orphaned task entries.

Copilot uses AI. Check for mistakes.
@thiagoralves thiagoralves merged commit 3f97185 into development Jan 28, 2026
1 check passed
@thiagoralves thiagoralves deleted the fix/modbus-fc5-fc6-response-echo branch January 28, 2026 19:33
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