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 thread
Ulrond marked this conversation as resolved.

### 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 thread
Ulrond marked this conversation as resolved.

# [ 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
101 changes: 101 additions & 0 deletions framework/core/networkControl.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +51 to +53

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?

@Ulrond Ulrond Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thank you read that wrong. This is coming from the config, config.get("retryCount", 1) it just has a default if the config isn't present.

the retry count is a system control not a test control, that's what I required, that would then mean that the hardware we're talking too has a specification, and if it doesn't meet that then it fails... And that's why it's setup this way.

It's not ok for a test to think it knows the specification of the hardware.

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.

In this case the hardware is the DUT, right? If that's the case I would expect the retry count to be in the device_config.yml and the test to read it and pass it in at run time. I could be missing something though?


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):
Comment thread
TB-1993 marked this conversation as resolved.
"""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
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")
108 changes: 108 additions & 0 deletions framework/core/networkModules/wol.py
Original file line number Diff line number Diff line change
@@ -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():
Comment thread
TB-1993 marked this conversation as resolved.
"""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))
Comment on lines +68 to +71

@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
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