Skip to content

Commit

Permalink
Add heartbeat command (#28)
Browse files Browse the repository at this point in the history
* Remove automatic setting of heartbeat and add heartbeat command

* Update changelog

* Update CHANGELOG with PR link

* Remove unused imports

* Add _check_internal back

* Add asyncio_default_fixture_loop_scope = "function"

* Improve actor test fixture

* Add test for heartbeat command

* Add fail test
  • Loading branch information
albireox authored Dec 25, 2024
1 parent 81a4870 commit 91bf52f
Show file tree
Hide file tree
Showing 7 changed files with 77 additions and 17 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

* Add reporting of roll-off error state and allow resetting on error.

### 🏷️ Changed

* [#28](https://vscode.dev/github/sdss/lvmecp/pull/28) Removed the automatic setting of the heartbeat variable. Added a `heartbeat` command that will be triggered by a heartbeat middleware.

### 🔧 Fixed

* Restore GS3 status registers and fix addresses for roll-off lockout and error.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ sdss = ["sdsstools", "clu"]
[tool.pytest.ini_options]
addopts = "--cov lvmecp --cov-report xml --cov-report html --cov-report term"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

[tool.coverage.run]
branch = true
Expand Down
12 changes: 0 additions & 12 deletions python/lvmecp/actor/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ async def start(self, **kwargs):
await super().start(**kwargs)

asyncio.create_task(self.emit_status())
asyncio.create_task(self.emit_heartbeat())

return self

Expand All @@ -75,17 +74,6 @@ async def emit_status(self, delay: float = 30.0):
await self.send_command(self.name, "status", internal=True)
await asyncio.sleep(delay)

async def emit_heartbeat(self, delay: float = 5.0):
"""Updates the heartbeat Modbus variable to indicate the system is alive."""

while True:
try:
await self.plc.modbus["hb_set"].set(True)
except Exception:
self.write("w", "Failed to set heartbeat variable.")
finally:
await asyncio.sleep(delay)

async def _check_internal(self):
return await super()._check_internal()

Expand Down
29 changes: 29 additions & 0 deletions python/lvmecp/actor/commands/heartbeat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego ([email protected])
# @Date: 2024-12-20
# @Filename: heartbeat.py
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)

from __future__ import annotations

from typing import TYPE_CHECKING

from . import parser


if TYPE_CHECKING:
from lvmecp.actor import ECPCommand


@parser.command()
async def heartbeat(command: ECPCommand):
"""Sets the heartbeat variable on the PLC."""

try:
await command.actor.plc.modbus["hb_set"].set(True)
except Exception:
return command.fail("Failed to set heartbeat.")
finally:
return command.finish()
10 changes: 5 additions & 5 deletions python/lvmecp/plc.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ async def notifier(value: int, labels: str, command: Command | None = None):
# Allow for 3 seconds for broadcast. This is needed because the PLC
# starts before the actor and for the first message the exchange is
# not yet available.
n_tries = 0
elapsed: float = 0
while actor.connection.connection is None:
n_tries += 1
if n_tries >= 3:
return None
await asyncio.sleep(1)
elapsed += 0.01
if elapsed > 3:
return
await asyncio.sleep(0.01)
actor.write(level, message)
elif command is not None:
command.write(level, message)
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ async def actor(simulator: Simulator, mocker):
mocker.patch.object(_actor.plc.hvac.modbus, "get_all", return_value={})

_actor = await setup_test_actor(_actor) # type: ignore
_actor.connection.connection = mocker.MagicMock(spec={"is_closed": False})

yield _actor

Expand Down
37 changes: 37 additions & 0 deletions tests/test_command_heartbeat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego ([email protected])
# @Date: 2024-12-24
# @Filename: test_command_heartbeat.py
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)

from __future__ import annotations

from typing import TYPE_CHECKING


if TYPE_CHECKING:
from pytest_mock import MockerFixture

from lvmecp.actor import ECPActor


async def test_command_heartbeat(actor: ECPActor, mocker: MockerFixture):
hb_set_mock = mocker.patch.object(actor.plc.modbus["hb_set"], "set")

cmd = await actor.invoke_mock_command("heartbeat")
await cmd

assert cmd.status.did_succeed

hb_set_mock.assert_called_once_with(True)


async def test_command_heartbeat_fails(actor: ECPActor, mocker: MockerFixture):
mocker.patch.object(actor.plc.modbus["hb_set"], "set", side_effect=Exception)

cmd = await actor.invoke_mock_command("heartbeat")
await cmd

assert cmd.status.did_fail

0 comments on commit 91bf52f

Please sign in to comment.