Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -115,13 +118,14 @@ class testController:
5. Selects rack and slot (from CLI `--rack` / `--slot` / `--slotName`)
6. Constructs log paths under `logs/<rack>/<slot>/<timestamp>/`
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/`.
Comment on lines +354 to +357

### 5.1 Power modules

`powerControlClass` delegates to a specific module based on `type` in config.

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/modules/device_manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions examples/configs/example_rack_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Comment on lines +98 to +103

# [ hdmiCECController: optional ] - Specifies hdmiCECController for the slot
# supported types:
# [type: "cec-client", adaptor: "/dev/ttycec"]
Expand Down
5 changes: 5 additions & 0 deletions framework/core/deviceManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
95 changes: 95 additions & 0 deletions framework/core/networkControl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/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)
Comment on lines +50 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should retries really come from the config. Not the test?

Comment on lines +48 to +52

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably get people to use the backoff library for this https://pypi.org/project/backoff/. I think this method becomes a nightmare if we ever add any methods that require arguments.

"""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
9 changes: 9 additions & 0 deletions framework/core/networkModules/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
107 changes: 107 additions & 0 deletions framework/core/networkModules/wol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/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():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking the networkController class would be where we would implement interacting with different network switches. So users could look up MAC addresses, IPs, enable/disable ports firewalls etc. So I was thinking this could just be a generic Host based network controller. With methods implemented to run on the host, providing some of that functionality.

What do you think?

"""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 sent to {}".format(self.mac))
return True
except Exception as error:
self.log.error("networkWol().wake failed: {}".format(error))
return False
1 change: 1 addition & 0 deletions framework/core/testControl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading