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..0a00fcf --- /dev/null +++ b/framework/core/networkControl.py @@ -0,0 +1,101 @@ +#!/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) + # 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) + 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. + """ + 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) + + 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. + + Returns: + boolean: True if any attempt succeeded, otherwise False. + """ + 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 + 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/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/networkModules/wol.py b/framework/core/networkModules/wol.py new file mode 100644 index 0000000..631c41a --- /dev/null +++ b/framework/core/networkModules/wol.py @@ -0,0 +1,108 @@ +#!/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.networkModules +#* ** @file : wol.py +#* ** @brief : Wake-on-LAN network module. wake() sends a magic packet to +#* ** wake a sleeping / soft-off device on the same broadcast domain. +#* ** +#* ****************************************************************************** + +import socket + + +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 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 ``network:``):: + + 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 + 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("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)) + + @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.""" + return b"\xff" * 6 + bytes.fromhex(self._hex_mac) * 16 + + def wake(self): + """Send the Wake-on-LAN magic packet. + + Returns: + bool: True if the packet was sent, False on error. + """ + try: + 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)) + 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)) + return False 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..8e73e9c --- /dev/null +++ b/tests/networkControl_tests.py @@ -0,0 +1,109 @@ +#!/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. +# * +#* ****************************************************************************** +#* +#* ** @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 + "/../") + +from framework.core.networkControl import networkControlClass +from framework.core.networkModules.wol import networkWol +from framework.core.logModule import logModule + +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) + + 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) + + 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() + + 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()