diff options
| -rw-r--r-- | README.md | 66 | ||||
| -rw-r--r-- | debian/changelog | 6 | ||||
| -rw-r--r-- | momonga/momonga.py | 94 | ||||
| -rw-r--r-- | momonga/momonga_device_strategy.py | 69 | ||||
| -rw-r--r-- | momonga/momonga_echonet_data.py | 19 | ||||
| -rw-r--r-- | momonga/momonga_echonet_enum.py | 8 | ||||
| -rw-r--r-- | momonga/momonga_exception.py | 8 | ||||
| -rw-r--r-- | momonga/momonga_response.py | 156 | ||||
| -rw-r--r-- | momonga/momonga_session_manager.py | 235 | ||||
| -rw-r--r-- | momonga/momonga_sk_wrapper.py | 56 | ||||
| -rw-r--r-- | setup.py | 2 | ||||
| -rw-r--r-- | tests/test_device_strategy_unit.py | 216 | ||||
| -rw-r--r-- | tests/test_notification_unit.py | 201 | ||||
| -rw-r--r-- | tests/test_sk_parser_unit.py | 158 | ||||
| -rw-r--r-- | tests/test_xmit_gate_unit.py | 215 | ||||
| -rw-r--r-- | tests/test_xmitter_unit.py | 130 |
16 files changed, 1317 insertions, 322 deletions
@@ -16,6 +16,8 @@ MomongaはBルートサービスを利用してスマートメーターと通信 # Note - Momongaは`WOPT 01\r`コマンドを実行して、Wi-SUNモジュールがUDPパケットのペイロードをASCIIフォーマットで出力するように設定します。注意: WOPTコマンドは実行回数に制限があるので初回のみ実行し、その設定はWi-SUNモジュールに保存されます。 - 一部のWi-SUNモジュールでは`ROPT`コマンドが`FAIL ER04`を返しサポートされません。その場合MomongaはASCII出力で動作していると仮定し、`WOPT`コマンドを実行せずに処理を継続します。 +- メソッドは物理量に即して命名しており、ECHONETの英語版ドキュメントの表記とは必ずしも一致しません。対応するEPCを調べる場合はメソッド名ではなくEPCコードで検索してください。 +- 送信ブロッキングなど諸条件により応答が遅延することがあるため、`get_historical_cumulative_energy_1()`は日を跨ぐタイミングで実行すべきではありません。 # Installation ```shell @@ -44,7 +46,7 @@ with momonga.Momonga(rbid, pwd, dev) as mo: - rbid: BルートID - pwd: Bルートパスワード - dev: Wi-SUNモジュールのデバイスファイルへのパス -- baudrate: シリアル通信のボーレート(デフォルト: 11520) +- baudrate: シリアル通信のボーレート(デフォルト: 115200) ### Return Value - mo: Momongaクラスのインスタンス @@ -61,7 +63,7 @@ PANAセッション管理レイヤのログ ## momonga.sk_wrapper_logger Wi-SUNモジュールとの通信ログ -## ログを有効にした例 +## Logging Example ```python3 import momonga import time @@ -101,9 +103,9 @@ PANAセッションを確立できなかったときに送出される。Bルー スマートメーターに対してコマンドを送信できなかったなどの理由で、スマートメーターに再接続が必要なときに送出される。 ## momonga.MomongaResponseNotPossible -スマートメーターがリクエストしたEPC (ECHONET Property Code) をサポートしていなかったとき送出される。スマートメーターに対して複数のEPCを同時に発行したとき、ひとつでもサポートされていないEPCがあるとこのエクセプションが送出される。スマートメーターがサポートしているEPCはmomonga.set_properties_to_get_values()、momonga.get_properties_to_get_values()で取得できる。 +スマートメーターがリクエストしたEPC (ECHONET Property Code) をサポートしていなかったとき送出される。スマートメーターに対して複数のEPCを同時に発行したとき、ひとつでもサポートされていないEPCがあるとこのエクセプションが送出される。スマートメーターがサポートしているEPCはmomonga.get_properties_to_set_values()、momonga.get_properties_to_get_values()で取得できる。 -## 例外を捕捉する例 +## Exception Handling Example ```python3 import momonga import time @@ -131,7 +133,6 @@ while True: 下記のイベントが発生したときMomongaはスマートメーターに対するコマンドの送信をブロッキングします。 1. PANAセッションのライフタイムが近づきWi-SUNモジュールが自動再認証を試みているとき 2. 送信データ量が規定値に達しWi-SUNモジュールが送信制限しているとき -3. 何らかの理由でシリアルデバイスとの通信がブロッキングされたとき したがって開発者はデータ設定または取得関数を呼び出したあと即座に応答が返ってこない可能性を考慮してください。 @@ -153,7 +154,7 @@ with momonga.Momonga(rbid, pwd, dev) as mo: while True: notif = mo.get_notification(timeout=2400) if notif is None: - continue # タイムアウト + continue # timed out esv = notif['esv'] for epc, value in notif['properties'].items(): print(f'ESV: {esv.name}, EPC: {epc}, value: {value}') @@ -177,9 +178,6 @@ async def main(): asyncio.run(main()) ``` -# Consideration -- 送信がブロッキングされるなど諸条件により関数呼び出しのあと応答が即座に返らないことがあるため、`momonga.get_historical_cumulative_energy_1()`は呼び出したときに期待した履歴の日付と結果の日付に齟齬が生じる可能性があることに注意してください。特にこの関数は日を跨ぐタイミングで実行すべきではありません。 - # API ## momonga.Momonga(rbid: str, pwd: str, dev: str, baudrate: int = 115200, reset_dev: bool = True, reopen_delays: Iterable[float] | None = None) Momongaクラスのインスタンス化。 @@ -195,10 +193,10 @@ e.g. ```python3 from itertools import repeat -# 3回まで10分おきに再接続する +# reconnect up to 3 times, 10 minutes apart momonga.Momonga(rbid, pwd, dev, reopen_delays=[600.0, 600.0, 600.0]) -# 10分おきに無期限で再接続する +# reconnect indefinitely, 10 minutes apart momonga.Momonga(rbid, pwd, dev, reopen_delays=repeat(600.0)) ``` @@ -326,6 +324,20 @@ Bルート識別番号を取得する。 ### Return Value - dict: {'manufacturer code': manufacturer_code, 'authentication id': authentication_id} +## momonga.get_one_minute_measured_cumulative_energy() +1分毎の積算電力量計測値を取得する。 +### Arguments +- Void +### Return Value +- dict: 収集日時と正方向および逆方向の積算電力量(kWh) + +e.g. +```python3 +{'timestamp': datetime.datetime, + 'cumulative energy': {'normal direction': int | float | None, + 'reverse direction': int | float | None}} +``` + ## momonga.get_coefficient_for_cumulative_energy() 積算電力量計測値、履歴を実使用量に換算する係数を取得する。Momongaが出力する結果には適宜この値が乗じられている。 ### Arguments @@ -335,6 +347,7 @@ Bルート識別番号を取得する。 ## momonga.get_number_of_effective_digits_for_cumulative_energy() 積算電力量計測値の有効桁数を取得する。 +### Arguments - Void ### Return Value - int: 有効桁数 @@ -376,7 +389,7 @@ e.g. - None ## momonga.get_day_for_historical_data_1() -積算履歴収集日1を設定する。 +積算履歴収集日1を取得する。 ### Arguments - Void ### Return Value @@ -415,7 +428,7 @@ e.g. 'cumulative energy': int | float} ``` -## momonga.get_historical_cumulative_energy_2(timestamp: datetime.datetime = None, num_of_data_points: int = 12) +## momonga.get_historical_cumulative_energy_2(timestamp: datetime.datetime | None = None, num_of_data_points: int = 12) 積算履歴収集日時、収集コマ数ならびに積算電力量の計測結果履歴を、正・逆 30 分毎のデータで過去最大6時間分取得する。 ### Arguments - timestamp: 収集日時 (Noneのときは現時刻) @@ -433,7 +446,7 @@ e.g. ## momonga.set_time_for_historical_data_2(timestamp: datetime.datetime, num_of_data_points: int = 12) 積算履歴収集日時ならびに収集コマ数を設定する。 ### Arguments -- timestamp: 収集日時 (Noneのときは現時刻) +- timestamp: 収集日時 - num_of_data_points: 収集コマ数 ### Return Value - None @@ -451,7 +464,7 @@ e.g. 'number of data points': int} ``` -## momonga.get_historical_cumulative_energy_3(timestamp: datetime.datetime = None, num_of_data_points: int = 10) +## momonga.get_historical_cumulative_energy_3(timestamp: datetime.datetime | None = None, num_of_data_points: int = 10) 積算履歴収集日時、収集コマ数ならびに積算電力量の計測結果履歴を、正・逆 1 分毎のデータで過去最大6時間分取得する。 ### Arguments - timestamp: 収集日時 (Noneのときは現時刻) @@ -469,7 +482,7 @@ e.g. ## momonga.set_time_for_historical_data_3(timestamp: datetime.datetime, num_of_data_points: int = 10) 積算履歴収集日時ならびに収集コマ数を設定する。 ### Arguments -- timestamp: 収集日時 (Noneのときは現時刻) +- timestamp: 収集日時 - num_of_data_points: 収集コマ数 ### Return Value - None @@ -524,6 +537,25 @@ with momonga.Momonga(rbid, pwd, dev) as mo: 'properties': {momonga.EchonetPropertyCode.cumulative_energy_measured_at_fixed_time: ...}} ``` +### Note: timeout=None is not recommended +PANAセッションが切断された場合でも、スマートメーターやWi-SUNモジュールがEVENTを送出せずに沈黙する状態(電波途絶、スマートメーター電源断など)では、MomongaはPANAセッションの切断を検知できない。その場合`get_notification(timeout=None)`は無期限にブロッキングする。 + +有限のtimeoutを設定し、`None`が一定回数連続した場合はスマートメーターにコマンドを送信してセッションの疎通を確認することを推奨する。セッションが切断されていれば`MomongaNeedToReopen`が送出される。 + +```python3 +with momonga.Momonga(rbid, pwd, dev) as mo: + consecutive_timeouts = 0 + while True: + notif = mo.get_notification(timeout=2400) # 40 minutes + if notif is None: + consecutive_timeouts += 1 + if consecutive_timeouts >= 3: + mo.get_operation_status() # raises MomongaNeedToReopen if session is lost + continue + consecutive_timeouts = 0 + # process notification +``` + # AsyncMomonga `AsyncMomonga`は`Momonga`の全メソッドを`asyncio`で利用できるラッパークラスです。内部的に`asyncio.to_thread()`を使用しており、`Momonga`のブロッキング処理をイベントループをブロックせずに実行できます。 @@ -557,7 +589,7 @@ async def main(): ## async AsyncMomonga.get_notification(timeout: int | float | None = None) 同期版`get_notification()`と同じ動作。タイムアウト時は`None`を返す。 -## その他のメソッド +## Other Methods `Momonga`の全メソッドに対応する`async`版が定義されています。`await mo.メソッド名()`の形式で呼び出せます。 ## Feedback diff --git a/debian/changelog b/debian/changelog index d70ac55..285666a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +python-momonga (0.6.0-1) unstable; urgency=medium + + * New upstream release. + + -- Edward Betts <edward@4angle.com> Sun, 21 Jun 2026 11:24:22 +0100 + python-momonga (0.5.0-1) unstable; urgency=medium * New upstream release. diff --git a/momonga/momonga.py b/momonga/momonga.py index 9b57977..767ef15 100644 --- a/momonga/momonga.py +++ b/momonga/momonga.py @@ -15,6 +15,9 @@ from .momonga_echonet_data import (EchonetProperty, energy_parsers) from .momonga_echonet_enum import (EchonetServiceCode, EchonetPropertyCode, ECHONET_LITE_EHD, ECHONET_LITE_PORT, + ECHONET_EHD_SLICE, ECHONET_TID_SLICE, + ECHONET_SEOJ_SLICE, ECHONET_DEOJ_SLICE, + ECHONET_ESV_OFFSET, ECHONET_OPC_OFFSET, SMART_METER_EOJ, CONTROLLER_EOJ) from .momonga_exception import (MomongaError, MomongaResponseNotExpected, @@ -22,7 +25,7 @@ from .momonga_exception import (MomongaError, MomongaNeedToReopen, MomongaValueError, MomongaRuntimeError) -from .momonga_response import SkEventRxUdp +from .momonga_response import SkEventNum, SkTxResult, SkParsedEvent, SkParsedRxUdp from .momonga_session_manager import MomongaSessionManager from .momonga_session_manager import logger as session_manager_logger from .momonga_sk_wrapper import logger as sk_wrapper_logger @@ -71,8 +74,19 @@ class Momonga: def __exit__(self, type, value, traceback) -> None: self.close() + def _route_meter_frame(self, frame: SkParsedRxUdp) -> None: + seoj = frame.data[ECHONET_SEOJ_SLICE] if len(frame.data) >= 7 else b'' + if seoj != SMART_METER_EOJ: + return + esv = frame.data[ECHONET_ESV_OFFSET] if len(frame.data) > 10 else -1 + if esv in (EchonetServiceCode.inf, EchonetServiceCode.infc): + self.session_manager.notif_q.put(frame) + else: + self.session_manager.recv_q.put(frame) + def open(self) -> Self: logger.info('Opening Momonga.') + self.session_manager.on_meter_frame = self._route_meter_frame self.session_manager.open() time.sleep(self.internal_xmit_interval) self.is_open = True @@ -107,18 +121,20 @@ class Momonga: logger.info('Momonga session reopened successfully.') def get_notification(self, timeout: int | float | None = None) -> dict | None: - if self.is_open is not True: + if not self.is_open: raise MomongaRuntimeError('Momonga is not open.') try: - raw = self.session_manager.notif_q.get(timeout=timeout) + frame = self.session_manager.notif_q.get(timeout=timeout) except queue.Empty: return None - pkt = SkEventRxUdp([raw], self.session_manager.skw.device_type) - data = pkt.data - esv = data[10] - opc = data[11] + data = frame.data + if len(data) < 12: + logger.warning('Received a malformed notification frame (too short: %d bytes). Discarding.' % len(data)) + return None + esv = data[ECHONET_ESV_OFFSET] + opc = data[ECHONET_OPC_OFFSET] if esv == EchonetServiceCode.infc: self.__send_infc_res(data) @@ -151,9 +167,9 @@ class Momonga: return {'esv': EchonetServiceCode(esv), 'properties': properties} def __send_infc_res(self, infc_data: bytes) -> None: - tid_int = int.from_bytes(infc_data[2:4], 'big') + tid_int = int.from_bytes(infc_data[ECHONET_TID_SLICE], 'big') header = self.__build_request_header(tid_int, EchonetServiceCode.infc_res) - opc = infc_data[11] + opc = infc_data[ECHONET_OPC_OFFSET] props = b'' cur = 12 for _ in range(opc): @@ -217,26 +233,26 @@ class Momonga: tid: int, req_properties: list[EchonetPropertyWithData] | list[EchonetProperty], ) -> list[EchonetPropertyWithData]: - ehd = data[0:2] + ehd = data[ECHONET_EHD_SLICE] if ehd != ECHONET_LITE_EHD: raise MomongaResponseNotExpected('The data format is not ECHONET Lite EDATA format 1') - if data[2:4] != tid.to_bytes(4, 'big')[-2:]: + if data[ECHONET_TID_SLICE] != tid.to_bytes(4, 'big')[-2:]: raise MomongaResponseNotExpected('The transaction ID does not match.') - seoj = data[4:7] + seoj = data[ECHONET_SEOJ_SLICE] if seoj != SMART_METER_EOJ: raise MomongaResponseNotExpected('The source is not a smart meter.') - deoj = data[7:10] + deoj = data[ECHONET_DEOJ_SLICE] if deoj != CONTROLLER_EOJ: raise MomongaResponseNotExpected('The destination is not a controller.') - esv = data[10] + esv = data[ECHONET_ESV_OFFSET] if 0x50 <= esv <= 0x5F: raise MomongaResponseNotPossible('The target smart meter could not respond. ESV: %X' % esv) - opc = data[11] + opc = data[ECHONET_OPC_OFFSET] req_opc = len(req_properties) if opc != req_opc: raise MomongaResponseNotExpected( @@ -272,7 +288,7 @@ class Momonga: req_properties: list[EchonetPropertyWithData] | list[EchonetProperty], ) -> list[EchonetPropertyWithData]: logger.debug('Checking if Momonga is open: is_open=%s', self.is_open) - if self.is_open is not True: + if not self.is_open: raise MomongaRuntimeError('Momonga is not open.') with self._request_lock: @@ -302,43 +318,43 @@ class Momonga: logger.warning('The request for transaction id "%04X" timed out.' % tid) break # to rexmit the request. - # messages of event types 21, 02, and received udp payloads will only be delivered. - if res.startswith('EVENT 21'): - param = res.split()[-1] - if param == '00': - logger.info('Successfully transmitted a request packet for transaction id "%04X".' % tid) - continue - elif param == '01': - logger.info('Retransmitting the request packet for transaction id "%04X".' % tid) - time.sleep(self.internal_xmit_interval) - break # to rexmit the request. - elif param == '02': - logger.info('Transmitting neighbor solicitation packets.') + if isinstance(res, SkParsedEvent): + if res.num == SkEventNum.tx_done: + param = res.param + if param == SkTxResult.success: + logger.info('Successfully transmitted a request packet for transaction id "%04X".' % tid) + continue + elif param == SkTxResult.failure: + logger.info('Retransmitting the request packet for transaction id "%04X".' % tid) + time.sleep(self.internal_xmit_interval) + break # to rexmit the request. + elif param == SkTxResult.neighbor_solicitation: + logger.info('Transmitting neighbor solicitation packets.') + continue + else: + logger.debug('A message for event 21 with an unknown parameter "%s" will be ignored.' % param) + continue + elif res.num == SkEventNum.neighbor_discovery: + logger.info('Received a neighbor advertisement packet.') continue else: - logger.debug('A message for event 21 with an unknown parameter "%s" will be ignored.' % param) continue - elif res.startswith('EVENT 02'): - logger.info('Received a neighbor advertisement packet.') - continue - elif res.startswith('ERXUDP'): - udp_pkt = SkEventRxUdp([res], self.session_manager.skw.device_type) - if not (udp_pkt.src_port == udp_pkt.dst_port == ECHONET_LITE_PORT): + elif isinstance(res, SkParsedRxUdp): + if not (res.src_port == res.dst_port == ECHONET_LITE_PORT): continue - elif udp_pkt.side: + elif res.side: continue - elif udp_pkt.src_addr != self.session_manager.smart_meter_addr: + elif res.src_addr != self.session_manager.smart_meter_addr: continue try: - res_properties = self.__extract_response_payload(udp_pkt.data, tid, req_properties) + res_properties = self.__extract_response_payload(res.data, tid, req_properties) except MomongaResponseNotExpected: continue logger.info('Successfully received a response packet for transaction id "%04X".' % tid) return res_properties else: - # this line should never be reached. continue logger.error('Gave up to obtain a response for transaction id "%04X". Close Momonga and open it again.' % tid) raise MomongaNeedToReopen('Gave up to obtain a response for transaction id "%04X".' diff --git a/momonga/momonga_device_strategy.py b/momonga/momonga_device_strategy.py new file mode 100644 index 0000000..c42b56e --- /dev/null +++ b/momonga/momonga_device_strategy.py @@ -0,0 +1,69 @@ +from collections.abc import Callable + +from .momonga_device_enum import DeviceType +from .momonga_response import DeviceStrategy, SkParsedEvent, SkParsedRxUdp + + +class BP35C2Strategy(DeviceStrategy): + device_type = DeviceType.BP35C2 + + def parse_event(self, parts: list[str]) -> SkParsedEvent | None: + # BP35C2 EVENT format includes SIDE: EVENT num addr side [param] + # Verified on hardware: b'EVENT 21 FE80:... 0 00\r\n' + if len(parts) < 3: + return None + side = int(parts[3], 16) if len(parts) > 3 else None + param = int(parts[4], 16) if len(parts) > 4 else None + return SkParsedEvent(num=int(parts[1], 16), src_addr=parts[2], side=side, param=param) + + def parse_erxudp(self, parts: list[str]) -> SkParsedRxUdp | None: + if len(parts) < 11: + return None + lqi = int(parts[6], 16) + return SkParsedRxUdp( + src_addr=parts[1], dst_addr=parts[2], + src_port=int(parts[3], 16), dst_port=int(parts[4], 16), + src_mac=bytes.fromhex(parts[5]), + lqi=lqi, rssi=0.275 * lqi - 104.27, + sec=int(parts[7], 16), side=int(parts[8], 16), + data=bytes.fromhex(parts[10]), + ) + + def skscan_command(self, duration: int) -> list[str]: + return ['SKSCAN', '2', 'FFFFFFFF', str(duration), '0'] + + def sksendto_args(self, handle: int, ip6_addr: str, port: int, sec: int, side: int, length: int) -> list[str]: + return ['SKSENDTO', str(handle), ip6_addr, '%04X' % port, str(sec), str(side), '%04X' % length] + + def decode_scan_side(self, extract: Callable[[str], str]) -> int | None: + return int(extract('Side:').split(':')[-1], 16) + + +class BP35A1Strategy(DeviceStrategy): + device_type = DeviceType.BP35A1 + + def parse_event(self, parts: list[str]) -> SkParsedEvent | None: + if len(parts) < 3: + return None + param = int(parts[3], 16) if len(parts) > 3 else None + return SkParsedEvent(num=int(parts[1], 16), src_addr=parts[2], side=None, param=param) + + def parse_erxudp(self, parts: list[str]) -> SkParsedRxUdp | None: + if len(parts) < 9: + return None + return SkParsedRxUdp( + src_addr=parts[1], dst_addr=parts[2], + src_port=int(parts[3], 16), dst_port=int(parts[4], 16), + src_mac=bytes.fromhex(parts[5]), + sec=int(parts[6], 16), side=None, + data=bytes.fromhex(parts[8]), + ) + + def skscan_command(self, duration: int) -> list[str]: + return ['SKSCAN', '2', 'FFFFFFFF', str(duration)] + + def sksendto_args(self, handle: int, ip6_addr: str, port: int, sec: int, side: int, length: int) -> list[str]: + return ['SKSENDTO', str(handle), ip6_addr, '%04X' % port, str(sec), '%04X' % length] + + def decode_scan_side(self, extract: Callable[[str], str]) -> int | None: + return None diff --git a/momonga/momonga_echonet_data.py b/momonga/momonga_echonet_data.py index f61c2ac..407f9c6 100644 --- a/momonga/momonga_echonet_data.py +++ b/momonga/momonga_echonet_data.py @@ -1,9 +1,12 @@ import datetime import inspect +from collections.abc import Callable from .momonga_echonet_enum import EchonetPropertyCode from .momonga_exception import MomongaRuntimeError, MomongaValueError +_CUMULATIVE_ENERGY_MISSING = 0xFFFFFFFE + class EchonetProperty: def __init__(self, @@ -160,14 +163,14 @@ class EchonetDataParser: edt[2], edt[3], edt[4], edt[5], edt[6]) normal_direction_energy = int.from_bytes(edt[7:11], 'big') - if normal_direction_energy == 0xFFFFFFFE: + if normal_direction_energy == _CUMULATIVE_ENERGY_MISSING: normal_direction_energy = None else: normal_direction_energy *= energy_unit normal_direction_energy *= energy_coefficient reverse_direction_energy = int.from_bytes(edt[11:15], 'big') - if reverse_direction_energy == 0xFFFFFFFE: + if reverse_direction_energy == _CUMULATIVE_ENERGY_MISSING: reverse_direction_energy = None else: reverse_direction_energy *= energy_unit @@ -232,7 +235,7 @@ class EchonetDataParser: for i in range(48): j = i * 4 cumulative_energy = int.from_bytes(energy_data_points[j:j + 4], 'big') - if cumulative_energy == 0xFFFFFFFE: + if cumulative_energy == _CUMULATIVE_ENERGY_MISSING: cumulative_energy = None else: cumulative_energy *= energy_unit @@ -290,14 +293,14 @@ class EchonetDataParser: for i in range(num_of_data_points): j = i * 8 normal_direction_energy = int.from_bytes(energy_data_points[j:j + 4], 'big') - if normal_direction_energy == 0xFFFFFFFE: + if normal_direction_energy == _CUMULATIVE_ENERGY_MISSING: normal_direction_energy = None else: normal_direction_energy *= energy_unit normal_direction_energy *= energy_coefficient reverse_direction_energy = int.from_bytes(energy_data_points[j + 4:j + 8], 'big') - if reverse_direction_energy == 0xFFFFFFFE: + if reverse_direction_energy == _CUMULATIVE_ENERGY_MISSING: reverse_direction_energy = None else: reverse_direction_energy *= energy_unit @@ -339,14 +342,14 @@ class EchonetDataParser: for i in range(num_of_data_points): j = i * 8 normal_direction_energy = int.from_bytes(energy_data_points[j:j + 4], 'big') - if normal_direction_energy == 0xFFFFFFFE: + if normal_direction_energy == _CUMULATIVE_ENERGY_MISSING: normal_direction_energy = None else: normal_direction_energy *= energy_unit normal_direction_energy *= energy_coefficient reverse_direction_energy = int.from_bytes(energy_data_points[j + 4:j + 8], 'big') - if reverse_direction_energy == 0xFFFFFFFE: + if reverse_direction_energy == _CUMULATIVE_ENERGY_MISSING: reverse_direction_energy = None else: reverse_direction_energy *= energy_unit @@ -373,7 +376,7 @@ class EchonetDataParser: 'number of data points': num_of_data_points} -parser_map: dict[EchonetPropertyCode, callable] = { +parser_map: dict[EchonetPropertyCode, Callable] = { EchonetPropertyCode.operation_status: EchonetDataParser.parse_operation_status, EchonetPropertyCode.installation_location: EchonetDataParser.parse_installation_location, EchonetPropertyCode.standard_version_information: EchonetDataParser.parse_standard_version_information, diff --git a/momonga/momonga_echonet_enum.py b/momonga/momonga_echonet_enum.py index 6ca04c6..39f5cd5 100644 --- a/momonga/momonga_echonet_enum.py +++ b/momonga/momonga_echonet_enum.py @@ -4,6 +4,14 @@ import enum ECHONET_LITE_EHD = b'\x10\x81' # Frame header: EHD1=0x10 (ECHONET Lite), EHD2=0x81 (EDATA format 1) ECHONET_LITE_PORT = 0x0E1A # Standard UDP port (3610) +# ECHONET Lite frame field offsets (EDATA format 1) +ECHONET_EHD_SLICE = slice(0, 2) # Frame header (2 bytes) +ECHONET_TID_SLICE = slice(2, 4) # Transaction ID (2 bytes) +ECHONET_SEOJ_SLICE = slice(4, 7) # Source EOJ (3 bytes) +ECHONET_DEOJ_SLICE = slice(7, 10) # Destination EOJ (3 bytes) +ECHONET_ESV_OFFSET = 10 # Service code (1 byte) +ECHONET_OPC_OFFSET = 11 # Operation count (1 byte) + # ECHONET Lite Object Identifiers used in B-route communication SMART_METER_EOJ = b'\x02\x88\x01' # Low-voltage smart electric energy meter (class 0x0288, instance 0x01) CONTROLLER_EOJ = b'\x05\xFF\x01' # Controller (home energy management system) diff --git a/momonga/momonga_exception.py b/momonga/momonga_exception.py index e8daf17..6132a19 100644 --- a/momonga/momonga_exception.py +++ b/momonga/momonga_exception.py @@ -38,19 +38,19 @@ class MomongaSkJoinFailure(MomongaError): pass -class MomongaRuntimeError(RuntimeError): +class MomongaRuntimeError(MomongaError, RuntimeError): pass -class MomongaValueError(ValueError): +class MomongaValueError(MomongaError, ValueError): pass -class MomongaTimeoutError(TimeoutError): +class MomongaTimeoutError(MomongaError, TimeoutError): pass -class MomongaKeyError(KeyError): +class MomongaKeyError(MomongaError, KeyError): pass diff --git a/momonga/momonga_response.py b/momonga/momonga_response.py index d550b45..2d83e0f 100644 --- a/momonga/momonga_response.py +++ b/momonga/momonga_response.py @@ -1,8 +1,92 @@ import logging +from collections.abc import Callable +from dataclasses import dataclass +from enum import IntEnum +from typing import Protocol from .momonga_exception import MomongaKeyError from .momonga_device_enum import DeviceType + +class SkEventNum(IntEnum): + """Wi-SUN module event numbers (parsed from hex strings, e.g. 'EVENT 21' → 0x21).""" + neighbor_discovery = 0x02 + tx_done = 0x21 + rejoin_failed = 0x24 + rejoined = 0x25 + session_closed = 0x27 + no_session = 0x28 + session_lifetime = 0x29 + rate_limit_exceeded = 0x32 + rate_limit_released = 0x33 + + +class SkTxResult(IntEnum): + """Param values for EVENT tx_done (0x21).""" + success = 0x00 + failure = 0x01 + neighbor_solicitation = 0x02 + + +@dataclass(frozen=True) +class SkParsedEvent: + """Typed representation of an EVENT line from the Wi-SUN module.""" + num: int # event number parsed as hex (e.g. 'EVENT 21' → int('21',16) = 33) + src_addr: str + side: int | None + param: int | None # present only for EVENT 21 (tx result) + + +@dataclass(frozen=True) +class SkParsedRxUdp: + """Typed representation of an ERXUDP line from the Wi-SUN module.""" + src_addr: str + dst_addr: str + src_port: int + dst_port: int + src_mac: bytes + side: int | None + sec: int + data: bytes + lqi: int | None = None + rssi: float | None = None + + +class DeviceStrategy(Protocol): + """Encapsulates all behavior that differs between Wi-SUN module models.""" + device_type: DeviceType + + def parse_event(self, parts: list[str]) -> SkParsedEvent | None: ... + def parse_erxudp(self, parts: list[str]) -> SkParsedRxUdp | None: ... + def skscan_command(self, duration: int) -> list[str]: ... + def sksendto_args(self, handle: int, ip6_addr: str, port: int, sec: int, side: int, length: int) -> list[str]: ... + def decode_scan_side(self, extract: Callable[[str], str]) -> int | None: ... + + +def parse_sk_line(line: str, strategy: DeviceStrategy) -> SkParsedEvent | SkParsedRxUdp | None: + """Parse a raw Wi-SUN serial line into a typed event object. + + Returns None for lines that are not EVENT or ERXUDP (e.g. OK, EPANDESC). + """ + parts = line.split() + if not parts: + return None + + if parts[0] == 'EVENT': + try: + return strategy.parse_event(parts) + except (ValueError, IndexError): + return None + + if parts[0] == 'ERXUDP': + try: + return strategy.parse_erxudp(parts) + except (ValueError, IndexError): + return None + + return None + + logger = logging.getLogger(__name__) @@ -21,12 +105,6 @@ class MomongaSkResponseBase: raise MomongaKeyError(key) -class MomongaSkDeviceDependentResponse(MomongaSkResponseBase): - def __init__(self, res, device_type: DeviceType): - self.device_type = device_type - super().__init__(res) - - class SkVerResponse(MomongaSkResponseBase): def decode(self): res_list = self.extract('EVER').split() @@ -49,7 +127,11 @@ class SkInfoResponse(MomongaSkResponseBase): self.side = int(res_list[5], 16) -class SkScanResponse(MomongaSkDeviceDependentResponse): +class SkScanResponse(MomongaSkResponseBase): + def __init__(self, res, strategy: DeviceStrategy): + self.strategy = strategy + super().__init__(res) + def decode(self): self.channel = int(self.extract('Channel:').split(':')[-1], 16) self.channel_page = int(self.extract('Channel Page:').split(':')[-1], 16) @@ -57,68 +139,10 @@ class SkScanResponse(MomongaSkDeviceDependentResponse): self.mac_addr = bytes.fromhex(self.extract('Addr:').split(':')[-1]) self.lqi = int(self.extract('LQI:').split(':')[-1], 16) self.rssi = 0.275 * self.lqi - 104.27 - match self.device_type: - case DeviceType.BP35A1: - self.side = None - case DeviceType.BP35C2: - self.side = int(self.extract('Side:').split(':')[-1], 16) - case _: - logger.warning('Unknown device type "%s" detected in SkScanResponse. Assuming BP35C2 behavior.', self.device_type) - self.side = int(self.extract('Side:').split(':')[-1], 16) + self.side = self.strategy.decode_scan_side(self.extract) self.pair_id = bytes.fromhex(self.extract('PairID:').split(':')[-1]) class SkLl64Response(MomongaSkResponseBase): def decode(self): self.ip6_addr = self.extract('FE80:') - - -class SkSendToResponse(MomongaSkDeviceDependentResponse): - def decode(self): - self.res_list = self.extract('EVENT 21').split() - self.event_num = int(self.res_list[1], 16) - self.src_addr = self.res_list[2] - match self.device_type: - case DeviceType.BP35A1: - self.side = None - self.param = int(self.res_list[3], 16) - case DeviceType.BP35C2: - self.side = int(self.res_list[3], 16) - self.param = int(self.res_list[4], 16) - case _: - logger.warning('Unknown device type "%s" detected in SkSendToResponse. Assuming BP35C2 behavior.', self.device_type) - self.side = int(self.res_list[3], 16) - self.param = int(self.res_list[4], 16) - - -class SkEventRxUdp(MomongaSkDeviceDependentResponse): - def decode(self): - self.res_list = self.extract('ERXUDP').split() - self.src_addr = self.res_list[1] - self.des_addr = self.res_list[2] - self.src_port = int(self.res_list[3], 16) - self.dst_port = int(self.res_list[4], 16) - self.src_mac = bytes.fromhex(self.res_list[5]) - match self.device_type: - case DeviceType.BP35A1: - self.lqi = None - self.rssi = None - self.sec = int(self.res_list[6], 16) - self.side = None - self.data_len = int(self.res_list[7], 16) - self.data = bytes.fromhex(self.res_list[8]) - case DeviceType.BP35C2: - self.lqi = int(self.res_list[6], 16) - self.rssi = 0.275 * self.lqi - 104.27 - self.sec = int(self.res_list[7], 16) - self.side = int(self.res_list[8], 16) - self.data_len = int(self.res_list[9], 16) - self.data = bytes.fromhex(self.res_list[10]) - case _: - logger.warning('Unknown device type "%s" detected in SkEventRxUdp. Assuming BP35C2 behavior.', self.device_type) - self.lqi = int(self.res_list[6], 16) - self.rssi = 0.275 * self.lqi - 104.27 - self.sec = int(self.res_list[7], 16) - self.side = int(self.res_list[8], 16) - self.data_len = int(self.res_list[9], 16) - self.data = bytes.fromhex(self.res_list[10]) diff --git a/momonga/momonga_session_manager.py b/momonga/momonga_session_manager.py index 777b5ef..f99ce22 100644 --- a/momonga/momonga_session_manager.py +++ b/momonga/momonga_session_manager.py @@ -3,14 +3,15 @@ import threading import queue import time +from collections.abc import Callable from typing import Self -from .momonga_echonet_enum import EchonetServiceCode, SMART_METER_EOJ from .momonga_exception import (MomongaSkScanFailure, MomongaSkJoinFailure, MomongaNeedToReopen, MomongaSkCommandExecutionFailure, ) +from .momonga_response import SkEventNum, SkParsedEvent, SkParsedRxUdp, parse_sk_line from .momonga_sk_wrapper import MomongaSkWrapper from .momonga_sk_wrapper import logger as sk_wrapper_logger @@ -44,10 +45,15 @@ class MomongaSessionManager: self.session_established = False self.receiver_th = None self.receiver_exception = None - self.xmit_restriction_cnt = 0 - self.xmit_lock = threading.Lock() + self.gate_lock = threading.Lock() + self.session_available = True + self.rate_ok = True + self.xmit_allowed = threading.Event() + self.xmit_allowed.set() self.rejoin_lock = threading.Lock() + self.on_meter_frame: Callable[[SkParsedRxUdp], None] | None = None + self.pkt_sbsc_q = queue.Queue() self.recv_q = queue.Queue() self.notif_q = queue.Queue() @@ -135,7 +141,7 @@ class MomongaSessionManager: if not rejoin_lock_acquired: logger.warning('Failed to acquire "rejoin_lock".') - if self.session_established is True: + if self.session_established: try: self.session_established = False logger.info('Terminating the PANA session...') @@ -158,10 +164,8 @@ class MomongaSessionManager: if self.skw.subscribers.get('pkt_sbsc_q') is not None: self.skw.subscribers.pop('pkt_sbsc_q') - self.unrestrict_to_xmit(force=True) + self.force_open_gates() - if self.xmit_lock.locked(): - logger.error('"xmit_lock" is unexpectedly locked.') if self.rejoin_lock.locked(): logger.error('"rejoin_lock" is unexpectedly locked.') @@ -172,100 +176,102 @@ class MomongaSessionManager: logger.debug('A packet receiver has been started.') try: while True: - res = self.pkt_sbsc_q.get() - if res == '__CLOSE__': + raw = self.pkt_sbsc_q.get() + if raw == '__CLOSE__': break - if not (res.startswith('EVENT') or res.startswith('ERXUDP')): - # messages that do not need to be handled will be discarded. - continue - - if res.startswith('EVENT 29'): - logger.debug('The PANA session lifetime has been expired.') - self.restrict_to_xmit() - elif res.startswith('EVENT 24'): - logger.warning('Could not rejoin the PAN.') - self.rejoin_lock.acquire() - if self.session_established is True: - self.session_established = False - try: - self.skw.skjoin(self.smart_meter_addr) - except MomongaSkJoinFailure as e: - logger.error('%s Close Momonga and open it again.' % (e)) - raise MomongaNeedToReopen('%s Close Momonga and open it again.' % (e)) - finally: + parsed = parse_sk_line(raw, self.skw.device_strategy) + + if isinstance(parsed, SkParsedEvent): + num = parsed.num + if num == SkEventNum.session_lifetime: + logger.debug('The PANA session lifetime has been expired.') + self.close_session_gate() + elif num == SkEventNum.rejoin_failed: + logger.warning('Could not rejoin the PAN.') + self.rejoin_lock.acquire() + if self.session_established: + self.session_established = False + try: + self.skw.skjoin(self.smart_meter_addr) + except MomongaSkJoinFailure as e: + logger.error('%s Close Momonga and open it again.' % (e)) + raise MomongaNeedToReopen('%s Close Momonga and open it again.' % (e)) + finally: + self.rejoin_lock.release() + else: self.rejoin_lock.release() - else: - self.rejoin_lock.release() - elif res.startswith('EVENT 25'): - logger.debug('Successfully rejoined the PAN.') - self.session_established = True - self.unrestrict_to_xmit() - elif res.startswith('EVENT 32'): - logger.warning('The transmission rate limit has been exceeded.') - self.restrict_to_xmit() - elif res.startswith('EVENT 33'): - logger.debug('The transmission rate limit has been released.') - self.unrestrict_to_xmit() - elif res.startswith('EVENT 27'): - self.restrict_to_xmit() - logger.debug('The PANA session has been closed successfully.') - elif res.startswith('EVENT 28'): # there was no session to close. - self.restrict_to_xmit() - logger.warning('There was no PANA session to close.') - elif res.startswith("EVENT 21") or res.startswith("EVENT 02"): - if self.is_restricted_to_xmit() is False: - self.recv_q.put(res) - elif res.startswith("ERXUDP"): - try: - data_hex = res.split()[-1] - seoj = data_hex[8:14].upper() if len(data_hex) >= 14 else '' - esv = int(data_hex[20:22], 16) if len(data_hex) >= 22 else -1 - except (ValueError, IndexError): - seoj = '' - esv = -1 - if seoj != SMART_METER_EOJ.hex().upper(): - pass # discard packets from non-smart-meter ECHONET objects - elif esv in (EchonetServiceCode.inf, EchonetServiceCode.infc): - self.notif_q.put(res) - else: - self.recv_q.put(res) - else: - # other events that do not need to be handled will be discarded. - continue + elif num == SkEventNum.rejoined: + logger.debug('Successfully rejoined the PAN.') + self.session_established = True + self.open_session_gate() + elif num == SkEventNum.rate_limit_exceeded: + logger.warning('The transmission rate limit has been exceeded.') + self.close_rate_gate() + elif num == SkEventNum.rate_limit_released: + logger.debug('The transmission rate limit has been released.') + self.open_rate_gate() + elif num == SkEventNum.session_closed: + self.close_session_gate() + logger.debug('The PANA session has been closed successfully.') + elif num == SkEventNum.no_session: + self.close_session_gate() + logger.warning('There was no PANA session to close.') + elif num in (SkEventNum.tx_done, SkEventNum.neighbor_discovery): + if not self.is_restricted_to_xmit(): + self.recv_q.put(parsed) + + elif isinstance(parsed, SkParsedRxUdp): + if parsed.src_addr == self.smart_meter_addr and self.on_meter_frame is not None: + # A slow callback delays all subsequent EVENT processing (e.g. EVENT 32/33). + try: + self.on_meter_frame(parsed) + except Exception as e: + logger.error('on_meter_frame raised an exception. %s: %s' % (type(e).__name__, e)) + except Exception as e: logger.error('An exception was raised from the receiver thread. %s: %s' % (type(e).__name__, e)) self.receiver_exception = e logger.debug('The packet receiver has been stopped.') + # Design note: the transmission gate is an optimization, not a correctness guarantee. + # There is an intentional check-then-act race window between xmit_allowed.wait() and + # sksendto(): the gate may close (e.g. EVENT 29 arrives) after the check but before + # the send. Plugging this window with a send-hold lock is not feasible — PANA session + # state and rate limiting live in the SK module firmware and cannot be controlled + # atomically from Python. Correctness is instead guaranteed by EVENT 21 result + # handling and the retry loop in __request_locked(): a failed or timed-out send is + # simply retried. The gate's value is reducing unnecessary sends during known-bad + # states, not providing atomicity. def xmitter(self, data: bytes, ) -> None: - retry_to_xmit = 3 - retry_to_acquire_xmit_lock = 60 + xmit_retry_limit = 3 + gate_wait_retry_limit = 60 + gate_wait_timeout = 60 xmitted = False - for _ in range(retry_to_xmit): - logger.debug('Trying to acquire "xmit_lock".') - locked = False - for r in range(retry_to_acquire_xmit_lock): - locked = self.xmit_lock.acquire(timeout=60) - if locked is False: - logger.warning('Could not acquire "xmit_lock". (%d/%d)' % (r + 1, retry_to_acquire_xmit_lock)) + for _ in range(xmit_retry_limit): + logger.debug('Waiting for transmission gate to open.') + allowed = False + for r in range(gate_wait_retry_limit): + allowed = self.xmit_allowed.wait(timeout=gate_wait_timeout) + if not allowed: + logger.warning('Transmission gate is still closed. (%d/%d)' % (r + 1, gate_wait_retry_limit)) if self.receiver_exception is not None: logger.error('Got an exception from the receiver thread. %s: %s' % (type(self.receiver_exception).__name__, self.receiver_exception)) raise MomongaNeedToReopen('Got an exception from the receiver thread. %s: %s' % (type(self.receiver_exception).__name__, self.receiver_exception)) else: break - if locked is False: + if not allowed: logger.error('Transmission rights could not be acquired. Close Momonga and open it again.') raise MomongaNeedToReopen('Transmission rights could not be acquired. Close Momonga and open it again.') else: - logger.debug('Acquired "xmit_lock".') + logger.debug('Transmission gate is open.') try: - if self.session_established is False: + if not self.session_established: logger.error('Tried to transmit a packet, but no PANA session was established.') raise MomongaNeedToReopen('No PANA session established. Close Momonga and open it again.') self.skw.sksendto(self.smart_meter_addr, data) @@ -277,48 +283,47 @@ class MomongaSessionManager: raise except Exception as e: logger.warning('An error occurred to transmit a packet. %s: %s' % (type(e).__name__, e)) - finally: - self.xmit_lock.release() time.sleep(3) - if xmitted is False: + if not xmitted: logger.error('Could not transmit a packet. Close Momonga and open it again.') raise MomongaNeedToReopen('Could not transmit a packet. Close Momonga and open it again.') - def restrict_to_xmit(self) -> None: - self.xmit_restriction_cnt += 1 - logger.debug('The counter for the restriction was incremented: %d' % (self.xmit_restriction_cnt)) - - if self.xmit_restriction_cnt > 2: - logger.error('The critical section counter for data transmission is inconsistent: Too big than expected.') - - if self.xmit_restriction_cnt == 1: - logger.debug('Trying to restrict data transmission.') - self.xmit_lock.acquire() - logger.debug('Data transmission is being restricted.') - - def unrestrict_to_xmit(self, - force=False, - ) -> None: - if force is True: - self.xmit_restriction_cnt = 0 - logger.debug('The counter for the restriction was forcibly set to zero.') - else: - self.xmit_restriction_cnt -= 1 - logger.debug('The counter for the restriction was decremented: %d' % (self.xmit_restriction_cnt)) - - if self.xmit_restriction_cnt < 0: - logger.error('The critical section counter for data transmit is inconsistent: Too small than expected.') - - if self.xmit_restriction_cnt == 0: - try: - self.xmit_lock.release() - except RuntimeError: - pass # xmit_lock is likely already unlocked. + def close_session_gate(self) -> None: + with self.gate_lock: + self.session_available = False + self.xmit_allowed.clear() + logger.debug('Session gate closed.') + + def open_session_gate(self) -> None: + with self.gate_lock: + self.session_available = True + if self.rate_ok: + self.xmit_allowed.set() + logger.debug('Both gates open; transmission allowed.') + else: + logger.debug('Session gate opened but rate gate still closed.') + + def close_rate_gate(self) -> None: + with self.gate_lock: + self.rate_ok = False + self.xmit_allowed.clear() + logger.debug('Rate gate closed.') + + def open_rate_gate(self) -> None: + with self.gate_lock: + self.rate_ok = True + if self.session_available: + self.xmit_allowed.set() + logger.debug('Both gates open; transmission allowed.') + else: + logger.debug('Rate gate opened but session gate still closed.') - logger.debug('Data transmission is being unrestricted.') + def force_open_gates(self) -> None: + with self.gate_lock: + self.session_available = True + self.rate_ok = True + self.xmit_allowed.set() + logger.debug('All gates forcibly opened.') def is_restricted_to_xmit(self) -> bool: - if self.xmit_restriction_cnt == 0: - return False - else: - return True + return not self.xmit_allowed.is_set() diff --git a/momonga/momonga_sk_wrapper.py b/momonga/momonga_sk_wrapper.py index a2b3d81..d681a56 100644 --- a/momonga/momonga_sk_wrapper.py +++ b/momonga/momonga_sk_wrapper.py @@ -15,16 +15,21 @@ from .momonga_exception import (MomongaError, MomongaSkCommandFailedToExecute, MomongaSkScanFailure, MomongaSkJoinFailure) -from .momonga_response import (SkVerResponse, +from .momonga_response import (DeviceStrategy, + SkVerResponse, SkAppVerResponse, SkInfoResponse, SkScanResponse, SkLl64Response) from .momonga_device_enum import DeviceType +from .momonga_device_strategy import BP35C2Strategy, BP35A1Strategy from .momonga_echonet_enum import ECHONET_LITE_PORT logger = logging.getLogger(__name__) +# BP35A1 returns this value for the SKINFO side field (not a real side index) +_BP35A1_SIDE_SENTINEL = 0xFFFE + class MomongaSkWrapper: def __init__(self, @@ -39,7 +44,11 @@ class MomongaSkWrapper: self.publisher_th_breaker = False self.publisher_th = None self.subscribers = {'cmd_exec_q': queue.Queue()} - self.device_type: DeviceType = DeviceType.BP35C2 + self.device_strategy: DeviceStrategy = BP35C2Strategy() + + @property + def device_type(self) -> DeviceType: + return self.device_strategy.device_type def __enter__(self) -> Self: return self.open() @@ -155,7 +164,7 @@ class MomongaSkWrapper: def received_packet_publisher(self) -> None: logger.debug('A received packet publisher has been started.') while True: - if self.publisher_th_breaker is True: + if self.publisher_th_breaker: break line = self.__readline(timeout=1) if line == '': @@ -210,7 +219,7 @@ class MomongaSkWrapper: if r.startswith(w): matched = True break - if matched is True: + if matched: break return res @@ -275,21 +284,12 @@ class MomongaSkWrapper: duration = 6 for _ in range(retry): logger.debug('Trying to scan a PAN... Duration: %d' % duration) - cmd = [] - match self.device_type: - case DeviceType.BP35A1: - cmd = ['SKSCAN', '2', 'FFFFFFFF', str(duration)] - case DeviceType.BP35C2: - cmd = ['SKSCAN', '2', 'FFFFFFFF', str(duration), '0'] - case _: - logger.warning('Unknown device type "%s" detected in skscan. Assuming BP35C2 behavior.', self.device_type) - cmd = ['SKSCAN', '2', 'FFFFFFFF', str(duration), '0'] - res = self.exec_command(cmd, 'EVENT 22') + res = self.exec_command(self.device_strategy.skscan_command(duration), 'EVENT 22') # estimated execution time: 0.0096s*(2^(DURATION=6)+1)*28 = 17.5s # estimated execution time: 0.0096s*(2^(DURATION=7)+1)*28 = 34.7s # estimated execution time: 0.0096s*(2^(DURATION=8)+1)*28 = 69.1s if 'EPANDESC' in res: - return SkScanResponse(res, self.device_type) + return SkScanResponse(res, self.device_strategy) duration += 1 raise MomongaSkScanFailure('Could not find the specified PAN.') @@ -326,30 +326,20 @@ class MomongaSkWrapper: sec: int = 2, side: int = 0, ) -> None: - match self.device_type: - case DeviceType.BP35A1: - self.exec_command(['SKSENDTO', str(handle), ip6_addr, '%04X' % port, - str(sec), '%04X' % len(data)], - payload=data) - case DeviceType.BP35C2: - self.exec_command(['SKSENDTO', str(handle), ip6_addr, '%04X' % port, - str(sec), str(side), '%04X' % len(data)], - payload=data) - case _: - logger.warning('Unknown device type "%s" detected in sksendto. Assuming BP35C2 behavior.', self.device_type) - self.exec_command(['SKSENDTO', str(handle), ip6_addr, '%04X' % port, - str(sec), str(side), '%04X' % len(data)], - payload=data) + self.exec_command( + self.device_strategy.sksendto_args(handle, ip6_addr, port, sec, side, len(data)), + payload=data, + ) def detect_device(self): logger.debug('Trying to detect device...') dev_info = self.skinfo() - if dev_info.side == 65534: + if dev_info.side == _BP35A1_SIDE_SENTINEL: logger.debug('Device type is BP35A1.') - self.device_type = DeviceType.BP35A1 + self.device_strategy = BP35A1Strategy() elif dev_info.side < 2: logger.debug('Device type is BP35C2.') - self.device_type = DeviceType.BP35C2 + self.device_strategy = BP35C2Strategy() else: logger.warning('Device type is UNKNOWN. Assuming BP35C2.') - self.device_type = DeviceType.BP35C2 + self.device_strategy = BP35C2Strategy() @@ -7,7 +7,7 @@ with open('README.md', 'r') as f: setuptools.setup( name='momonga', - version='0.5.0', + version='0.6.0', description='Python Route B Library: A Communicator for Low-voltage Smart Electric Energy Meters', long_description=long_description, long_description_content_type='text/markdown', diff --git a/tests/test_device_strategy_unit.py b/tests/test_device_strategy_unit.py new file mode 100644 index 0000000..6f6e1f0 --- /dev/null +++ b/tests/test_device_strategy_unit.py @@ -0,0 +1,216 @@ +""" +Unit tests for BP35C2Strategy and BP35A1Strategy. + +Run: + python -m unittest tests/test_device_strategy_unit.py -v +""" +import unittest + +from momonga.momonga_device_enum import DeviceType +from momonga.momonga_device_strategy import BP35C2Strategy, BP35A1Strategy +from momonga.momonga_response import SkParsedEvent, SkParsedRxUdp + +C2 = BP35C2Strategy() +A1 = BP35A1Strategy() + + +# --------------------------------------------------------------------------- +# device_type attribute +# --------------------------------------------------------------------------- + +class TestDeviceType(unittest.TestCase): + + def test_bp35c2_device_type(self): + self.assertEqual(C2.device_type, DeviceType.BP35C2) + + def test_bp35a1_device_type(self): + self.assertEqual(A1.device_type, DeviceType.BP35A1) + + +# --------------------------------------------------------------------------- +# parse_event +# --------------------------------------------------------------------------- + +class TestBP35C2ParseEvent(unittest.TestCase): + + def test_with_side_and_param(self): + result = C2.parse_event(['EVENT', '21', 'FE80::1', '0', '01']) + self.assertIsInstance(result, SkParsedEvent) + self.assertEqual(result.num, 0x21) + self.assertEqual(result.src_addr, 'FE80::1') + self.assertEqual(result.side, 0) + self.assertEqual(result.param, 1) + + def test_with_side_no_param(self): + result = C2.parse_event(['EVENT', '29', 'FE80::1', '0']) + self.assertIsInstance(result, SkParsedEvent) + self.assertEqual(result.side, 0) + self.assertIsNone(result.param) + + def test_no_side_no_param(self): + result = C2.parse_event(['EVENT', '22', 'FE80::1']) + self.assertIsInstance(result, SkParsedEvent) + self.assertIsNone(result.side) + self.assertIsNone(result.param) + + def test_too_few_parts_returns_none(self): + self.assertIsNone(C2.parse_event(['EVENT', '21'])) + + def test_invalid_hex_num_raises(self): + with self.assertRaises(ValueError): + C2.parse_event(['EVENT', 'ZZ', 'FE80::1']) + + +class TestBP35A1ParseEvent(unittest.TestCase): + + def test_with_param_no_side(self): + result = A1.parse_event(['EVENT', '21', 'FE80::1', '01']) + self.assertIsInstance(result, SkParsedEvent) + self.assertEqual(result.num, 0x21) + self.assertIsNone(result.side) + self.assertEqual(result.param, 1) + + def test_no_param_no_side(self): + result = A1.parse_event(['EVENT', '29', 'FE80::1']) + self.assertIsInstance(result, SkParsedEvent) + self.assertIsNone(result.side) + self.assertIsNone(result.param) + + def test_too_few_parts_returns_none(self): + self.assertIsNone(A1.parse_event(['EVENT', '21'])) + + +# --------------------------------------------------------------------------- +# parse_erxudp +# --------------------------------------------------------------------------- + +_C2_PARTS = ['ERXUDP', 'FE80::1', 'FE80::2', '0E1A', '0E1A', + 'AABBCCDDEEFF', '50', '00', '00', '0002', '1081'] + +_A1_PARTS = ['ERXUDP', 'FE80::1', 'FE80::2', '0E1A', '0E1A', + 'AABBCCDDEEFF', '00', '0002', '1081'] + + +class TestBP35C2ParseErxudp(unittest.TestCase): + + def test_all_fields(self): + result = C2.parse_erxudp(_C2_PARTS) + self.assertIsInstance(result, SkParsedRxUdp) + self.assertEqual(result.src_addr, 'FE80::1') + self.assertEqual(result.dst_addr, 'FE80::2') + self.assertEqual(result.src_port, 0x0E1A) + self.assertEqual(result.dst_port, 0x0E1A) + self.assertEqual(result.src_mac, bytes.fromhex('AABBCCDDEEFF')) + self.assertEqual(result.lqi, 0x50) + self.assertAlmostEqual(result.rssi, 0.275 * 0x50 - 104.27, places=5) + self.assertEqual(result.sec, 0) + self.assertEqual(result.side, 0) + self.assertEqual(result.data, bytes.fromhex('1081')) + + def test_too_few_parts_returns_none(self): + self.assertIsNone(C2.parse_erxudp(_C2_PARTS[:10])) + + def test_invalid_mac_raises(self): + parts = list(_C2_PARTS) + parts[5] = 'ZZZZZZZZZZZZ' + with self.assertRaises(ValueError): + C2.parse_erxudp(parts) + + def test_invalid_data_raises(self): + parts = list(_C2_PARTS) + parts[10] = 'ZZ' + with self.assertRaises(ValueError): + C2.parse_erxudp(parts) + + def test_invalid_port_raises(self): + parts = list(_C2_PARTS) + parts[3] = 'ZZZZ' + with self.assertRaises(ValueError): + C2.parse_erxudp(parts) + + +class TestBP35A1ParseErxudp(unittest.TestCase): + + def test_all_fields(self): + result = A1.parse_erxudp(_A1_PARTS) + self.assertIsInstance(result, SkParsedRxUdp) + self.assertIsNone(result.lqi) + self.assertIsNone(result.rssi) + self.assertIsNone(result.side) + self.assertEqual(result.sec, 0) + self.assertEqual(result.data, bytes.fromhex('1081')) + + def test_too_few_parts_returns_none(self): + self.assertIsNone(A1.parse_erxudp(_A1_PARTS[:8])) + + def test_invalid_data_raises(self): + parts = list(_A1_PARTS) + parts[8] = 'ZZ' + with self.assertRaises(ValueError): + A1.parse_erxudp(parts) + + +# --------------------------------------------------------------------------- +# skscan_command +# --------------------------------------------------------------------------- + +class TestSkscanCommand(unittest.TestCase): + + def test_bp35c2_includes_side_param(self): + cmd = C2.skscan_command(6) + self.assertEqual(cmd, ['SKSCAN', '2', 'FFFFFFFF', '6', '0']) + + def test_bp35a1_no_side_param(self): + cmd = A1.skscan_command(6) + self.assertEqual(cmd, ['SKSCAN', '2', 'FFFFFFFF', '6']) + + def test_bp35c2_duration_changes(self): + self.assertEqual(C2.skscan_command(7)[3], '7') + + def test_bp35a1_duration_changes(self): + self.assertEqual(A1.skscan_command(8)[3], '8') + + +# --------------------------------------------------------------------------- +# sksendto_args +# --------------------------------------------------------------------------- + +class TestSksendtoArgs(unittest.TestCase): + + def test_bp35c2_includes_side(self): + args = C2.sksendto_args(1, 'FE80::1', 0x0E1A, 2, 0, 10) + self.assertEqual(args, ['SKSENDTO', '1', 'FE80::1', '0E1A', '2', '0', '000A']) + + def test_bp35a1_no_side(self): + args = A1.sksendto_args(1, 'FE80::1', 0x0E1A, 2, 0, 10) + self.assertEqual(args, ['SKSENDTO', '1', 'FE80::1', '0E1A', '2', '000A']) + + def test_bp35c2_length_hex_format(self): + args = C2.sksendto_args(1, 'FE80::1', 0x0E1A, 2, 0, 256) + self.assertEqual(args[-1], '0100') + + def test_bp35a1_length_hex_format(self): + args = A1.sksendto_args(1, 'FE80::1', 0x0E1A, 2, 0, 256) + self.assertEqual(args[-1], '0100') + + +# --------------------------------------------------------------------------- +# decode_scan_side +# --------------------------------------------------------------------------- + +class TestDecodeScanSide(unittest.TestCase): + + def test_bp35c2_parses_side_from_extract(self): + result = C2.decode_scan_side(lambda key: 'Side:0') + self.assertEqual(result, 0) + + def test_bp35c2_side_1(self): + result = C2.decode_scan_side(lambda key: 'Side:1') + self.assertEqual(result, 1) + + def test_bp35a1_always_returns_none(self): + self.assertIsNone(A1.decode_scan_side(lambda key: 'Side:0')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_notification_unit.py b/tests/test_notification_unit.py index 87402c7..c5f6995 100644 --- a/tests/test_notification_unit.py +++ b/tests/test_notification_unit.py @@ -2,13 +2,15 @@ import datetime import queue import threading import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import momonga from momonga.momonga import Momonga from momonga.momonga_async import AsyncMomonga from momonga.momonga_echonet_data import EchonetDataBuilder, EchonetDataParser from momonga.momonga_echonet_enum import EchonetPropertyCode, EchonetServiceCode +from momonga.momonga_device_strategy import BP35C2Strategy +from momonga.momonga_response import SkParsedRxUdp # --------------------------------------------------------------------------- @@ -97,6 +99,77 @@ class TestEchonetDataParser(unittest.TestCase): self.assertIn(EchonetPropertyCode.operation_status, result) self.assertIn(EchonetPropertyCode.fault_status, result) + def test_parse_installation_location_not_set(self): + self.assertEqual(EchonetDataParser.parse_installation_location(b'\x00'), 'location not set') + + def test_parse_installation_location_room(self): + # 0x0F: code>>3=1 (living room), code&0x07=7 → 'living room 7' + self.assertEqual(EchonetDataParser.parse_installation_location(b'\x0F'), 'living room 7') + + def test_parse_installation_location_not_fixed(self): + self.assertEqual(EchonetDataParser.parse_installation_location(b'\xFF'), 'location not fixed') + + def test_parse_standard_version_information(self): + # edt[0]=0 (skip), edt[1]=0 (skip), edt[2]=0x46='F', edt[3]=1 → 'F.1' + self.assertEqual(EchonetDataParser.parse_standard_version_information(b'\x00\x00\x46\x01'), 'F.1') + + def test_parse_route_b_id(self): + edt = b'\x00\x11\x22\x33\xAA\xBB\xCC' + result = EchonetDataParser.parse_route_b_id(edt) + self.assertEqual(result['manufacturer code'], b'\x11\x22\x33') + self.assertEqual(result['authentication id'], b'\xAA\xBB\xCC') + + def test_parse_day_for_historical_data_1(self): + self.assertEqual(EchonetDataParser.parse_day_for_historical_data_1(b'\x05'), 5) + + def test_parse_cumulative_energy_measured_at_fixed_time(self): + ts = b'\x07\xe8\x06\x05\x0c\x00\x00' # 2024-06-05 12:00:00 + energy = b'\x00\x00\x00\x64' # 100 raw + result = EchonetDataParser.parse_cumulative_energy_measured_at_fixed_time( + ts + energy, energy_unit=1, energy_coefficient=1) + self.assertEqual(result['timestamp'], datetime.datetime(2024, 6, 5, 12, 0, 0)) + self.assertAlmostEqual(result['cumulative energy'], 100.0) + + def test_parse_time_for_historical_data_2(self): + edt = b'\x07\xe8\x06\x05\x0c\x1e\x06' # 2024-06-05 12:30, 6 points + result = EchonetDataParser.parse_time_for_historical_data_2(edt) + self.assertEqual(result['timestamp'], datetime.datetime(2024, 6, 5, 12, 30)) + self.assertEqual(result['number of data points'], 6) + + def test_parse_time_for_historical_data_2_missing_year(self): + edt = b'\xFF\xFF\x01\x01\x00\x00\x06' # year=0xFFFF → None + result = EchonetDataParser.parse_time_for_historical_data_2(edt) + self.assertIsNone(result['timestamp']) + + def test_parse_time_for_historical_data_3(self): + edt = b'\x07\xe8\x06\x05\x0c\x1e\x0A' # 2024-06-05 12:30, 10 points + result = EchonetDataParser.parse_time_for_historical_data_3(edt) + self.assertEqual(result['timestamp'], datetime.datetime(2024, 6, 5, 12, 30)) + self.assertEqual(result['number of data points'], 10) + + def test_parse_time_for_historical_data_3_missing_year(self): + edt = b'\xFF\xFF\x01\x01\x00\x00\x0A' + result = EchonetDataParser.parse_time_for_historical_data_3(edt) + self.assertIsNone(result['timestamp']) + + def test_parse_one_minute_measured_cumulative_energy_missing_values(self): + # 0xFFFFFFFE sentinel → None for both directions + ts = b'\x07\xe8\x06\x05\x0c\x00\x00' + missing = b'\xFF\xFF\xFF\xFE' + result = EchonetDataParser.parse_one_minute_measured_cumulative_energy( + ts + missing + missing, energy_unit=1, energy_coefficient=1) + self.assertIsNone(result['cumulative energy']['normal direction']) + self.assertIsNone(result['cumulative energy']['reverse direction']) + + def test_parse_one_minute_measured_cumulative_energy_values(self): + ts = b'\x07\xe8\x06\x05\x0c\x00\x00' + normal = b'\x00\x00\x00\x64' # 100 + reverse = b'\x00\x00\x00\xC8' # 200 + result = EchonetDataParser.parse_one_minute_measured_cumulative_energy( + ts + normal + reverse, energy_unit=0.1, energy_coefficient=2) + self.assertAlmostEqual(result['cumulative energy']['normal direction'], 20.0) + self.assertAlmostEqual(result['cumulative energy']['reverse direction'], 40.0) + # --------------------------------------------------------------------------- # EchonetDataBuilder @@ -184,16 +257,16 @@ class TestGetNotification(unittest.TestCase): mo.session_manager.notif_q.get.side_effect = queue.Empty self.assertIsNone(mo.get_notification(timeout=0)) + @staticmethod + def _make_frame(data: bytes) -> SkParsedRxUdp: + return SkParsedRxUdp(src_addr='', dst_addr='', src_port=0, dst_port=0, + src_mac=b'', side=0, sec=0, data=data) + def test_inf_notification_parsed(self): mo = self._make_momonga() data = _make_echonet_frame(0x73, 0xE7, b'\x00\x00\x03\xe8') # INF, instantaneous_power=1000 - mo.session_manager.notif_q.get.return_value = 'ERXUDP dummy' - fake_pkt = MagicMock() - fake_pkt.data = data - - with patch('momonga.momonga.SkEventRxUdp', return_value=fake_pkt): - result = mo.get_notification(timeout=1) - + mo.session_manager.notif_q.get.return_value = self._make_frame(data) + result = mo.get_notification(timeout=1) self.assertEqual(result['esv'], EchonetServiceCode.inf) self.assertIn(EchonetPropertyCode.instantaneous_power, result['properties']) self.assertEqual(result['properties'][EchonetPropertyCode.instantaneous_power], 1000) @@ -201,40 +274,25 @@ class TestGetNotification(unittest.TestCase): def test_infc_triggers_infc_res(self): mo = self._make_momonga() data = _make_echonet_frame(0x74, 0xE7, b'\x00\x00\x03\xe8') # INFC - mo.session_manager.notif_q.get.return_value = 'ERXUDP dummy' + mo.session_manager.notif_q.get.return_value = self._make_frame(data) mo.session_manager.xmitter.return_value = None - fake_pkt = MagicMock() - fake_pkt.data = data - - with patch('momonga.momonga.SkEventRxUdp', return_value=fake_pkt): - result = mo.get_notification(timeout=1) - + result = mo.get_notification(timeout=1) mo.session_manager.xmitter.assert_called_once() self.assertEqual(result['esv'], EchonetServiceCode.infc) def test_infc_res_xmit_failure_does_not_raise(self): mo = self._make_momonga() data = _make_echonet_frame(0x74, 0xE7, b'\x00\x00\x03\xe8') - mo.session_manager.notif_q.get.return_value = 'ERXUDP dummy' + mo.session_manager.notif_q.get.return_value = self._make_frame(data) mo.session_manager.xmitter.side_effect = Exception('xmit failed') - fake_pkt = MagicMock() - fake_pkt.data = data - - with patch('momonga.momonga.SkEventRxUdp', return_value=fake_pkt): - result = mo.get_notification(timeout=1) # must not raise - + result = mo.get_notification(timeout=1) # must not raise self.assertEqual(result['esv'], EchonetServiceCode.infc) def test_unknown_epc_stored_as_raw_bytes(self): mo = self._make_momonga() data = _make_echonet_frame(0x73, 0x01, b'\xAB\xCD') # EPC 0x01 not in enum - mo.session_manager.notif_q.get.return_value = 'ERXUDP dummy' - fake_pkt = MagicMock() - fake_pkt.data = data - - with patch('momonga.momonga.SkEventRxUdp', return_value=fake_pkt): - result = mo.get_notification(timeout=1) - + mo.session_manager.notif_q.get.return_value = self._make_frame(data) + result = mo.get_notification(timeout=1) self.assertEqual(result['properties'][0x01], b'\xAB\xCD') def test_energy_epc_uses_energy_unit_and_coefficient(self): @@ -246,16 +304,16 @@ class TestGetNotification(unittest.TestCase): energy_bytes = b'\x00\x00\x00\x64' # 100 raw → 100 * 0.1 * 2 = 20.0 edt = ts_bytes + energy_bytes data = _make_echonet_frame(0x73, 0xEA, edt) - mo.session_manager.notif_q.get.return_value = 'ERXUDP dummy' - fake_pkt = MagicMock() - fake_pkt.data = data - - with patch('momonga.momonga.SkEventRxUdp', return_value=fake_pkt): - result = mo.get_notification(timeout=1) - + mo.session_manager.notif_q.get.return_value = self._make_frame(data) + result = mo.get_notification(timeout=1) parsed = result['properties'][EchonetPropertyCode.cumulative_energy_measured_at_fixed_time] self.assertAlmostEqual(parsed['cumulative energy'], 20.0) + def test_malformed_frame_too_short_returns_none(self): + mo = self._make_momonga() + mo.session_manager.notif_q.get.return_value = self._make_frame(b'\x10\x81\x00\x01') + self.assertIsNone(mo.get_notification(timeout=1)) + # --------------------------------------------------------------------------- # AsyncMomonga @@ -353,22 +411,41 @@ class TestReceiverRouting(unittest.TestCase): def _make_session_manager(self): from momonga.momonga_session_manager import MomongaSessionManager + from momonga.momonga_echonet_enum import EchonetServiceCode, SMART_METER_EOJ sm = object.__new__(MomongaSessionManager) sm.pkt_sbsc_q = queue.Queue() sm.recv_q = queue.Queue() sm.notif_q = queue.Queue() - sm.xmit_lock = threading.Lock() - sm.xmit_restriction_cnt = 0 + sm.gate_lock = threading.Lock() + sm.session_available = True + sm.rate_ok = True + sm.xmit_allowed = threading.Event() + sm.xmit_allowed.set() sm.session_established = True sm.receiver_exception = None + sm.smart_meter_addr = 'FE80::1' sm.skw = MagicMock() + sm.skw.device_strategy = BP35C2Strategy() + + def route(frame): + seoj = frame.data[4:7] if len(frame.data) >= 7 else b'' + if seoj != SMART_METER_EOJ: + return + esv = frame.data[10] if len(frame.data) > 10 else -1 + if esv in (EchonetServiceCode.inf, EchonetServiceCode.infc): + sm.notif_q.put(frame) + else: + sm.recv_q.put(frame) + sm.on_meter_frame = route return sm @staticmethod def _erxudp(seoj: str, esv: int) -> str: # EHD(4) + TID(4) + SEOJ(6) + DEOJ(6) + ESV(2) + OPC+EPC+PDC+EDT payload = '1081' + '0001' + seoj + '05FF01' + ('%02X' % esv) + '01E70400000000' - return 'ERXUDP x x x x x x x x ' + payload + # BP35C2 format: ERXUDP src dst sport dport mac lqi sec side data_len data + return ('ERXUDP FE80::1 FE80::2 0E1A 0E1A AABBCCDDEEFF ' + '50 00 00 %04X %s' % (len(payload) // 2, payload)) def _route(self, sm, packet): th = threading.Thread(target=sm.receiver, daemon=True) @@ -401,6 +478,52 @@ class TestReceiverRouting(unittest.TestCase): self.assertTrue(sm.notif_q.empty()) self.assertFalse(sm.recv_q.empty()) + def test_erxudp_wrong_ip_discarded(self): + sm = self._make_session_manager() + payload = '1081000102880105FF017301E70400000000' + data_len = '%04X' % (len(payload) // 2) + wrong_ip = ('ERXUDP FE80::FFFF FE80::2 0E1A 0E1A AABBCCDDEEFF ' + '50 00 00 %s %s' % (data_len, payload)) + self._route(sm, wrong_ip) + self.assertTrue(sm.notif_q.empty()) + self.assertTrue(sm.recv_q.empty()) + + def test_event_tx_done_to_recv_q(self): + from momonga.momonga_response import SkParsedEvent, SkEventNum + sm = self._make_session_manager() + self._route(sm, 'EVENT 21 FE80::1 0 00') + self.assertTrue(sm.notif_q.empty()) + self.assertFalse(sm.recv_q.empty()) + item = sm.recv_q.get_nowait() + self.assertIsInstance(item, SkParsedEvent) + self.assertEqual(item.num, SkEventNum.tx_done) + + def test_event_neighbor_discovery_to_recv_q(self): + from momonga.momonga_response import SkParsedEvent, SkEventNum + sm = self._make_session_manager() + self._route(sm, 'EVENT 02 FE80::1 0') + self.assertTrue(sm.notif_q.empty()) + self.assertFalse(sm.recv_q.empty()) + item = sm.recv_q.get_nowait() + self.assertIsInstance(item, SkParsedEvent) + self.assertEqual(item.num, SkEventNum.neighbor_discovery) + + def test_unrecognized_line_ignored(self): + sm = self._make_session_manager() + self._route(sm, 'OK') + self.assertTrue(sm.notif_q.empty()) + self.assertTrue(sm.recv_q.empty()) + + def test_on_meter_frame_exception_does_not_kill_receiver(self): + sm = self._make_session_manager() + + def bad_callback(frame): + raise RuntimeError('boom') + sm.on_meter_frame = bad_callback + + self._route(sm, self._erxudp('028801', 0x73)) + self.assertIsNone(sm.receiver_exception) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_sk_parser_unit.py b/tests/test_sk_parser_unit.py new file mode 100644 index 0000000..b2a7e8e --- /dev/null +++ b/tests/test_sk_parser_unit.py @@ -0,0 +1,158 @@ +""" +Unit tests for parse_sk_line() and the typed event dataclasses. + +Run: + python -m unittest tests/test_sk_parser_unit.py -v +""" +import unittest + +from momonga.momonga_device_strategy import BP35C2Strategy, BP35A1Strategy +from momonga.momonga_response import ( + SkEventNum, SkParsedEvent, SkParsedRxUdp, parse_sk_line, +) + +C2 = BP35C2Strategy() +A1 = BP35A1Strategy() + + +def _c2_erxudp(data_hex: str, src_addr: str = 'FE80::1') -> str: + """Build a minimal BP35C2 ERXUDP line.""" + data_len = '%04X' % (len(data_hex) // 2) + return ('ERXUDP %s FE80::2 0E1A 0E1A AABBCCDDEEFF ' + '50 00 00 %s %s' % (src_addr, data_len, data_hex)) + + +def _a1_erxudp(data_hex: str) -> str: + """Build a minimal BP35A1 ERXUDP line.""" + data_len = '%04X' % (len(data_hex) // 2) + return ('ERXUDP FE80::1 FE80::2 0E1A 0E1A AABBCCDDEEFF ' + '00 %s %s' % (data_len, data_hex)) + + +# --------------------------------------------------------------------------- +# EVENT parsing +# --------------------------------------------------------------------------- + +class TestParseSkLineEvent(unittest.TestCase): + + def test_bp35c2_tx_done_with_side_and_param(self): + result = parse_sk_line('EVENT 21 FE80::1 0 01', C2) + self.assertIsInstance(result, SkParsedEvent) + self.assertEqual(result.num, SkEventNum.tx_done) + self.assertEqual(result.src_addr, 'FE80::1') + self.assertEqual(result.side, 0) + self.assertEqual(result.param, 1) + + def test_bp35c2_session_lifetime_no_param(self): + result = parse_sk_line('EVENT 29 FE80::1 0', C2) + self.assertIsInstance(result, SkParsedEvent) + self.assertEqual(result.num, SkEventNum.session_lifetime) + self.assertIsNone(result.param) + + def test_bp35a1_tx_done_no_side(self): + result = parse_sk_line('EVENT 21 FE80::1 01', A1) + self.assertIsInstance(result, SkParsedEvent) + self.assertEqual(result.num, SkEventNum.tx_done) + self.assertIsNone(result.side) + self.assertEqual(result.param, 1) + + def test_bp35a1_session_lifetime_no_side_no_param(self): + result = parse_sk_line('EVENT 29 FE80::1', A1) + self.assertIsInstance(result, SkParsedEvent) + self.assertIsNone(result.side) + self.assertIsNone(result.param) + + def test_all_named_event_nums_parse(self): + for ev in SkEventNum: + line = 'EVENT %02X FE80::1 0' % ev + result = parse_sk_line(line, C2) + self.assertIsInstance(result, SkParsedEvent) + self.assertEqual(result.num, ev) + + def test_event_too_few_parts_returns_none(self): + self.assertIsNone(parse_sk_line('EVENT 21', C2)) + + def test_event_invalid_hex_num_returns_none(self): + self.assertIsNone(parse_sk_line('EVENT ZZ FE80::1 0', C2)) + + +# --------------------------------------------------------------------------- +# ERXUDP parsing +# --------------------------------------------------------------------------- + +class TestParseSkLineErxudp(unittest.TestCase): + + def test_bp35c2_fields(self): + result = parse_sk_line(_c2_erxudp('1081'), C2) + self.assertIsInstance(result, SkParsedRxUdp) + self.assertEqual(result.src_addr, 'FE80::1') + self.assertEqual(result.dst_addr, 'FE80::2') + self.assertEqual(result.src_port, 0x0E1A) + self.assertEqual(result.dst_port, 0x0E1A) + self.assertEqual(result.src_mac, bytes.fromhex('AABBCCDDEEFF')) + self.assertEqual(result.lqi, 0x50) + self.assertAlmostEqual(result.rssi, 0.275 * 0x50 - 104.27, places=5) + self.assertEqual(result.sec, 0) + self.assertEqual(result.side, 0) + self.assertEqual(result.data, bytes.fromhex('1081')) + self.assertIsInstance(result.rssi, float) + + def test_bp35a1_fields(self): + result = parse_sk_line(_a1_erxudp('1081'), A1) + self.assertIsInstance(result, SkParsedRxUdp) + self.assertIsNone(result.lqi) + self.assertIsNone(result.rssi) + self.assertIsNone(result.side) + self.assertEqual(result.data, bytes.fromhex('1081')) + + def test_bp35c2_data_roundtrip(self): + payload = '1081000102880105FF017301E70400000000' + result = parse_sk_line(_c2_erxudp(payload), C2) + self.assertIsInstance(result, SkParsedRxUdp) + self.assertEqual(result.data, bytes.fromhex(payload)) + + def test_bp35c2_too_few_parts_returns_none(self): + # 10 parts only (needs 11) + self.assertIsNone(parse_sk_line('ERXUDP x x x x x x x x x x', C2)) + + def test_bp35a1_too_few_parts_returns_none(self): + # 8 parts only (needs 9) + self.assertIsNone(parse_sk_line('ERXUDP x x x x x x x x', A1)) + + def test_invalid_hex_mac_returns_none(self): + self.assertIsNone(parse_sk_line( + 'ERXUDP FE80::1 FE80::2 0E1A 0E1A ZZZZZZZZZZZZ 50 00 00 0001 10', C2)) + + def test_invalid_hex_data_returns_none(self): + self.assertIsNone(parse_sk_line( + 'ERXUDP FE80::1 FE80::2 0E1A 0E1A AABBCCDDEEFF 50 00 00 0001 ZZ', C2)) + + def test_invalid_port_returns_none(self): + self.assertIsNone(parse_sk_line( + 'ERXUDP FE80::1 FE80::2 ZZZZ 0E1A AABBCCDDEEFF 50 00 00 0001 10', C2)) + + +# --------------------------------------------------------------------------- +# Non-EVENT / non-ERXUDP lines +# --------------------------------------------------------------------------- + +class TestParseSkLineOther(unittest.TestCase): + + def test_ok_returns_none(self): + self.assertIsNone(parse_sk_line('OK', C2)) + + def test_fail_returns_none(self): + self.assertIsNone(parse_sk_line('FAIL ER10', C2)) + + def test_epandesc_line_returns_none(self): + self.assertIsNone(parse_sk_line(' Channel:3A', C2)) + + def test_empty_string_returns_none(self): + self.assertIsNone(parse_sk_line('', C2)) + + def test_whitespace_only_returns_none(self): + self.assertIsNone(parse_sk_line(' ', C2)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_xmit_gate_unit.py b/tests/test_xmit_gate_unit.py new file mode 100644 index 0000000..f4a57a0 --- /dev/null +++ b/tests/test_xmit_gate_unit.py @@ -0,0 +1,215 @@ +""" +Unit tests for the two-gate transmission backpressure mechanism. + +The session manager has two independent gates: + - session gate: closed by EVENT 27/28/29, opened by EVENT 25 + - rate gate: closed by EVENT 32, opened by EVENT 33 + +Transmission is allowed only when BOTH gates are open. + +Run: + python -m unittest tests/test_xmit_gate_unit.py -v +""" +import queue +import threading +import unittest +from unittest.mock import MagicMock + +from momonga.momonga_device_strategy import BP35C2Strategy +from momonga.momonga_session_manager import MomongaSessionManager + + +def _make_sm(): + sm = object.__new__(MomongaSessionManager) + sm.pkt_sbsc_q = queue.Queue() + sm.recv_q = queue.Queue() + sm.notif_q = queue.Queue() + sm.gate_lock = threading.Lock() + sm.session_available = True + sm.rate_ok = True + sm.xmit_allowed = threading.Event() + sm.xmit_allowed.set() + sm.session_established = True + sm.receiver_exception = None + sm.smart_meter_addr = 'FE80::1' + sm.on_meter_frame = None + sm.rejoin_lock = threading.Lock() + sm.skw = MagicMock() + sm.skw.device_strategy = BP35C2Strategy() + return sm + + +def _run(sm, *events): + """Run receiver in a thread, push events then close.""" + th = threading.Thread(target=sm.receiver, daemon=True) + th.start() + for ev in events: + sm.pkt_sbsc_q.put(ev) + sm.pkt_sbsc_q.put('__CLOSE__') + th.join(timeout=2) + + +# --------------------------------------------------------------------------- +# Initial state +# --------------------------------------------------------------------------- + +class TestXmitGateInitial(unittest.TestCase): + + def test_initially_not_restricted(self): + sm = _make_sm() + self.assertFalse(sm.is_restricted_to_xmit()) + + +# --------------------------------------------------------------------------- +# Session gate +# --------------------------------------------------------------------------- + +class TestSessionGate(unittest.TestCase): + + def test_session_lifetime_blocks(self): + sm = _make_sm() + _run(sm, 'EVENT 29 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_session_closed_blocks(self): + sm = _make_sm() + _run(sm, 'EVENT 27 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_no_session_blocks(self): + sm = _make_sm() + _run(sm, 'EVENT 28 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_rejoined_unblocks(self): + sm = _make_sm() + _run(sm, 'EVENT 29 FE80::1 0', 'EVENT 25 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_double_session_block_single_unblock_sufficient(self): + # Boolean gate: second block is idempotent, one unblock is enough. + # With the old counter design this would leave cnt=1 and stay blocked. + sm = _make_sm() + _run(sm, 'EVENT 29 FE80::1 0', 'EVENT 29 FE80::1 0', 'EVENT 25 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_spurious_rejoined_is_safe(self): + sm = _make_sm() + _run(sm, 'EVENT 25 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + +# --------------------------------------------------------------------------- +# Rate gate +# --------------------------------------------------------------------------- + +class TestRateGate(unittest.TestCase): + + def test_rate_limit_exceeded_blocks(self): + sm = _make_sm() + _run(sm, 'EVENT 32 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_rate_limit_released_unblocks(self): + sm = _make_sm() + _run(sm, 'EVENT 32 FE80::1 0', 'EVENT 33 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_double_rate_block_single_unblock_sufficient(self): + sm = _make_sm() + _run(sm, 'EVENT 32 FE80::1 0', 'EVENT 32 FE80::1 0', 'EVENT 33 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_spurious_rate_released_is_safe(self): + sm = _make_sm() + _run(sm, 'EVENT 33 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + +# --------------------------------------------------------------------------- +# Interleaved session + rate events +# Both gates must be open before transmission is allowed. +# --------------------------------------------------------------------------- + +class TestXmitGateInterleaving(unittest.TestCase): + + def test_32_29_still_blocked_after_33(self): + # Rate released but session still blocked. + sm = _make_sm() + _run(sm, 'EVENT 32 FE80::1 0', 'EVENT 29 FE80::1 0', 'EVENT 33 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_32_29_still_blocked_after_25(self): + # Session restored but rate still limited. + sm = _make_sm() + _run(sm, 'EVENT 32 FE80::1 0', 'EVENT 29 FE80::1 0', 'EVENT 25 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_29_32_still_blocked_after_25(self): + sm = _make_sm() + _run(sm, 'EVENT 29 FE80::1 0', 'EVENT 32 FE80::1 0', 'EVENT 25 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_29_32_still_blocked_after_33(self): + sm = _make_sm() + _run(sm, 'EVENT 29 FE80::1 0', 'EVENT 32 FE80::1 0', 'EVENT 33 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + + def test_32_29_33_25_fully_unblocked(self): + sm = _make_sm() + _run(sm, + 'EVENT 32 FE80::1 0', + 'EVENT 29 FE80::1 0', + 'EVENT 33 FE80::1 0', + 'EVENT 25 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_32_29_25_33_fully_unblocked(self): + sm = _make_sm() + _run(sm, + 'EVENT 32 FE80::1 0', + 'EVENT 29 FE80::1 0', + 'EVENT 25 FE80::1 0', + 'EVENT 33 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_29_32_33_25_fully_unblocked(self): + sm = _make_sm() + _run(sm, + 'EVENT 29 FE80::1 0', + 'EVENT 32 FE80::1 0', + 'EVENT 33 FE80::1 0', + 'EVENT 25 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_29_32_25_33_fully_unblocked(self): + sm = _make_sm() + _run(sm, + 'EVENT 29 FE80::1 0', + 'EVENT 32 FE80::1 0', + 'EVENT 25 FE80::1 0', + 'EVENT 33 FE80::1 0') + self.assertFalse(sm.is_restricted_to_xmit()) + + +# --------------------------------------------------------------------------- +# Force open (used by close()) +# --------------------------------------------------------------------------- + +class TestForceOpenGates(unittest.TestCase): + + def test_force_clears_session_and_rate(self): + sm = _make_sm() + _run(sm, 'EVENT 29 FE80::1 0', 'EVENT 32 FE80::1 0') + self.assertTrue(sm.is_restricted_to_xmit()) + sm.force_open_gates() + self.assertFalse(sm.is_restricted_to_xmit()) + + def test_force_on_already_open_is_safe(self): + sm = _make_sm() + sm.force_open_gates() + self.assertFalse(sm.is_restricted_to_xmit()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_xmitter_unit.py b/tests/test_xmitter_unit.py new file mode 100644 index 0000000..d890b2d --- /dev/null +++ b/tests/test_xmitter_unit.py @@ -0,0 +1,130 @@ +""" +Unit tests for MomongaSessionManager.xmitter(). + +Run: + python -m unittest tests/test_xmitter_unit.py -v +""" +import threading +import unittest +from unittest.mock import MagicMock, patch, call + +from momonga.momonga_device_strategy import BP35C2Strategy +from momonga.momonga_exception import ( + MomongaSkCommandExecutionFailure, + MomongaNeedToReopen, +) +from momonga.momonga_session_manager import MomongaSessionManager + + +def _make_sm(): + sm = object.__new__(MomongaSessionManager) + sm.xmit_allowed = threading.Event() + sm.xmit_allowed.set() + sm.session_established = True + sm.receiver_exception = None + sm.smart_meter_addr = 'FE80::1' + sm.skw = MagicMock() + sm.skw.device_strategy = BP35C2Strategy() + return sm + + +class TestXmitterSuccess(unittest.TestCase): + + @patch('momonga.momonga_session_manager.time.sleep') + def test_sends_when_gate_open(self, _sleep): + sm = _make_sm() + sm.xmitter(b'\x00\x01') + sm.skw.sksendto.assert_called_once_with('FE80::1', b'\x00\x01') + + @patch('momonga.momonga_session_manager.time.sleep') + def test_no_retry_on_first_success(self, _sleep): + sm = _make_sm() + sm.xmitter(b'\x00') + self.assertEqual(sm.skw.sksendto.call_count, 1) + + +class TestXmitterGateTimeout(unittest.TestCase): + + @patch('momonga.momonga_session_manager.time.sleep') + def test_gate_never_opens_raises(self, _sleep): + sm = _make_sm() + sm.xmit_allowed.clear() + with patch.object(sm.xmit_allowed, 'wait', return_value=False): + with self.assertRaises(MomongaNeedToReopen): + sm.xmitter(b'\x00') + + @patch('momonga.momonga_session_manager.time.sleep') + def test_receiver_exception_during_wait_raises(self, _sleep): + sm = _make_sm() + sm.xmit_allowed.clear() + sm.receiver_exception = RuntimeError('receiver died') + with patch.object(sm.xmit_allowed, 'wait', return_value=False): + with self.assertRaises(MomongaNeedToReopen): + sm.xmitter(b'\x00') + + @patch('momonga.momonga_session_manager.time.sleep') + def test_gate_opens_after_wait_succeeds(self, _sleep): + sm = _make_sm() + responses = [False] * 5 + [True] + with patch.object(sm.xmit_allowed, 'wait', side_effect=responses): + sm.xmitter(b'\x00') + sm.skw.sksendto.assert_called_once() + + +class TestXmitterSendFailure(unittest.TestCase): + + @patch('momonga.momonga_session_manager.time.sleep') + def test_sk_failure_retries_up_to_limit(self, _sleep): + sm = _make_sm() + sm.skw.sksendto.side_effect = MomongaSkCommandExecutionFailure('fail') + with self.assertRaises(MomongaNeedToReopen): + sm.xmitter(b'\x00') + self.assertEqual(sm.skw.sksendto.call_count, 3) + + @patch('momonga.momonga_session_manager.time.sleep') + def test_sk_failure_then_success_does_not_raise(self, _sleep): + sm = _make_sm() + sm.skw.sksendto.side_effect = [ + MomongaSkCommandExecutionFailure('fail'), + None, + ] + sm.xmitter(b'\x00') + self.assertEqual(sm.skw.sksendto.call_count, 2) + + @patch('momonga.momonga_session_manager.time.sleep') + def test_need_to_reopen_propagates_immediately(self, _sleep): + sm = _make_sm() + sm.skw.sksendto.side_effect = MomongaNeedToReopen('session gone') + with self.assertRaises(MomongaNeedToReopen): + sm.xmitter(b'\x00') + self.assertEqual(sm.skw.sksendto.call_count, 1) + + @patch('momonga.momonga_session_manager.time.sleep') + def test_generic_exception_retries(self, _sleep): + sm = _make_sm() + sm.skw.sksendto.side_effect = [OSError('io error'), None] + sm.xmitter(b'\x00') + self.assertEqual(sm.skw.sksendto.call_count, 2) + + @patch('momonga.momonga_session_manager.time.sleep') + def test_session_not_established_raises(self, _sleep): + sm = _make_sm() + sm.session_established = False + with self.assertRaises(MomongaNeedToReopen): + sm.xmitter(b'\x00') + sm.skw.sksendto.assert_not_called() + + @patch('momonga.momonga_session_manager.time.sleep') + def test_sleep_called_between_retries(self, mock_sleep): + sm = _make_sm() + sm.skw.sksendto.side_effect = [ + MomongaSkCommandExecutionFailure('fail'), + MomongaSkCommandExecutionFailure('fail'), + None, + ] + sm.xmitter(b'\x00') + self.assertEqual(mock_sleep.call_count, 2) + + +if __name__ == '__main__': + unittest.main() |
