Skip to content

Commit 5f9e621

Browse files
authored
Merge pull request labgrid-project#1822 from AiyionPrime/feat/driver/power/poe_netgear_plus
driver/power/poe_netgear_plus: Add support
2 parents 5f8af1f + 30f1542 commit 5f9e621

4 files changed

Lines changed: 143 additions & 2 deletions

File tree

doc/configuration.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ Currently available are:
216216
``poe_mib``
217217
Controls PoE switches using the PoE SNMP administration MiBs.
218218

219+
``poe_netgear_plus``
220+
Controls NETGEAR Plus switches using an HTTP interface.
221+
219222
``raritan``
220223
Controls *Raritan PDUs* via SNMP.
221224

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Control NETGEAR Plus devices via HTTP.
2+
3+
Available switch models:
4+
https://github.com/foxey/py-netgear-plus?tab=readme-ov-file#supported-and-tested-netgear-modelsproducts-and-firmware-versions
5+
6+
The password defaults to "P4ssword", but can be configured on a per-device basis like this:
7+
8+
NetworkPowerPort:
9+
model: poe_netgear_plus
10+
host: 'http://username_is_unused:AnotherP4ssword@192.168.0.239/'
11+
index: 7
12+
13+
Omitting the password defaults as described above.
14+
15+
NetworkPowerPort:
16+
model: poe_netgear_plus
17+
host: 'http://192.168.0.239/'
18+
index: 7
19+
20+
"""
21+
22+
from urllib.parse import urlparse
23+
24+
from py_netgear_plus import NetgearSwitchConnector
25+
26+
from ..exception import ExecutionError
27+
28+
29+
def _get_hostname_and_password(url: str) -> tuple[str, str]:
30+
"""Obtain credentials from url or default and return hostname and password.
31+
32+
If no password is in the URL return "P4ssword", which fulfills the minimal requirements from Netgear:
33+
- 8-20 characters
34+
- at least one upper case character
35+
- at least one lower case character
36+
- at least one number
37+
38+
Args:
39+
url: A URL with an optional basic auth prefix.
40+
41+
Returns:
42+
A tuple of the hostname, and the extracted or default password
43+
44+
"""
45+
parse_result = urlparse(url)
46+
if parse_result.scheme != "http":
47+
raise ExecutionError(f"URL must start with http://, found {parse_result.scheme} for {url}.")
48+
49+
password = "P4ssword" if parse_result.password is None else parse_result.password
50+
51+
return parse_result.hostname, password
52+
53+
54+
def power_set(host: str, _port: int, index: int, value: bool) -> None:
55+
"""Set the PoE output index based for a given host.
56+
57+
Args:
58+
host: The netloc with optional password e.g. "192.168.0.239" or ":P4ssword@192.168.0.239"
59+
_port: As the webserver of the switch is always on port 80, this is ignored
60+
index: Zero based access to the switches network ports
61+
value: Whether the port should enable PoE output
62+
63+
"""
64+
index = int(index)
65+
netgear_port_number = index + 1
66+
67+
(hostname, password) = _get_hostname_and_password(host)
68+
69+
sw = NetgearSwitchConnector(hostname, password)
70+
sw.autodetect_model()
71+
try:
72+
sw.get_login_cookie()
73+
sw._get_switch_metadata()
74+
if value:
75+
sw.turn_on_poe_port(netgear_port_number)
76+
else:
77+
sw.turn_off_poe_port(netgear_port_number)
78+
finally:
79+
sw.delete_login_cookie()
80+
81+
82+
def power_get(host: str, _port: int, index: int) -> bool:
83+
"""Determine whether a given Port has PoE enabled.
84+
85+
Args:
86+
host: The netloc with optional password e.g. "192.168.0.239" or ":P4ssword@192.168.0.239"
87+
_port: As the webserver of the switch is always on port 80, this is ignored
88+
index: Zero based access to the switches network ports
89+
90+
Returns:
91+
Whether the PoE output is enabled.
92+
93+
Raises:
94+
ExecutionError: In case the status dictionary contains unexpected PoE status values.
95+
96+
"""
97+
index = int(index)
98+
netgear_port_number = index + 1
99+
100+
(hostname, password) = _get_hostname_and_password(host)
101+
102+
sw = NetgearSwitchConnector(hostname, password)
103+
sw.autodetect_model()
104+
try:
105+
sw.get_login_cookie()
106+
sw._get_switch_metadata()
107+
data = sw._get_poe_port_config()
108+
key = f"port_{netgear_port_number}_poe_power_active"
109+
if data[key] == "on":
110+
return True
111+
if data[key] == "off":
112+
return False
113+
msg = f"Expected literal 'on'|'off', found {data[key]}"
114+
raise ExecutionError(msg)
115+
finally:
116+
sw.delete_login_cookie()

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ kasa = ["python-kasa>=0.7.0"]
6565
modbus = ["pyModbusTCP>=0.2.0"]
6666
modbusrtu = ["minimalmodbus>=1.0.2"]
6767
mqtt = ["paho-mqtt>=2.0.0"]
68+
netgear = ["py-netgear-plus>=0.4.7"]
6869
onewire = ["onewire>=0.2"]
6970
pyvisa = [
7071
"pyvisa>=1.11.3",
@@ -78,7 +79,7 @@ vxi11 = ["python-vxi11>=0.9"]
7879
xena = ["xenavalkyrie>=3.0.1"]
7980
deb = ["labgrid[modbus,onewire,snmp]"]
8081
dev = [
81-
"labgrid[doc,docker,graph,kasa,modbus,modbusrtu,mqtt,onewire,pyvisa,snmp,vxi11]",
82+
"labgrid[doc,docker,graph,kasa,modbus,modbusrtu,mqtt,netgear,onewire,pyvisa,snmp,vxi11]",
8283

8384
# additional dev dependencies
8485
"psutil>=5.8.0",
@@ -222,6 +223,7 @@ include = [
222223
"labgrid/driver/manualswitchdriver.py",
223224
"labgrid/driver/power/gude8031.py",
224225
"labgrid/driver/power/pe6216.py",
226+
"labgrid/driver/power/poe_netgear_plus.py",
225227
"labgrid/driver/power/shelly_gen2.py",
226228
"labgrid/driver/rawnetworkinterfacedriver.py",
227229
"labgrid/protocol/**/*.py",

tests/test_powerdriver.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import pytest
44

5+
from labgrid.driver import ExecutionError
6+
from labgrid.driver.power.poe_netgear_plus import _get_hostname_and_password
57
from labgrid.resource import NetworkPowerPort, YKUSHPowerPort
68
from labgrid.driver.powerdriver import (
79
ExternalPowerDriver,
810
ManualPowerDriver,
911
NetworkPowerDriver,
1012
YKUSHPowerDriver,
1113
)
12-
from labgrid.util.helper import processwrapper
1314

1415

1516
class TestManualPowerDriver:
@@ -292,6 +293,7 @@ def test_import_backends(self):
292293
import labgrid.driver.power.netio
293294
import labgrid.driver.power.netio_kshell
294295
import labgrid.driver.power.pe6216
296+
import labgrid.driver.power.poe_netgear_plus
295297
import labgrid.driver.power.rest
296298
import labgrid.driver.power.sentry
297299
import labgrid.driver.power.eg_pms2_network
@@ -380,3 +382,21 @@ def test_ykush3_on(self, target, mocker):
380382
check_output_mock.assert_called_with(
381383
["ykushcmd", "ykush3", "-s", self.YKUSH3_FAKE_SERIAL, "-u", "3"]
382384
)
385+
386+
387+
class TestPoeNetgearPlusPowerDriver:
388+
@pytest.mark.parametrize(
389+
'url, expected_host, expected_pw',
390+
[
391+
("http://example.com", "example.com", "P4ssword"),
392+
("http://ignored:detected_pw@example.com", "example.com", "detected_pw"),
393+
]
394+
)
395+
def test_get_hostname_and_password(self, url: str, expected_host: str, expected_pw: str):
396+
returned_host, returned_pw = _get_hostname_and_password(url)
397+
assert returned_host == expected_host
398+
assert returned_pw == expected_pw
399+
400+
def test_get_hostname_and_pw_non_http_raises(self):
401+
with pytest.raises(ExecutionError, match="URL must start with http://"):
402+
_get_hostname_and_password("no_http_protocol")

0 commit comments

Comments
 (0)