Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added support for dry-run mode and overrides for debug/oneshot from command line #46

Merged
merged 1 commit into from
Sep 9, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/energomera_hass_mqtt/hass_mqtt.py
Original file line number Diff line number Diff line change
@@ -74,9 +74,10 @@ def calculate_bcc(bytes_data: bytes) -> bytes:
return bcc.to_bytes(length=1, byteorder='big')

def __init__(
self, config: EnergomeraConfig,
self, config: EnergomeraConfig, dry_run: bool = False
):
self._config = config
self._dry_run = dry_run
# Override the method in the `iec62056_21` library with the specific
# implementation
# pylint: disable=protected-access
@@ -100,6 +101,7 @@ def __init__(
mqtt_tls_context = ssl.SSLContext()

self._mqtt_client = MqttClient(
dry_run=self._dry_run,
hostname=getenv('MQTT_HOST', config.of.mqtt.host),
port=getenv('MQTT_PORT', config.of.mqtt.port),
username=getenv('MQTT_USER', config.of.mqtt.user),
32 changes: 30 additions & 2 deletions src/energomera_hass_mqtt/main.py
Original file line number Diff line number Diff line change
@@ -49,6 +49,24 @@ def process_cmdline() -> Tuple[str, Namespace]:
default=DEFAULT_CONFIG_FILE,
help="Path to configuration file (default: '%(default)s')"
)
parser.add_argument(
'-a', '--dry-run',
action='store_true',
default=False,
help="Dry run, do not actually send any data"
)
parser.add_argument(
'-d', '--debug',
action='store_true',
default=False,
help="Enable debug logging"
)
parser.add_argument(
'-o', '--one-shot',
action='store_true',
default=False,
help="Run only once, then exit"
)
return (parser.prog, parser.parse_args())


@@ -70,13 +88,23 @@ async def async_main(mqtt_port: Optional[int] = None) -> None:
# Override port of MQTT broker (if provided)
if mqtt_port:
config.of.mqtt.port = mqtt_port
logging.basicConfig(level=config.logging_level)

# Support overriding certain configuration options from command line
if args.one_shot:
config.of.general.oneshot = True

if args.debug:
config.of.general.logging_level = 'debug'

# Print configuration details
print(f'Starting {prog}, configuration:')
print(config)

client = EnergomeraHassMqtt(config)
if args.dry_run:
print('Dry run mode enabled, no data will be sent over MQTT')

logging.basicConfig(level=config.logging_level)
client = EnergomeraHassMqtt(config=config, dry_run=args.dry_run)
while True:
config.interpolate()
try:
36 changes: 35 additions & 1 deletion src/energomera_hass_mqtt/mqtt_client.py
Original file line number Diff line number Diff line change
@@ -60,11 +60,15 @@ class MqttClient(aiomqtt.Client):
last will thru its constructor, while the `EnergomeraHassMqtt` consuming it
only has required property for that around actual publish calls.
:param dry_run: Whether to skip actual connection and publish calls
:param args: Pass-through positional arguments for parent class
:param kwargs: Pass-through keyword arguments for parent class
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(
self, *args: Any, dry_run: bool = False, **kwargs: Any
) -> None:
self._will_set = 'will' in kwargs
self._dry_run = dry_run
super().__init__(logger=_LOGGER, *args, **kwargs)
# Skip locking in `__aenter__` and `__aexit__` - those aren't used with
# context manager, rather as regular methods, especially the former
@@ -81,6 +85,9 @@ async def connect(self) -> None:
a process loop with no risk of hitting non-reentrant error from base
class.
"""
if self._dry_run:
_LOGGER.debug('Dry run, skipping connection')
return
# Using combination of `self._connected` and `self._disconnected` (both
# inherited from `asyncio_mqtt.client`) to detect of MQTT client needs
# a reconnection upon a network error isn't reliable - the former isn't
@@ -104,9 +111,27 @@ async def disconnect(self) -> None:
Disconnects from MQTT broker using `__aexit__` method of the base class
as per migration guide above.
"""
if self._dry_run:
_LOGGER.debug('Dry run, skipping disconnection')
return

# pylint:disable=unnecessary-dunder-call
await self.__aexit__(None, None, None)

# pylint:disable=arguments-differ
async def publish(self, *args: Any, **kwargs: Any) -> None:
"""
Publishes a message to the MQTT broker.
:param args: Pass-through positional arguments for parent method
:param kwargs: Pass-through keyword arguments for parent method
"""
if self._dry_run:
_LOGGER.debug('Dry run, skipping publish')
return

await super().publish(*args, **kwargs)

def will_set(self, *args: Any, **kwargs: Any) -> None:
"""
Allows setting last will to the underlying MQTT client.
@@ -117,14 +142,23 @@ def will_set(self, *args: Any, **kwargs: Any) -> None:
:param args: Pass-through positional arguments for parent method
:param kwargs: Pass-through keyword arguments for parent method
"""
if self._dry_run:
_LOGGER.debug('Dry run, skipping to set the last will')
return

if self._will_set:
return

self._client.will_set(*args, **kwargs)
self._will_set = True

def will_clear(self) -> None:
"""
Clears the last will might have been set previously.
"""
if self._dry_run:
_LOGGER.debug('Dry run, skipping to clear the last will')
return

self._client.will_clear()
self._will_set = False
17 changes: 17 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@
import sys
from functools import reduce
from unittest.mock import patch, call, DEFAULT, AsyncMock, Mock
import aiomqtt
from callee import Regex as CallRegexMatcher
import pytest
from pytest import FixtureRequest
@@ -1104,3 +1105,19 @@ def mock_mqtt() -> Iterator[MockMqttT]:
publish=DEFAULT, connect=DEFAULT, will_set=DEFAULT,
new_callable=AsyncMock) as mocks:
yield mocks


@pytest.fixture
def mock_mqtt_underlying() -> Iterator[MockMqttT]:
'''
Provides necessary mocks to underlying MQTT client, to be used as context
manager.
'''
with patch.multiple(
aiomqtt.Client,
publish=DEFAULT,
__aenter__=DEFAULT,
__aexit__=DEFAULT,
new_callable=AsyncMock
) as mocks:
yield mocks
36 changes: 35 additions & 1 deletion tests/test_energomera.py
Original file line number Diff line number Diff line change
@@ -21,7 +21,8 @@
'''
Tests for 'energomera_hass_mqtt' package
'''
from unittest.mock import call
from unittest.mock import call, patch
import logging
import pytest
from conftest import (
MQTT_PUBLISH_CALLS_COMPLETE, SERIAL_EXCHANGE_COMPLETE, MockMqttT,
@@ -42,6 +43,39 @@ def test_normal_run(mock_serial: MockSerialT, mock_mqtt: MockMqttT) -> None:
)


@pytest.mark.usefixtures('mock_config')
def test_dry_run(
mock_serial: MockSerialT, mock_mqtt_underlying: MockMqttT,
monkeypatch: pytest.MonkeyPatch
) -> None:
'''
Tests for enabling dry-run mode from command line.
'''
monkeypatch.setattr('sys.argv', ['energomera_hass_mqtt', '-a'])
main()
# Ensure that no MQTT calls are made are sent
mock_mqtt_underlying['publish'].assert_not_called()
mock_mqtt_underlying['__aenter__'].assert_not_called()
mock_mqtt_underlying['__aexit__'].assert_not_called()
# While serial exchanges are still made
mock_serial['_send'].assert_has_calls(
[call(x['receive_bytes']) for x in SERIAL_EXCHANGE_COMPLETE]
)


@pytest.mark.usefixtures('mock_config', 'mock_mqtt', 'mock_serial')
def test_debug_run(
monkeypatch: pytest.MonkeyPatch
) -> None:
'''
Tests for enabling debug logging from command line.
'''
monkeypatch.setattr('sys.argv', ['energomera_hass_mqtt', '-d'])
with patch('logging.basicConfig') as mock:
main()
mock.assert_called_with(level=logging.DEBUG)


@pytest.mark.usefixtures('mock_serial', 'mock_config', 'mock_mqtt')
@pytest.mark.serial_simulate_timeout(True)
def test_timeout() -> None: