Skip to content

Add S7CommPlus alarm handling and subscriptions - #820

Open
gijzelaerr wants to merge 4 commits into
masterfrom
feat/794-s7commplus-alarm-support
Open

Add S7CommPlus alarm handling and subscriptions#820
gijzelaerr wants to merge 4 commits into
masterfrom
feat/794-s7commplus-alarm-support

Conversation

@gijzelaerr

@gijzelaerr gijzelaerr commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • add sync and async alarm subscription create/delete APIs
  • add active-alarm browsing with multilingual text filtering
  • decode unsolicited alarm notifications into public Alarm, AlarmText, and AlarmNotification models
  • default subscriptions to a notification credit limit of 10, matching the working S7-1500 reference trace

Testing

  • uv run --frozen pytest (1650 passed, 82 skipped)
  • uv run --frozen pre-commit run --all-files
  • uv build --no-sources

The wire-level tests cover subscription filters, active alarm state, coming timestamps, multilingual text payloads, sync/async client APIs, and notification framing. Real PLC notification delivery still needs validation because alarm object contents and delivery behavior vary by firmware.

Fixes #794

Comment thread s7commplus/alarm.py Outdated
Comment thread s7commplus/async_client.py
Comment thread s7commplus/async_client.py Outdated
await self._send_request(FunctionCode.DELETE_OBJECT, payload)
logger.info(f"Subscription {subscription_id:#x} deleted")

async def create_alarm_subscription(

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.

Doesn't really work for me, but I got you a trace using the C# library (TX = before TLS encryption, RX = received after TLS decryption)

{"t":531.665,"type":"note","text":"AlarmSubscriptionCreate()"}
{"t":533.610,"layer":"s7cp","dir":"TX","len":252,"hex":"720200f431000004ca0000000870000cb73470000cb80004000000000002a17fffc00187690000a38169001517537562736372697074696f6e5f32313437343637323635a3883a000202a3876a00030000a3876b000900a38810000202a38811000101a3881820040388808480000000a38819000400a3881a000400a3881b000200a3881c000200a3881d0007000aa3881e0003ffffa15101000194660000a381690015125337704472697665725f416c61726d696e67a3876d000203a3946310030a0000000000000000000000000000000000000000a3bc33200301ffffa3bf75200400a3bf6d000101a4946400000008a2a20000000072020000"}
{"t":550.019,"layer":"s7cp","dir":"RX","len":30,"hex":"7202001632000004ca0000000834000187808099390a0000000072020000"}
{"t":550.942,"type":"note","text":"TestWaitForAlarmNotifications(waitTimeout=20000, untilNumberOfAlarms=3, languageId=1033)"}
{"t":7090.215,"layer":"s7cp","dir":"RX","len":590,"hex":"720202463370000cb904000000000001000006596bd6805195010051010001000081a172007382947984800000a3816900150954656d704461695f31a3946e000d8a0e004200010000a3946f000287a3947000030102a39471001700000d919b120002879b13001018cd4d3de5007df69b141014119080821300000000000000000204010100009080800500000000000000000202000690808035000000000000000002020000908080350000000000000000020200049080803500000000000000000202000490a8817e0000000000000000020bfe093d46352b53332d4232000000000000000000000000000000000000000000009c3e0010000000000000000000a39f6f000803a3bd05001400110103af2b00000040010000000022000201a3bd6d0004868718a3951b4014a0a48001003d4d616368696e652077696c6c206f706572617465206174203830252073706565642064756520746f2070726f6475637420726571756972656d656e7473a0a4800200442869292028403525734029204d616368696e65204031256440202d205374617475733a2052756e6e696e67202d2052656475636564206d616368696e652073706565642ea0a48003000b424d4b3a20403525734020a0a48006001d416c61726d20636c6173733a20496e666f726d6174696f6e206f6e6c79a0a48009004f4c6f672066696c653a20284e616d65206f6620746865206c6f672066696c6520776865726520746865736520696e666f726d6174696f6e2d6f6e6c7920616c61726d73206172652073746f7265642900a20000000072020000"}
{"t":7095.333,"layer":"s7cp","dir":"RX","len":243,"hex":"720200eb3370000cb904000000000002010006596bd6807b81010051010001000081a172007383947984800000a3816900150954656d704461695f32a3946e000d8a0e002500010000a3946f000287a3947000030100a39471001700000d919b120002879b13001018cd4d3de51bc86b9b1410141190808213000000000000000002040101000000000000000000000000000000000000000000000000000000000000000000009c3e0010000000000000000000a39f6f000803a3bd05001400110103f3990000004b000000000023000001a3bd6d0004868719a3951b4014a0a4800200045465737400a20000000072020000"}
{"t":15040.527,"layer":"s7cp","dir":"RX","len":617,"hex":"720202613370000cb904000000000003020006596bd6f9abaf010051010001000081a172007382947984800000a3816900150954656d704461695f31a3946e000d8a0e003b00010000a3946f000287a3947000030103a39471001700000d919b120002879b13001018cd4d3fbf25e3909b141014119080821300000000000000000204010100009080800500000000000000000202000390808035000000000000000002020000908080350000000000000000020200019080803500000000000000000202000190a8817e0000000000000000020bfe093d46332b53312d4d31000000000000000000000000000000000000000000009c3e0010000000000000000000a39f6f000802a3bd050014001101034d8a0000003d090000000022000101a3bd6d000486871aa3951b4014a0a4800100745468652073656e736f722069732062726f6b656e2c2062757420746865206c696e652063616e207374696c6c206f7065726174652e20506c65617365207265706f72742074686520697373756520746f20746865206d616e616765722061742074686520656e64206f6620746865207368696674a0a4800200415761726e696e673a2028403525734029204d616368696e65204031256440202d205374617475733a2052756e6e696e67202d2042726f6b656e2073656e736f722ea0a48003000b424d4b3a20403525734020a0a48006001f416c61726d20636c6173733a204e6f2061636b6e6f776c656467656d656e74a0a480090034436f6e746163743a20284e616d65206f662074686520636f6e7461637420706572736f6e20666f72207468697320616c61726d2900a20000000072020000"}
{"t":15043.512,"type":"note","text":"AlarmSubscriptionDelete()"}
{"t":15045.561,"layer":"s7cp","dir":"TX","len":55,"hex":"7202002f31000004d40000000970000cb73470000cb800000004e88969001200000000896a001300896b00040000030000000072020000"}
{"t":15060.838,"layer":"s7cp","dir":"RX","len":28,"hex":"7202001432000004d400000009340070000cb80c0000000072020000"}
{"t":15063.242,"type":"note","text":"Disconnect()"}
{"t":15063.340,"layer":"s7cp","dir":"TX","len":55,"hex":"7202002f31000004d40000000a70000cb73470000cb700000004e88969001200000000896a001300896b00040000040000000072020000"}
{"t":15076.681,"layer":"s7cp","dir":"RX","len":35,"hex":"7202001b32000004d40000000a349088f08080828a802d70000cb70000000072020000"}

@gijzelaerr

Copy link
Copy Markdown
Owner Author

@bvanelli I updated the alarm implementation from your traces: subscription creation/deletion now targets the subscription container with the captured framing, the exact subscription and child names are used, default credits are 10, and LanguageId is an IntEnum accepted by the public APIs. Byte-exact create/delete fixtures are included.

Could you please rerun alarm subscription creation, notification delivery, deletion, and an LCID-based active-alarm read against the current branch, then review again if those now work on the PLC?

Install:

pip install --upgrade "python-snap7 @ git+https://github.com/gijzelaerr/python-snap7.git@feat/794-s7commplus-alarm-support"

All 42 CI checks pass; local validation reports 1,827 passed, 78 skipped.

@gijzelaerr
gijzelaerr requested a review from bvanelli August 20, 2026 06:50
@bvanelli

bvanelli commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

For it it didn't really work, I get a timeout instead when it is about receiving the notification:

Traceback (most recent call last):
  File "/Users/brunno.vanelli/Documents/git/python-snap7/s7commplus/connection.py", line 1534, in _recv_s7_data
    return self._ssl_object.read(65536)  # type: ignore[union-attr]
           ~~~~~~~~~~~~~~~~~~~~~^^^^^^^
  File "/Users/brunno.vanelli/.local/share/uv/python/cpython-3.14.5-macos-aarch64-none/lib/python3.14/ssl.py", line 880, in read
    v = self._sslobj.read(len)
ssl.SSLWantReadError: The operation did not complete (read) (_ssl.c:2711)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/brunno.vanelli/Documents/git/python-snap7/snap7/connection.py", line 466, in _recv_exact
    chunk = self.socket.recv(size - len(data))
TimeoutError: timed out

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/brunno.vanelli/Documents/git/python-snap7/example/s7commplus1_alarms.py", line 52, in <module>
    alarms = client.receive_alarm_notification()
  File "/Users/brunno.vanelli/Documents/git/python-snap7/s7commplus/client.py", line 720, in receive_alarm_notification
    return parse_alarm_notification(self._connection.receive_notification(), language_ids)
                                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/Users/brunno.vanelli/Documents/git/python-snap7/s7commplus/connection.py", line 865, in receive_notification
    frame = self._recv_s7_data()
  File "/Users/brunno.vanelli/Documents/git/python-snap7/s7commplus/connection.py", line 1536, in _recv_s7_data
    self._tls_read_incoming()
    ~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/Users/brunno.vanelli/Documents/git/python-snap7/s7commplus/connection.py", line 1548, in _tls_read_incoming
    data = self._iso_conn.receive_data()
  File "/Users/brunno.vanelli/Documents/git/python-snap7/snap7/connection.py", line 204, in receive_data
    tpkt_header = self._recv_exact(4)
  File "/Users/brunno.vanelli/Documents/git/python-snap7/snap7/connection.py", line 473, in _recv_exact
    raise S7TimeoutError("Receive timeout")
snap7.error.S7TimeoutError: Receive timeout

Sent the byte exchange via email.

@gijzelaerr

Copy link
Copy Markdown
Owner Author

Thanks — I inspected the exchange you sent by email. I will not copy the byte capture into this PR, tests, or fixtures.

The response to create_alarm_subscription() is structurally successful: the PLC returns a zero status and one subscription object. The timeout happens afterward, while waiting for an unsolicited notification, so this trace does not by itself indicate that subscription creation failed.

Could you confirm whether you deliberately caused a new alarm transition after the subscription was created? Existing active alarms may be returned by browse_alarms() without being replayed as subscription notifications. A useful hardware check would be:

  1. create the subscription;
  2. trigger a new alarm transition on the PLC;
  3. call receive_alarm_notification();
  4. separately report whether browse_alarms() sees the active alarm.

There is also a client-side behavior we should improve independently: an idle sync notification window currently surfaces as S7TimeoutError, and the ISO receive layer marks the connection disconnected on that timeout. That makes “no notification arrived yet” look more severe than it is, but changing that behavior alone would not make the PLC emit an event.

@gijzelaerr
gijzelaerr removed the request for review from bvanelli September 1, 2026 17:08
@bvanelli

bvanelli commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Ah, it makes sense that I did not receive a notification because, no data came until the timeout. By running it a couple of times, I now get data (all alarms are fake, BMKs are random):

AlarmNotification(subscription_id=1879051449,
                  credit_tick=1,
                  sequence_number=0,
                  subscription_change_counter=0,
                  timestamp=1788363027507044,
                  alarms=(Alarm(cpu_alarm_id=9947888893196042240,
                                all_states_info=134,
                                domain=259,
                                message_type=2,
                                sequence_counter=4947,
                                name='TempDai_1',
                                state='going',
                                timestamp=1788363027503741271,
                                acknowledge_timestamp=0,
                                hmi_info=b'\x01\x033\xb0\x00\x00\x00>'
                                         b'\t\x00\x00\x00\x00"\x00\x01\x01',
                                associated_values=(b'\x01\x01\x00\x00',
                                                   b'\x00\x04',
                                                   b'\x00\x00',
                                                   b'\x00\x05',
                                                   b'\x00\x05',
                                                   b'\xfe\t=F6+S2-G1',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b'',
                                                   b''),
                                texts={1033: AlarmText(language_id=1033,
                                                       info_text='The product '
                                                                 'pallet has '
                                                                 'almost been '
                                                                 'completely '
                                                                 'filled. '
                                                                 'Please '
                                                                 'exchange it '
                                                                 'soon',
                                                       alarm_text='Warning: '
                                                                  '(@5%s@) '
                                                                  'Machine '
                                                                  '@1%d@ - '
                                                                  'Status: '
                                                                  'Running - '
                                                                  'Pallet '
                                                                  'almost '
                                                                  'full.',
                                                       additional_texts=('BMK: '
                                                                         '@5%s@ ',
                                                                         '',
                                                                         '',
                                                                         'Alarm '
                                                                         'class: '
                                                                         'No '
                                                                         'acknowledgement',
                                                                         '',
                                                                         '',
                                                                         'Contact: '
                                                                         '(Name '
                                                                         'of '
                                                                         'the '
                                                                         'contact '
                                                                         'person '
                                                                         'for '
                                                                         'this '
                                                                         'alarm)',
                                                                         '',
                                                                         ''))}),))

BTW, I'm not sure where you want to put this (if on the AlarmNotification class directly), but you need to interpolate every entry of alarm_text and additional_texts with the associated_values. For example, this that I placed above should result in:

Warning: (=F6+S2-G1) Machine 4 - Status: Running - Pallet almost full.

browse_alarms also works, although I seem to get timeouts often too. I did not investigate the reason.

You cannot compare browse alarms with notifications. Notifications are only sent on change, while browse alarms return the full state. But here is a consecutive read directly after receiving a notification. You see the same cpu_alarm_id.

AlarmNotification(subscription_id=1879051449, credit_tick=1, sequence_number=0, subscription_change_counter=0, timestamp=1788363987902179, alarms=(Alarm(cpu_alarm_id=9947888893196042240, all_states_info=135, domain=259, message_type=2, sequence_counter=5103, name='TempDai_1', state='coming', timestamp=1788363987898972159, acknowledge_timestamp=0, hmi_info=b'\x01\x033\xb0\x00\x00\x00>\t\x00\x00\x00\x00"\x00\x01\x01', associated_values=(b'\x01\x01\x00\x00', b'\x00\x04', b'\x00\x00', b'\x00\x05', b'\x00\x05', b'\xfe\t=F6+S2-G1', b'', b'', b'', b'', b'', b'', b'', b'', b'', b'', b''), texts={1033: AlarmText(language_id=1033, info_text='The product pallet has almost been completely filled. Please exchange it soon', alarm_text='Warning: (@5%s@) Machine @1%d@ - Status: Running - Pallet almost full.', additional_texts=('BMK: @5%s@ ', '', '', 'Alarm class: No acknowledgement', '', '', 'Contact: (Name of the contact person for this alarm)', '', ''))}),))
[Alarm(cpu_alarm_id=9947888893196042240, all_states_info=135, domain=259, message_type=2, sequence_counter=5103, name='ExplDai_2', state='coming', timestamp=1788363987898972159, acknowledge_timestamp=0, hmi_info=b'\x01\x033\xb0\x00\x00\x00>\t\x00\x00\x00\x00"\x00\x01\x01', associated_values=(b'\x01\x01\x00\x00', b'\x00\x04', b'\x00\x00', b'\x00\x05', b'\x00\x05', b'\xfe\t=F6+S2-G1', b'', b'', b'', b'', b'', b'', b'', b'', b'', b'', b''), texts={1033: AlarmText(language_id=1033, info_text='The product pallet has almost been completely filled. Please exchange it soon', alarm_text='Warning: (@5%s@) Machine @1%d@ - Status: Running - Pallet almost full.', additional_texts=('BMK: @5%s@ ', '', '', 'Alarm class: No acknowledgement', '', '', 'Contact: (Name of the contact person for this alarm)', '', ''))}), Alarm(cpu_alarm_id=9947888871721205760, all_states_info=133, domain=257, message_type=1, sequence_counter=17, name='ExplDai_2', state='coming', timestamp=1788328544048217866, acknowledge_timestamp=0, hmi_info=b'\x01\x03\xd5\n\x00\x00\x00<\x10\x00\x00\x00\x00!\x00\x03\x00', associated_values=(b'\x01\x01\x00\x00', b'\x00\x02', b'\x00\x02', b'\x00\x02', b'\x00\x02', b'\xfe\t=F2+S3-B1', b'', b'', b'', b'', b'', b'', b'', b'', b'', b'', b''), texts={1033: AlarmText(language_id=1033, info_text='One of the parts is stuck on the line, so the line has been stopped. Please acknowledge the alarm and report the issue to the safety team', alarm_text='ERROR: (@5%s@) Machine @1%d@ - Status: Error - Stuck part.', additional_texts=('BMK: @5%s@ ', '', '', 'Alarm class: Acknowledgement', '', '', 'Contact: (Name of the contact person for this alarm)', '', ''))}), Alarm(cpu_alarm_id=9947888867426238464, all_states_info=133, domain=257, message_type=1, sequence_counter=12, name='ExplDai_2', state='coming', timestamp=1788328529044962582, acknowledge_timestamp=0, hmi_info=b'\x01\x03s\xa6\x00\x00\x00;\x10\x00\x00\x00\x00!\x00\x03\x00', associated_values=(b'\x01\x01\x00\x00', b'\x00\x01', b'\x00\x02', b'\x00\x00', b'\x00\x00', b'\xfe\n=F1+A1-KE1', b'', b'', b'', b'', b'', b'', b'', b'', b'', b'', b''), texts={1033: AlarmText(language_id=1033, info_text='The emergency stop button has been pressed. Please acknowledge the alarm, clear the problem on the line and then reset the emergency stop button', alarm_text='ERROR: (@5%s@) Machine @1%d@ - Status: Error - Emergency button pressed.', additional_texts=('BMK: @5%s@ ', '', '', 'Alarm class: Acknowledgement', '', '', 'Contact: (Name of the contact person for this alarm)', '', ''))})]

Comment thread s7commplus/async_client.py Outdated
frame = await asyncio.wait_for(receive, timeout) if timeout is not None else await receive
return parse_alarm_notification(frame, language_ids)

async def browse_alarms(self, language_ids: Optional[list[LanguageId | int]] = None) -> list[Alarm]:

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.

I would insttead call it read_alarms for consistency, and hint on the docs that you should use one or the other (or both).


self._connected = False
self._session_id = 0
self._subscription_container_id = 0

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.

You introduce the same construct in https://github.com/gijzelaerr/python-snap7/pull/825/changes. Could having both data and alarm subscriptions cause problems here?

@gijzelaerr

Copy link
Copy Markdown
Owner Author

Addressed the hardware-test feedback in d91bbd3: alarm text placeholders now interpolate typed associated values (including the reported @1%d@ and @5%s@ forms), while Alarm.associated_values retains the raw bytes. Added read_alarms() as the canonical snapshot API and retained browse_alarms() as a compatibility alias. The receive methods now document that concurrent data/alarm receive loops on one connection are not supported yet. Local build, full tests (1778 passed, 82 skipped), and pre-commit all pass.

@bvanelli

bvanelli commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

In would not retain browse_alarms as compatibility, because the Siemens actually provides this option too. You are able to browse alarm definitions, in this returns a static list.

My opinion would be to just rename it, to keep it consistent with the other browse (explore).

@gijzelaerr

Copy link
Copy Markdown
Owner Author

Agreed — browse_alarms() should remain available for the distinct static alarm-definition browse operation. I removed the sync and async compatibility aliases in cd5d0ec; read_alarms() remains the active-alarm snapshot API.

I also merged current master into the branch without rebasing, including #849 and the reduced CI matrix from #857. Validation is green locally: 15 focused alarm tests, 1,818 full-suite tests passed with 82 skipped, source/wheel builds passed, and all pre-commit hooks passed.

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.

Alarm handling and subscriptions (S7CommPlus)

2 participants