Skip to content

Commit

Permalink
Feature: auto update time (#24)
Browse files Browse the repository at this point in the history
Plejd now updates its internal clock automatically

Three settings are exposed to this change.
It is possible to set the interval between time checks
It is possible to set the time diff threshhold, ie how much shall the
clock diff before updating it
A third, but not yet used, option to use an external NTP server
  • Loading branch information
ha-enthus1ast authored Dec 14, 2023
1 parent 09c4a4c commit 507cdbd
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 57 deletions.
39 changes: 24 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,17 @@ Configuration of the application. See example configuration [here](#example).

### General

| Parameter | Description | Default |
|------------------------------------------|-------------------------------------------------|-------------|
| `health_check` (Optional) | Enable health check | True |
| `health_check_interval` (Optional) | Interval in seconds between health check writes | 60.0 |
| `health_check_dir` (Optional) | Directory to store health check files | "~/.plejd/" |
| `health_check_bt_file` (Optional) | File name for Bluetooth health check | "bluetooth" |
| `health_check_mqtt_file` (Optional) | File name for MQTT health check | "mqtt" |
| `health_check_heartbeat_file` (Optional) | File name for heartbeat file | "heartbeat" |
| Parameter | Description | Default |
|------------------------------------------|-----------------------------------------------------------------------------------------|-------------|
| `health_check` (Optional) | Enable health check | True |
| `health_check_interval` (Optional) | Interval in seconds between health check writes | 60.0 |
| `health_check_dir` (Optional) | Directory to store health check files | "~/.plejd/" |
| `health_check_bt_file` (Optional) | File name for Bluetooth health check | "bluetooth" |
| `health_check_mqtt_file` (Optional) | File name for MQTT health check | "mqtt" |
| `health_check_heartbeat_file` (Optional) | File name for heartbeat file | "heartbeat" |
| `time_update_interval` (Optional) | Interval in seconds between updating Plejd time | 3600.0 |
| `time_update_threshold` (Optional) | Time difference in seconds between Plejd time and local time before updating Plejd time | 10.0 |
| `time_use_sys_time` (Optional) | Whether or not to use system time instead of NTP time **NOT USED YET!** | True |

### API

Expand All @@ -155,13 +158,19 @@ Configuration of the application. See example configuration [here](#example).

### MQTT

| Parameter | Description | Default |
|----------------------------------|---------------------------------|-----------------|
| `host` (Optional) | Address of the host MQTT broker | "localhost" |
| `port` (Optional) | Port of the MQTT host | 1883 |
| `user` (Optional) | MQTT user name | None |
| `password` (Optional) | Password of the MQTT user | None |
| `ha_discovery_prefix` (Optional) | Home assistant discovery prefix | "homeassistant" |
| Parameter | Description | Default |
|-------------------------------|---------------------------------------------------------------------------|-----------------|
| `host` (Optional) | MQTT broker host | "localhost" |
| `port` (Optional) | MQTT broker port | 1883 |
| `username` (Optional) | MQTT broker username | None |
| `password` (Optional) | MQTT broker password | None |
| `client_name` (Optional) | MQTT client name | None |
| `use_tls` (Optional) | Whether or not to use TLS | False |
| `tls_key` (Optional) | TLS key file | None |
| `tls_certfile` (Optional) | TLS certificate file | None |
| `tls_ca_cert` (Optional) | TLS CA certificate file | None |
| `discovery_prefix` (Optional) | The root of the topic tree where HA is listening for messages | "homeassistant" |
| `state_prefix` (Optional) | The root of the topic tree ha-mqtt-discovery publishes its state messages | "hmd" |

### BLE

Expand Down
56 changes: 30 additions & 26 deletions plejd_mqtt_ha/bt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from plejd_mqtt_ha import constants
from plejd_mqtt_ha.mdl.bt_device_info import BTDeviceInfo
from plejd_mqtt_ha.mdl.settings import PlejdSettings


Expand Down Expand Up @@ -298,18 +297,17 @@ async def get_last_data(self) -> bytes:
encoded_address = self._encode_address(self._client.address)
return self._encrypt_decrypt_data(self._crypto_key, encoded_address, encrypted_data)

async def get_plejd_time(self, plejd_device: BTDeviceInfo) -> Optional[datetime]:
async def get_plejd_time(self, ble_address: int) -> datetime:
"""Request time from plejd mesh.
Parameters
----------
plejd_device : PlejdDevice
Plejd device to get time from, can be any device in the mesh. Does not really matter
which one.
ble_address : int
Address of the device to request time from
Returns
-------
Optional[datetime]
datetime
Returns the time in datetime format
"""
if not self.is_connected():
Expand All @@ -318,15 +316,18 @@ async def get_plejd_time(self, plejd_device: BTDeviceInfo) -> Optional[datetime]
raise PlejdNotConnectedError(error_message)

try:
# Request time
await self.send_command(
plejd_device.ble_address,
constants.PlejdCommand.BLE_CMD_TIME_UPDATE.value,
"",
constants.PlejdResponse.BLE_REQUEST_RESPONSE.value,
)
# Read respone
last_data = await self.get_last_data()
# Requires atomic operation, otherwise we might miss the response
lock = asyncio.Lock()
async with lock:
# Request time
await self.send_command(
ble_address,
constants.PlejdCommand.BLE_CMD_TIME_UPDATE.value,
"",
constants.PlejdResponse.BLE_REQUEST_RESPONSE.value,
)
# Read respone
last_data = await self.get_last_data()
except (PlejdBluetoothError, PlejdNotConnectedError, PlejdTimeoutError):
logging.error("Failed to read time from Plejd mesh, when calling get_last_data")
raise
Expand All @@ -338,35 +339,39 @@ async def get_plejd_time(self, plejd_device: BTDeviceInfo) -> Optional[datetime]
):
logging.warning(
"Failed to read time from Plejd mesh, using device: %s",
plejd_device.name,
ble_address,
)
raise UnsupportedCommandError("Received unknown command")

# Check that last_data is long enough
if len(last_data) < 9:
logging.error("Buffer is too short to unpack time")
raise PlejdBluetoothError("Buffer is too short to unpack time")

# Convert from unix timestamp
plejd_time = datetime.fromtimestamp(struct.unpack_from("<I", last_data, 5)[0])

return plejd_time

async def set_plejd_time(self, plejd_device: BTDeviceInfo, time: datetime) -> bool:
async def set_plejd_time(self, ble_address: int, time: datetime) -> None:
"""Set time in plejd mesh.
Parameters
----------
plejd_device : PlejdDevice
Plejd device to use to set time, can be any device in the mesh. Does not really matter
which one.
ble_address : int
Address of the device to set time in
time : datetime
Time to set in datetime format
Returns
-------
bool
Boolean status of the operation
Raises
------
PlejdBluetoothError
If an error occurs when sending the command to the mesh
"""
timestamp = struct.pack("<I", int(time.timestamp())) + b"\x00"
try:
await self.send_command(
plejd_device.ble_address,
ble_address,
constants.PlejdCommand.BLE_CMD_TIME_UPDATE.value,
timestamp.hex(),
constants.PlejdResponse.BLE_REQUEST_NO_RESPONSE.value,
Expand All @@ -377,7 +382,6 @@ async def set_plejd_time(self, plejd_device: BTDeviceInfo, time: datetime) -> bo
raise

logging.debug("Successfully set plejd time to: %s", time)
return True

async def _connect(self) -> bool:
self._disconnect = False
Expand Down
8 changes: 8 additions & 0 deletions plejd_mqtt_ha/mdl/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,11 @@ class PlejdSettings(BaseModel):
"""File name for MQTT health check"""
health_check_hearbeat_file: str = "heartbeat"
"""File name for heartbeat file"""

time_update_interval: float = 60.0 * 60.0 # 1 hour
"""Interval in seconds between updating Plejd time"""
time_update_threshold: float = 10.0
"""Time difference in seconds between Plejd time and local time before updating Plejd time"""
time_use_sys_time: bool = True
"""Whether or not to use system time instead of NTP time"""
# TODO: Add ntp server option
54 changes: 49 additions & 5 deletions plejd_mqtt_ha/plejd.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""

import asyncio
import datetime
import logging
import logging.handlers
import os
Expand All @@ -26,7 +27,7 @@

import yaml
from plejd_mqtt_ha import constants
from plejd_mqtt_ha.bt_client import BTClient
from plejd_mqtt_ha.bt_client import BTClient, PlejdBluetoothError
from plejd_mqtt_ha.mdl.combined_device import (
BTDeviceError,
CombinedDevice,
Expand Down Expand Up @@ -134,11 +135,54 @@ async def _run(config: str, log_level: str, log_file: str) -> None:
return
logging.info(f"Created {len(discovered_devices)} Plejd devices.. [OK]")

heartbeat_task = asyncio.create_task(
write_health_data(plejd_settings, discovered_devices)
) # Create heartbeat task
heartbeat_task = asyncio.create_task(write_health_data(plejd_settings, discovered_devices))
update_time_task = asyncio.create_task(_update_plejd_time(plejd_settings, discovered_devices))

await heartbeat_task # Wait indefinitely for the heartbeat task
await asyncio.gather(heartbeat_task, update_time_task) # Wait indefinitely for the tasks


async def _update_plejd_time(
plejd_settings: PlejdSettings, discovered_devices: list[CombinedDevice]
) -> None:
"""Update Plejd time continously.
Parameters
----------
plejd_settings : PlejdSettings
Settings
discovered_devices : list[CombinedDevice]
List of discovered devices
"""
bt_client = discovered_devices[0]._plejd_bt_client
while True: # Run forever
for device in discovered_devices:
try:
ble_address = device._device_info.ble_address
current_plejd_time = await bt_client.get_plejd_time(ble_address)

if plejd_settings.time_use_sys_time:
current_sys_time = datetime.datetime.now()
else:
current_sys_time = None # TODO: not implemented

if abs(current_plejd_time - current_sys_time) > datetime.timedelta(
seconds=plejd_settings.time_update_interval
):
await bt_client.set_plejd_time(ble_address, current_sys_time)
logging.info(f"Updated Plejd time using device {device._device_info.name}")
else:
logging.info(
f"Used device {device._device_info.name} to tell Plejd time is already up"
"to date"
)
except PlejdBluetoothError as err:
logging.error(
f"Error {err} updating Plejd time using device {device._device_info.name}, "
"trying next device"
)
continue

await asyncio.sleep(plejd_settings.time_update_interval)


async def write_health_data(
Expand Down
20 changes: 9 additions & 11 deletions tests/test_bt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,18 +260,18 @@ async def test_get_plejd_time(

if expected_exception:
with pytest.raises(expected_exception):
await self.bt_client.get_plejd_time(self.bt_device)
await self.bt_client.get_plejd_time(self.bt_device.ble_address)
else:
result = await self.bt_client.get_plejd_time(self.bt_device)
result = await self.bt_client.get_plejd_time(self.bt_device.ble_address)
assert result == expected_result

@pytest.mark.asyncio
@pytest.mark.parametrize(
"send_command_success, expected_exception, expected_result",
[
(False, PlejdNotConnectedError, False), # Not connected
(False, PlejdBluetoothError, False), # send_command failed
(True, None, True), # Successful case
(False, PlejdNotConnectedError(""), False), # Not connected
(False, PlejdBluetoothError(""), False), # send_command failed
(False, None, None), # Successful case
],
)
async def test_set_plejd_time(
Expand All @@ -282,16 +282,14 @@ async def test_set_plejd_time(
time = datetime.datetime.now()
timestamp = struct.pack("<I", int(time.timestamp())) + b"\x00"
self.bt_client.send_command = mocker.AsyncMock(
side_effect=expected_exception("send_command failed")
if not send_command_success
else None
side_effect=expected_exception if not send_command_success else None
)

if expected_exception:
with pytest.raises(expected_exception):
await self.bt_client.set_plejd_time(self.bt_device, time)
with pytest.raises(type(expected_exception)):
await self.bt_client.set_plejd_time(self.bt_device.ble_address, time)
else:
result = await self.bt_client.set_plejd_time(self.bt_device, time)
result = await self.bt_client.set_plejd_time(self.bt_device.ble_address, time)
assert result == expected_result
self.bt_client.send_command.assert_called_once_with(
self.bt_device.ble_address,
Expand Down

0 comments on commit 507cdbd

Please sign in to comment.