From c26f24a2dfeb2909ae7f0d5390ec714bb01b7ffc Mon Sep 17 00:00:00 2001 From: Ulrond <131959864+Ulrond@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:19:07 +0100 Subject: [PATCH 1/5] feat(power): add Wake-on-LAN power module (type: wol) powerControlClass had no way to wake a device that is asleep or in a Wake-on-LAN soft-off state. Add framework/core/powerModules/wol.py (powerWol) and a `type: "wol"` dispatch branch: - powerOn() sends the WoL magic packet (6x 0xFF + MAC x16) to broadcast:port. - Wake-on-LAN is wake-only: powerOff() is a logged no-op, reboot() a best-effort wake. - Config: mac (required), broadcast (default 255.255.255.255), port (default 9). - Standard-library only (socket); no new dependencies. Fixes #216 --- framework/core/powerControl.py | 3 + framework/core/powerModules/wol.py | 110 +++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 framework/core/powerModules/wol.py diff --git a/framework/core/powerControl.py b/framework/core/powerControl.py index e0e7c5c..703c89a 100644 --- a/framework/core/powerControl.py +++ b/framework/core/powerControl.py @@ -53,6 +53,7 @@ from framework.core.powerModules.hs100 import powerHS100 from framework.core.powerModules.SLP import powerSLP from framework.core.powerModules.none import powerNone +from framework.core.powerModules.wol import powerWol from framework.core.utilities import utilities from framework.core.logModule import logModule @@ -94,6 +95,8 @@ def __init__(self, log:logModule, config:dict): self.powerSwitch = powerTapo( log, **config) elif type == "SLP": self.powerSwitch = powerSLP(log, self.ip, config.get("username"), config.get("password"), config.get("outlet_id"),config.get('port',23)) + elif type == "wol": + self.powerSwitch = powerWol( log, config.get("mac"), config.get("broadcast", "255.255.255.255"), config.get("port", 9)) elif type == "none": self.powerSwitch = powerNone( log ) else: diff --git a/framework/core/powerModules/wol.py b/framework/core/powerModules/wol.py new file mode 100644 index 0000000..1780342 --- /dev/null +++ b/framework/core/powerModules/wol.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +#** ***************************************************************************** +# * +# * If not stated otherwise in this file or this component's LICENSE file the +# * following copyright and licenses apply: +# * +# * Copyright 2026 RDK Management +# * +# * Licensed under the Apache License, Version 2.0 (the "License"); +# * you may not use this file except in compliance with the License. +# * You may obtain a copy of the License at +# * +# * +# http://www.apache.org/licenses/LICENSE-2.0 +# * +# * Unless required by applicable law or agreed to in writing, software +# * distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. +# * +#* ****************************************************************************** +#* +#* ** Project : RAFT +#* ** @addtogroup : core.powerModules +#* ** @file : wol.py +#* ** @brief : Wake-on-LAN power module. powerOn() sends a magic packet to +#* ** wake a sleeping / soft-off device on the same broadcast domain. +#* ** +#* ****************************************************************************** + +import socket + + +class powerWol(): + """Wake-on-LAN "power switch". + + A magic packet wakes a device that is asleep or in a Wake-on-LAN soft-off + state, provided the host sending it shares the target's layer-2 broadcast + domain (or a directed subnet broadcast reaches it). Wake-on-LAN is + wake-only: it cannot power a device off, so powerOff() is a logged no-op and + reboot() is a best-effort wake. + + Rack-config fields (under a device's ``powerSwitch:``):: + + powerSwitch: + type: "wol" + mac: "b0:3e:51:ff:f6:bc" # required - target NIC MAC + broadcast: "255.255.255.255" # optional - subnet broadcast for directed WoL + port: 9 # optional - 9 (default) or 7 + """ + + def __init__(self, log, mac, broadcast="255.255.255.255", port=9): + """ + Args: + log: The log module. + mac (str): Target NIC MAC address (":" / "-" separated or bare hex). + broadcast (str): Broadcast address the magic packet is sent to. + port (int): UDP port for the magic packet (typically 7 or 9). + """ + self.log = log + if not mac: + raise ValueError("powerWol requires a 'mac' address") + self.mac = mac + self.broadcast = broadcast or "255.255.255.255" + self.port = int(port) if port else 9 + self.log.info("powerWol(mac={}, broadcast={}, port={})".format( + self.mac, self.broadcast, self.port)) + + def _magicPacket(self): + """Build the Wake-on-LAN magic packet: 6x 0xFF followed by the MAC x16.""" + hexMac = self.mac.replace(":", "").replace("-", "").replace(".", "") + if len(hexMac) != 12: + raise ValueError("Invalid MAC address: {}".format(self.mac)) + return b"\xff" * 6 + bytes.fromhex(hexMac) * 16 + + def powerOn(self): + """Send the Wake-on-LAN magic packet. + + Returns: + bool: True if the packet was sent, False on error. + """ + try: + packet = self._magicPacket() + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.sendto(packet, (self.broadcast, self.port)) + self.log.info("powerWol().powerOn: magic packet sent to {}".format(self.mac)) + return True + except Exception as error: + self.log.error("powerWol().powerOn failed: {}".format(error)) + return False + + def powerOff(self): + """Wake-on-LAN cannot power a device off; logged no-op. + + Returns: + bool: Always True (nothing to do). + """ + self.log.info("powerWol().powerOff: Wake-on-LAN is wake-only; no power-off performed") + return True + + def reboot(self): + """No hard power cycle over Wake-on-LAN; best-effort wake. + + Returns: + bool: Result of powerOn(). + """ + self.log.info("powerWol().reboot: Wake-on-LAN is wake-only; sending wake") + return self.powerOn() From 1a27ddd77873b4483e7fa8229e173878abe0ee22 Mon Sep 17 00:00:00 2001 From: Ulrond <131959864+Ulrond@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:14:12 +0100 Subject: [PATCH 2/5] refactor(network): re-home Wake-on-LAN from power seam to a network/wake seam Wake-on-LAN is a network (layer-2) action, not a power action: it sends a UDP magic packet, cannot power a device off, and supports none of the power metering contract. Move it out of powerModules onto a dedicated network controller that mirrors the existing device-controller pattern (remoteController / hdmiCECController / avSyncController). - Add framework/core/networkModules/wol.py (networkWol) with a single wake() verb (renamed from powerOn; drop the power-only powerOff/reboot shims). - Add framework/core/networkControl.py (networkControlClass) dispatching type: "wol", with retry, mirroring powerControl. - Wire a device `network:` config block in deviceManager -> dut.networkController; expose it in testControl. - Revert the power-seam coupling: remove the wol import + type:"wol" branch from powerControl.py and delete powerModules/wol.py. - Document the `network:` block in the example rack config. - Add tests/networkControl_tests.py (packet correctness, loopback capture, MAC validation, unknown-type handling). Config now: network: type: "wol" mac: "aa:bb:cc:dd:ee:ff" broadcast: "255.255.255.255" # optional port: 9 # optional Waking is now an explicit dut.networkController.wake() rather than riding on powerControl.powerOn(). Relates to #216 --- AGENTS.md | 44 ++++++++- docs/modules/device_manager.md | 1 + examples/configs/example_rack_config.yml | 7 ++ framework/core/deviceManager.py | 5 + framework/core/networkControl.py | 92 +++++++++++++++++++ framework/core/networkModules/__init__.py | 9 ++ .../{powerModules => networkModules}/wol.py | 46 +++------- framework/core/powerControl.py | 3 - framework/core/testControl.py | 1 + tests/networkControl_tests.py | 45 +++++++++ 10 files changed, 216 insertions(+), 37 deletions(-) create mode 100644 framework/core/networkControl.py create mode 100644 framework/core/networkModules/__init__.py rename framework/core/{powerModules => networkModules}/wol.py (71%) create mode 100644 tests/networkControl_tests.py diff --git a/AGENTS.md b/AGENTS.md index 53d5684..4225cd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ python_raft/ rcCodes.py # rcCode enum (remote control key constants) commonRemote.py # commonRemoteClass, remoteControllerMapping powerControl.py # powerControlClass (delegates to modules) + networkControl.py # networkControlClass (network/wake, delegates to modules) hdmiCECController.py # HDMICECController avSyncController.py # AVSyncController (SyncONE2) utPlaneController.py # utPlaneController (ut-core integration) @@ -66,6 +67,8 @@ python_raft/ kasaControl.py # TP-Link Kasa smart plugs/strips tapoControl.py # TP-Link Tapo smart plugs SLP.py # Server Technology SLP PDU + networkModules/ + wol.py # Wake-on-LAN (networkWol, wake()) remoteControllerModules/ remoteInterface.py # Abstract base for remotes none.py # No-op remote @@ -115,13 +118,14 @@ class testController: 5. Selects rack and slot (from CLI `--rack` / `--slot` / `--slotName`) 6. Constructs log paths under `logs////` 7. Creates `deviceManager` from slot device list -8. Sets up shortcut attributes: `self.dut`, `self.session`, `self.powerControl`, `self.commonRemote`, `self.hdmiCECController` +8. Sets up shortcut attributes: `self.dut`, `self.session`, `self.powerControl`, `self.networkController`, `self.commonRemote`, `self.hdmiCECController` 9. Optionally sets up `capture` (video), `webpageController` (Selenium) **Key attributes available in tests:** - `self.session` -- console session (SSH/Serial/Telnet) for the DUT. The constructor auto-selects the console marked `enabled: true` in config, falling back to `default` (or the first available console) when none is flagged - `self.dut` -- `deviceClass` instance for "dut" - `self.powerControl` -- `powerControlClass` +- `self.networkController` -- `networkControlClass` or None (network/wake, e.g. Wake-on-LAN) - `self.commonRemote` -- `commonRemoteClass` - `self.hdmiCECController` -- `HDMICECController` or None - `self.capture` -- `capture` instance or None @@ -175,6 +179,7 @@ class deviceClass: **Attributes:** - `consoles` -- dict of `consoleClass` instances keyed by name (e.g. "default", "ssh") - `powerControl` -- `powerControlClass` or None +- `networkController` -- `networkControlClass` or None (network/wake, e.g. Wake-on-LAN) - `outBoundClient` -- `outboundClientClass` or None - `remoteController` -- `commonRemoteClass` or None - `hdmiCECController` -- `HDMICECController` or None @@ -342,7 +347,16 @@ Config fields: `type: "telnet"`, `address`/`ip`, `username`, `password`, `port` --- -## 5. Power Modules +## 5. Power & Network Control + +Two parallel controllers follow the same pattern -- a `*ControlClass` that delegates +to a `type`-dispatched module, with retry via `retryCount` (default 1) / `retryDelay` +(default 30s). `powerControlClass` (config `powerSwitch:`) owns device power; the +separate `networkControlClass` (config `network:`) owns network-level actions such as +Wake-on-LAN. They are intentionally distinct: Wake-on-LAN is a network (layer-2) +action, not a power action, so it lives in `networkModules/`, not `powerModules/`. + +### 5.1 Power modules `powerControlClass` delegates to a specific module based on `type` in config. @@ -369,6 +383,32 @@ All operations support retry via `retryCount` (default 1) and `retryDelay` (defa | `"tapo"` | `powerTapo` | `ip`, `username`, `password`, `outlet` | | `"SLP"` | `powerSLP` | `ip`, `username`, `password`, `outlet_id`, `port` (23) | +### 5.2 Network / wake modules + +`networkControlClass` (device `network:` block) delegates to a `type`-dispatched +network module. Waking is explicit -- call `dut.networkController.wake()` (it does not +ride on `powerControl.powerOn()`). + +```python +class networkControlClass: + def wake(self) -> bool # e.g. send a Wake-on-LAN magic packet +``` + +| Type | Module | Config fields | +|---|---|---| +| `"wol"` | `networkWol` | `mac` (required), `broadcast` (default `255.255.255.255`), `port` (default `9`) | + +```yaml +network: + type: "wol" + mac: "aa:bb:cc:dd:ee:ff" + broadcast: "255.255.255.255" # optional - subnet broadcast for directed WoL + port: 9 # optional (7 or 9) +``` + +The seam is designed to grow (e.g. `isReachable()` / `waitForOnline()` as a natural +companion to `wake()`; `deviceClass.pingTest()` already offers ICMP reachability). + --- ## 6. Remote Control diff --git a/docs/modules/device_manager.md b/docs/modules/device_manager.md index 39882fd..7747a7c 100644 --- a/docs/modules/device_manager.md +++ b/docs/modules/device_manager.md @@ -27,6 +27,7 @@ * **Manages:** * `consoles` (dictionary): Stores available consoles for the device. * `powerControl` (`powerControlClass` object): Handles power cycling. + * `networkController` (`networkControlClass` object): Handles network-level actions such as Wake-on-LAN (`dut.networkController.wake()`). * `outBoundClient` (`outboundClientClass` object): Handles outbound connections from the device. * `remoteController` (`commonRemoteClass` object): Manages remote control actions. diff --git a/examples/configs/example_rack_config.yml b/examples/configs/example_rack_config.yml index 8e12cd2..84c0fc4 100644 --- a/examples/configs/example_rack_config.yml +++ b/examples/configs/example_rack_config.yml @@ -95,6 +95,13 @@ rackConfig: # [type: "SLP", ip:"", username: "", password: "", outlet_id:"", port:"optional"] # [type: "none" ] if section doesn't exist then type:none will be used + # [ network: optional ] - Network/wake controller for the slot + # Network-level device control (distinct from powerSwitch); wake-only. + # supported types: + # [type: "wol", mac: "aa:bb:cc:dd:ee:ff", broadcast:"optional (default 255.255.255.255)", port:"optional (default 9)"] + # Wake-on-LAN magic packet - wakes a sleeping / soft-off device on the + # same broadcast domain. Call dut.networkController.wake(). + # [ hdmiCECController: optional ] - Specifies hdmiCECController for the slot # supported types: # [type: "cec-client", adaptor: "/dev/ttycec"] diff --git a/framework/core/deviceManager.py b/framework/core/deviceManager.py index cadf2a1..2ff0f62 100644 --- a/framework/core/deviceManager.py +++ b/framework/core/deviceManager.py @@ -38,6 +38,7 @@ from framework.core.commandModules.telnetClass import telnet from framework.core.logModule import logModule from framework.core.powerControl import powerControlClass +from framework.core.networkControl import networkControlClass from framework.core.outboundClient import outboundClientClass from framework.core.commonRemote import commonRemoteClass from framework.core.hdmiCECController import HDMICECController @@ -160,6 +161,7 @@ def __init__(self, log:logModule, logPath:str, devices:dict): self.log = log self.consoles = dict() self.powerControl = None + self.networkController = None self.outBoundClient = None self.remoteController = None self.hdmiCECController = None @@ -174,6 +176,9 @@ def __init__(self, log:logModule, logPath:str, devices:dict): config = device.get("powerSwitch") if config != None: self.powerControl = powerControlClass( log, config ) + config = device.get("network") + if config != None: + self.networkController = networkControlClass( log, config ) consoles = device.get("consoles") for element in consoles: for name in element: diff --git a/framework/core/networkControl.py b/framework/core/networkControl.py new file mode 100644 index 0000000..fb4014e --- /dev/null +++ b/framework/core/networkControl.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +#** ***************************************************************************** +# * +# * If not stated otherwise in this file or this component's LICENSE file the +# * following copyright and licenses apply: +# * +# * Copyright 2026 RDK Management +# * +# * Licensed under the Apache License, Version 2.0 (the "License"); +# * you may not use this file except in compliance with the License. +# * You may obtain a copy of the License at +# * +# * +# http://www.apache.org/licenses/LICENSE-2.0 +# * +# * Unless required by applicable law or agreed to in writing, software +# * distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. +# * +#* ****************************************************************************** +#* +#* ** Project : RAFT +#* ** @addtogroup : core +#* ** @file : networkControl.py +#* ** +#* ** @brief : Network-level device control (e.g. Wake-on-LAN wake) for a DUT. +#* ** +#* ****************************************************************************** + +from framework.core.networkModules.wol import networkWol +from framework.core.utilities import utilities + +from framework.core.logModule import logModule + +class networkControlClass(): + + def __init__(self, log:logModule, config:dict): + """Initialise the network controller. + + Args: + log (logModule): log module class + config (dict): configuration from the .yml file (a device ``network:`` block) + """ + self.log = log + self.utils = utilities(log) + self.name = config.get("name") + + # If variables are not passed in the config they will be defaulted to retryCount: [1], retryDelay: [30] + self.retryCount = config.get("retryCount", 1) + self.retryDelay = config.get("retryDelay", 30) + + self.networkModule = None + type = config.get("type") + if type == "wol": + self.networkModule = networkWol(log, config.get("mac"), config.get("broadcast", "255.255.255.255"), config.get("port", 9)) + else: + self.log.error("Network controller type [{}] unknown".format(type)) + return + + def wake(self): + """Wake the device over the network (e.g. Wake-on-LAN magic packet). + + Returns: + bool: True if the wake was successfully sent. + """ + self.log.info("wake ({})".format(self.name)) + return self.networkRetry(self.networkModule.wake) + + def networkRetry(self, networkMethod): + """Perform the passed networkMethod and retry it if it fails. + + Args: + networkMethod (Method): The networkMethod to perform. + + Raises: + e: Exception if the networkMethod fails after all of the retries. + + Returns: + boolean: Whether the networkMethod was successfully performed. + """ + for x in range(self.retryCount+1): + try: + result = networkMethod() + break + except Exception as e: + self.log.info(f"Could not perform {[networkMethod.__name__]}. Retry count: [{x}] of [{self.retryCount}], delay is [{self.retryDelay}]") + if x == self.retryCount: + raise e + self.utils.wait(self.retryDelay) + return result diff --git a/framework/core/networkModules/__init__.py b/framework/core/networkModules/__init__.py new file mode 100644 index 0000000..1ab8f7f --- /dev/null +++ b/framework/core/networkModules/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 + +import sys +import os + +dir_path = os.path.dirname(os.path.realpath(__file__)) +sys.path.append( dir_path ) + +#print("framework-> networkModules -> __init__.py") diff --git a/framework/core/powerModules/wol.py b/framework/core/networkModules/wol.py similarity index 71% rename from framework/core/powerModules/wol.py rename to framework/core/networkModules/wol.py index 1780342..030732f 100644 --- a/framework/core/powerModules/wol.py +++ b/framework/core/networkModules/wol.py @@ -22,9 +22,9 @@ #* ****************************************************************************** #* #* ** Project : RAFT -#* ** @addtogroup : core.powerModules +#* ** @addtogroup : core.networkModules #* ** @file : wol.py -#* ** @brief : Wake-on-LAN power module. powerOn() sends a magic packet to +#* ** @brief : Wake-on-LAN network module. wake() sends a magic packet to #* ** wake a sleeping / soft-off device on the same broadcast domain. #* ** #* ****************************************************************************** @@ -32,18 +32,18 @@ import socket -class powerWol(): - """Wake-on-LAN "power switch". +class networkWol(): + """Wake-on-LAN network module. A magic packet wakes a device that is asleep or in a Wake-on-LAN soft-off state, provided the host sending it shares the target's layer-2 broadcast - domain (or a directed subnet broadcast reaches it). Wake-on-LAN is - wake-only: it cannot power a device off, so powerOff() is a logged no-op and - reboot() is a best-effort wake. + domain (or a directed subnet broadcast reaches it). Wake-on-LAN is a + network (layer-2) action, not a power action: it can only wake a device, so + the module exposes a single ``wake()`` verb. - Rack-config fields (under a device's ``powerSwitch:``):: + Rack-config fields (under a device's ``network:``):: - powerSwitch: + network: type: "wol" mac: "b0:3e:51:ff:f6:bc" # required - target NIC MAC broadcast: "255.255.255.255" # optional - subnet broadcast for directed WoL @@ -60,11 +60,11 @@ def __init__(self, log, mac, broadcast="255.255.255.255", port=9): """ self.log = log if not mac: - raise ValueError("powerWol requires a 'mac' address") + raise ValueError("networkWol requires a 'mac' address") self.mac = mac self.broadcast = broadcast or "255.255.255.255" self.port = int(port) if port else 9 - self.log.info("powerWol(mac={}, broadcast={}, port={})".format( + self.log.info("networkWol(mac={}, broadcast={}, port={})".format( self.mac, self.broadcast, self.port)) def _magicPacket(self): @@ -74,7 +74,7 @@ def _magicPacket(self): raise ValueError("Invalid MAC address: {}".format(self.mac)) return b"\xff" * 6 + bytes.fromhex(hexMac) * 16 - def powerOn(self): + def wake(self): """Send the Wake-on-LAN magic packet. Returns: @@ -85,26 +85,8 @@ def powerOn(self): with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.sendto(packet, (self.broadcast, self.port)) - self.log.info("powerWol().powerOn: magic packet sent to {}".format(self.mac)) + self.log.info("networkWol().wake: magic packet sent to {}".format(self.mac)) return True except Exception as error: - self.log.error("powerWol().powerOn failed: {}".format(error)) + self.log.error("networkWol().wake failed: {}".format(error)) return False - - def powerOff(self): - """Wake-on-LAN cannot power a device off; logged no-op. - - Returns: - bool: Always True (nothing to do). - """ - self.log.info("powerWol().powerOff: Wake-on-LAN is wake-only; no power-off performed") - return True - - def reboot(self): - """No hard power cycle over Wake-on-LAN; best-effort wake. - - Returns: - bool: Result of powerOn(). - """ - self.log.info("powerWol().reboot: Wake-on-LAN is wake-only; sending wake") - return self.powerOn() diff --git a/framework/core/powerControl.py b/framework/core/powerControl.py index 703c89a..e0e7c5c 100644 --- a/framework/core/powerControl.py +++ b/framework/core/powerControl.py @@ -53,7 +53,6 @@ from framework.core.powerModules.hs100 import powerHS100 from framework.core.powerModules.SLP import powerSLP from framework.core.powerModules.none import powerNone -from framework.core.powerModules.wol import powerWol from framework.core.utilities import utilities from framework.core.logModule import logModule @@ -95,8 +94,6 @@ def __init__(self, log:logModule, config:dict): self.powerSwitch = powerTapo( log, **config) elif type == "SLP": self.powerSwitch = powerSLP(log, self.ip, config.get("username"), config.get("password"), config.get("outlet_id"),config.get('port',23)) - elif type == "wol": - self.powerSwitch = powerWol( log, config.get("mac"), config.get("broadcast", "255.255.255.255"), config.get("port", 9)) elif type == "none": self.powerSwitch = powerNone( log ) else: diff --git a/framework/core/testControl.py b/framework/core/testControl.py index 30cba8b..b6142ca 100644 --- a/framework/core/testControl.py +++ b/framework/core/testControl.py @@ -205,6 +205,7 @@ def __init__(self, testName="", qcId="", maxRunTime=TEST_MAX_RUN_TIME, level=log self.session = self.dut.getConsoleSession(console_name) self.outboundClient = self.dut.outBoundClient self.powerControl = self.dut.powerControl + self.networkController = self.dut.networkController self.commonRemote = self.dut.remoteController self.hdmiCECController = self.dut.hdmiCECController self.utils = utilities(self.log) diff --git a/tests/networkControl_tests.py b/tests/networkControl_tests.py new file mode 100644 index 0000000..748d5b8 --- /dev/null +++ b/tests/networkControl_tests.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +#** ***************************************************************************** +# * +# * If not stated otherwise in this file or this component's LICENSE file the +# * following copyright and licenses apply: +# * +# * Copyright 2026 RDK Management +# * +# * Licensed under the Apache License, Version 2.0 (the "License"); +# * you may not use this file except in compliance with the License. +# * You may obtain a copy of the License at +# * +# * +# http://www.apache.org/licenses/LICENSE-2.0 +# * +# * Unless required by applicable law or agreed to in writing, software +# * distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. +# * +#* ****************************************************************************** + +import os +import sys + +# Add the framework path to system +dir_path = os.path.dirname(os.path.realpath(__file__)) +sys.path.append(dir_path+"/../") + +from framework.core.networkControl import networkControlClass +from framework.core.logModule import logModule + +if __name__ == "__main__": + log = logModule("networkControlTest") + log.setLevel( log.INFO ) + + # Network/wake controller straight from a `network:` config block + config = { "type": "wol", "mac": "aa:bb:cc:dd:ee:ff" } + control = networkControlClass( log, config ) + + log.info("Testing wake") + control.wake() + + log.info("Test complete") From 1fc2e62357c772e3feec4bf27dc2eb03c044ab2e Mon Sep 17 00:00:00 2001 From: Ulrond <131959864+Ulrond@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:01:05 +0100 Subject: [PATCH 3/5] fix(network): fail-fast MAC validation, snake_case, wake() guard (review) Addresses review feedback on the WoL network module: - Validate/normalise the MAC at construction (strip whitespace, require 12 hex digits) with a clear, field-named ValueError, instead of only checking length later and surfacing a generic bytes.fromhex() error. Misconfiguration now fails when the device is built, not silently at wake() time. - Rename _magicPacket -> _magic_packet for consistency with the other modules' snake_case private helpers; update the call site. - Guard networkControlClass.wake() when no module was constructed (unknown 'type') so it returns a clear error instead of an AttributeError on None. --- framework/core/networkControl.py | 3 +++ framework/core/networkModules/wol.py | 27 +++++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/framework/core/networkControl.py b/framework/core/networkControl.py index fb4014e..b18e34c 100644 --- a/framework/core/networkControl.py +++ b/framework/core/networkControl.py @@ -65,6 +65,9 @@ def wake(self): Returns: bool: True if the wake was successfully sent. """ + if self.networkModule is None: + self.log.error("wake ({}): no network module configured (check 'type')".format(self.name)) + return False self.log.info("wake ({})".format(self.name)) return self.networkRetry(self.networkModule.wake) diff --git a/framework/core/networkModules/wol.py b/framework/core/networkModules/wol.py index 030732f..b546cb3 100644 --- a/framework/core/networkModules/wol.py +++ b/framework/core/networkModules/wol.py @@ -62,17 +62,32 @@ def __init__(self, log, mac, broadcast="255.255.255.255", port=9): if not mac: raise ValueError("networkWol requires a 'mac' address") self.mac = mac + # Validate/normalise the MAC up front so a misconfiguration fails at + # construction with a clear error, rather than silently at wake() time. + self._hex_mac = self._normalise_mac(mac) self.broadcast = broadcast or "255.255.255.255" self.port = int(port) if port else 9 self.log.info("networkWol(mac={}, broadcast={}, port={})".format( self.mac, self.broadcast, self.port)) - def _magicPacket(self): + @staticmethod + def _normalise_mac(mac): + """Strip separators/whitespace and validate the MAC as 12 hex digits. + + Raises: + ValueError: with a clear, field-named message if the MAC is not + twelve hexadecimal digits. + """ + hex_mac = mac.strip().replace(":", "").replace("-", "").replace(".", "") + if len(hex_mac) != 12 or any(c not in "0123456789abcdefABCDEF" for c in hex_mac): + raise ValueError( + "Invalid Wake-on-LAN 'mac' address: {!r} " + "(expected 12 hex digits, e.g. aa:bb:cc:dd:ee:ff)".format(mac)) + return hex_mac + + def _magic_packet(self): """Build the Wake-on-LAN magic packet: 6x 0xFF followed by the MAC x16.""" - hexMac = self.mac.replace(":", "").replace("-", "").replace(".", "") - if len(hexMac) != 12: - raise ValueError("Invalid MAC address: {}".format(self.mac)) - return b"\xff" * 6 + bytes.fromhex(hexMac) * 16 + return b"\xff" * 6 + bytes.fromhex(self._hex_mac) * 16 def wake(self): """Send the Wake-on-LAN magic packet. @@ -81,7 +96,7 @@ def wake(self): bool: True if the packet was sent, False on error. """ try: - packet = self._magicPacket() + packet = self._magic_packet() with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.sendto(packet, (self.broadcast, self.port)) From 39d0dd00f05c241e45ad5000b5f25babc95c9a3a Mon Sep 17 00:00:00 2001 From: Ulrond <131959864+Ulrond@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:53:19 +0100 Subject: [PATCH 4/5] fix(network): retry on failure + convert WoL test to automated unittest (review) - networkRetry now retries on a falsy return as well as on exceptions, up to retryCount with a fixed retryDelay (no exponential backoff -- a WoL packet either lands or it does not). Previously it only retried on exceptions, so the configured retries never fired for a failed send. Also fixes the retry log message (bare method name instead of "['wake']") and no longer re-raises. - tests/networkControl_tests.py is now a unittest.TestCase (assertions, CI-runnable) instead of a manual print script: magic-packet correctness, MAC normalisation, fail-fast invalid-MAC, wake() send over loopback, unknown-type guard, and retry-count behaviour. --- framework/core/networkControl.py | 23 +++++---- tests/networkControl_tests.py | 84 ++++++++++++++++++++++++++++---- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/framework/core/networkControl.py b/framework/core/networkControl.py index b18e34c..079c87f 100644 --- a/framework/core/networkControl.py +++ b/framework/core/networkControl.py @@ -74,22 +74,27 @@ def wake(self): def networkRetry(self, networkMethod): """Perform the passed networkMethod and retry it if it fails. + Retries on both a raised exception and a falsy return, up to + ``retryCount`` times with a fixed ``retryDelay`` between attempts + (Wake-on-LAN either lands or it does not -- no exponential backoff). + Args: networkMethod (Method): The networkMethod to perform. - Raises: - e: Exception if the networkMethod fails after all of the retries. - Returns: - boolean: Whether the networkMethod was successfully performed. + boolean: True if any attempt succeeded, otherwise False. """ - for x in range(self.retryCount+1): + result = False + for attempt in range(self.retryCount + 1): try: result = networkMethod() + except Exception as error: + self.log.error("{} raised: {}".format(networkMethod.__name__, error)) + result = False + if result: break - except Exception as e: - self.log.info(f"Could not perform {[networkMethod.__name__]}. Retry count: [{x}] of [{self.retryCount}], delay is [{self.retryDelay}]") - if x == self.retryCount: - raise e + if attempt < self.retryCount: + self.log.info("{} failed, retry [{}] of [{}] after [{}]s".format( + networkMethod.__name__, attempt + 1, self.retryCount, self.retryDelay)) self.utils.wait(self.retryDelay) return result diff --git a/tests/networkControl_tests.py b/tests/networkControl_tests.py index 748d5b8..8e73e9c 100644 --- a/tests/networkControl_tests.py +++ b/tests/networkControl_tests.py @@ -20,26 +20,90 @@ # * limitations under the License. # * #* ****************************************************************************** +#* +#* ** @brief : Automated unit tests for the network/wake controller (WoL). +#* ** Runs under `python3 -m unittest` -- no device/rack config +#* ** required (the WoL logic is exercised over loopback). +#* ****************************************************************************** import os import sys +import socket +import unittest # Add the framework path to system dir_path = os.path.dirname(os.path.realpath(__file__)) -sys.path.append(dir_path+"/../") +sys.path.append(dir_path + "/../") from framework.core.networkControl import networkControlClass +from framework.core.networkModules.wol import networkWol from framework.core.logModule import logModule -if __name__ == "__main__": - log = logModule("networkControlTest") - log.setLevel( log.INFO ) +MAC = "aa:bb:cc:dd:ee:ff" +EXPECTED = b"\xff" * 6 + bytes.fromhex("aabbccddeeff") * 16 + + +class TestNetworkWol(unittest.TestCase): + """Unit tests for the Wake-on-LAN network module and controller.""" + + @classmethod + def setUpClass(cls): + cls.log = logModule("networkControlTest") + cls.log.setLevel(cls.log.CRITICAL) + + def test_magic_packet(self): + """Magic packet is 6x0xFF followed by the MAC x16 (102 bytes).""" + packet = networkWol(self.log, MAC)._magic_packet() + self.assertEqual(packet, EXPECTED) + self.assertEqual(len(packet), 102) + + def test_mac_normalisation(self): + """Separators and surrounding whitespace are accepted.""" + for mac in ("aa:bb:cc:dd:ee:ff", "aa-bb-cc-dd-ee-ff", " aabbccddeeff "): + self.assertEqual(networkWol(self.log, mac)._magic_packet(), EXPECTED) - # Network/wake controller straight from a `network:` config block - config = { "type": "wol", "mac": "aa:bb:cc:dd:ee:ff" } - control = networkControlClass( log, config ) + def test_invalid_mac_rejected_at_construction(self): + """A bad MAC fails fast with a clear ValueError, not at wake() time.""" + for bad in ("", "aa:bb:cc", "zz:bb:cc:dd:ee:ff"): + with self.assertRaises(ValueError): + networkWol(self.log, bad) - log.info("Testing wake") - control.wake() + def test_wake_sends_magic_packet(self): + """wake() sends the magic packet to the configured broadcast:port.""" + rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + rx.bind(("127.0.0.1", 0)) + port = rx.getsockname()[1] + rx.settimeout(2) + try: + control = networkControlClass( + self.log, {"type": "wol", "mac": MAC, "broadcast": "127.0.0.1", "port": port}) + self.assertTrue(control.wake()) + received, _ = rx.recvfrom(1024) + self.assertEqual(received, EXPECTED) + finally: + rx.close() - log.info("Test complete") + def test_unknown_type_does_not_crash(self): + """An unknown type yields no module and wake() returns False (no AttributeError).""" + control = networkControlClass(self.log, {"type": "nope"}) + self.assertIsNone(control.networkModule) + self.assertFalse(control.wake()) + + def test_retry_on_failure(self): + """networkRetry retries on a falsy result up to retryCount, then gives up.""" + control = networkControlClass(self.log, {"type": "wol", "mac": MAC}) + control.retryCount = 2 + control.retryDelay = 0 + self.calls = 0 + + def always_fail(): + self.calls += 1 + return False + + self.assertFalse(control.networkRetry(always_fail)) + self.assertEqual(self.calls, 3) # initial attempt + 2 retries + + +if __name__ == "__main__": + unittest.main() From f8131e3dca1079bd932ac8a1cfaad575770281a0 Mon Sep 17 00:00:00 2001 From: Ulrond <131959864+Ulrond@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:01:54 +0100 Subject: [PATCH 5/5] fix(network): clearer wake logging (name fallback, broadcast:port in message) Addresses remaining Copilot log-clarity comments: default the controller name to the type so logs read 'wake (wol)' not 'wake (None)', and state the actual destination (broadcast:port for the target MAC) in the wake log line. --- framework/core/networkControl.py | 3 ++- framework/core/networkModules/wol.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/framework/core/networkControl.py b/framework/core/networkControl.py index 079c87f..0a00fcf 100644 --- a/framework/core/networkControl.py +++ b/framework/core/networkControl.py @@ -45,7 +45,8 @@ def __init__(self, log:logModule, config:dict): """ self.log = log self.utils = utilities(log) - self.name = config.get("name") + # Fall back to the type so logs read e.g. "wake (wol)" rather than "wake (None)". + self.name = config.get("name") or config.get("type") # If variables are not passed in the config they will be defaulted to retryCount: [1], retryDelay: [30] self.retryCount = config.get("retryCount", 1) diff --git a/framework/core/networkModules/wol.py b/framework/core/networkModules/wol.py index b546cb3..631c41a 100644 --- a/framework/core/networkModules/wol.py +++ b/framework/core/networkModules/wol.py @@ -100,7 +100,8 @@ def wake(self): with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.sendto(packet, (self.broadcast, self.port)) - self.log.info("networkWol().wake: magic packet sent to {}".format(self.mac)) + self.log.info("networkWol().wake: magic packet for {} sent to {}:{}".format( + self.mac, self.broadcast, self.port)) return True except Exception as error: self.log.error("networkWol().wake failed: {}".format(error))