Skip to content
Merged
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
168 changes: 132 additions & 36 deletions cross_platform/src/pace_controller/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import ipaddress
import json
import os
import platform
Expand All @@ -19,6 +20,8 @@ class NetworkLease:
interface: str
address_added: bool
platform_name: str
route_added: bool = False
source_address: str = "192.168.10.1"


def configure_dedicated_adapter(
Expand All @@ -35,20 +38,37 @@ def configure_dedicated_adapter(


def restore_dedicated_adapter(lease: NetworkLease | None, address: str = "192.168.10.1") -> None:
if lease is None or not lease.address_added:
if lease is None:
return
address = lease.source_address or address
if lease.platform_name == "Windows":
script = (
f"Remove-NetIPAddress -InterfaceIndex {int(lease.interface)} "
f"-IPAddress '{address}' -Confirm:$false -ErrorAction SilentlyContinue"
)
commands: list[str] = []
if lease.route_added:
commands.append(
f"Remove-NetRoute -InterfaceIndex {int(lease.interface)} "
"-AddressFamily IPv4 -DestinationPrefix '192.168.10.0/24' "
"-Confirm:$false -ErrorAction SilentlyContinue"
)
if lease.address_added:
commands.append(
f"Remove-NetIPAddress -InterfaceIndex {int(lease.interface)} "
f"-IPAddress '{address}' -Confirm:$false -ErrorAction SilentlyContinue"
)
if not commands:
return
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
[
"powershell",
"-NoProfile",
"-NonInteractive",
"-Command",
"; ".join(commands),
],
check=False,
capture_output=True,
text=True,
)
elif lease.platform_name == "Linux":
elif lease.platform_name == "Linux" and lease.address_added:
command = ["ip", "address", "delete", f"{address}/24", "dev", lease.interface]
_run_linux_privileged(command, check=False)

Expand Down Expand Up @@ -78,38 +98,115 @@ def _configure_windows(address: str, prefix_length: int) -> NetworkLease:
except json.JSONDecodeError as exc:
raise NetworkConfigurationError("Cannot parse Windows adapter information") from exc
adapters = raw if isinstance(raw, list) else [raw]
for item in adapters:
ips = item.get("IPs", [])
if isinstance(ips, str):
ips = [ips]
if address in ips:
return NetworkLease(str(item["Index"]), False, "Windows")
candidates = [
item
for item in adapters
if not item.get("HasGateway", False)
and all(str(ip).startswith("169.254.") for ip in (item.get("IPs", []) if isinstance(item.get("IPs", []), list) else [item.get("IPs")]))
]
if len(candidates) != 1:
network = ipaddress.ip_network(f"{address}/{prefix_length}", strict=False)
owners = [item for item in adapters if address in _windows_ips(item)]
if len(owners) > 1:
raise NetworkConfigurationError(
f"Expected exactly one safe dedicated Ethernet adapter; found {len(candidates)}. No adapter was modified."
f"Address {address} is present on multiple adapters. No adapter was modified."
)
index = int(candidates[0]["Index"])
command = (
f"New-NetIPAddress -InterfaceIndex {index} -IPAddress '{address}' "
f"-PrefixLength {prefix_length} -ErrorAction Stop | Out-Null"
)
result = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", command],

address_added = False
if owners:
selected = owners[0]
else:
candidates = [
item
for item in adapters
if not item.get("HasGateway", False)
and all(value.is_link_local for value in _windows_ipv4_addresses(item))
]
if len(candidates) != 1:
raise NetworkConfigurationError(
"Expected exactly one safe dedicated Ethernet adapter; "
f"found {len(candidates)}. No adapter was modified."
)
selected = candidates[0]

conflicts = [
item
for item in adapters
if item is not selected
and any(value in network for value in _windows_ipv4_addresses(item))
]
if conflicts:
raise NetworkConfigurationError(
f"Network {network} is already used by another adapter. No adapter was modified."
)

index = int(selected["Index"])
command = (
f"New-NetIPAddress -InterfaceIndex {index} -AddressFamily IPv4 "
f"-IPAddress '{address}' -PrefixLength {prefix_length} "
"-PolicyStore ActiveStore -ErrorAction Stop | Out-Null"
)
result = _run_windows_powershell(command)
if result.returncode != 0:
raise NetworkConfigurationError(
result.stderr.strip()
or "Administrator privileges are required to configure Ethernet."
)
address_added = True

index = int(selected["Index"])
destination = str(network)
prepare = f"""
$temporaryAddress = $null
for ($attempt = 1; $attempt -le 20; $attempt++) {{
$temporaryAddress = @(Get-NetIPAddress -InterfaceIndex {index} -AddressFamily IPv4 -IPAddress '{address}' -ErrorAction SilentlyContinue |
Where-Object AddressState -eq 'Preferred')
if ($temporaryAddress.Count -gt 0) {{ break }}
Start-Sleep -Milliseconds 500
}}
if ($temporaryAddress.Count -eq 0) {{
throw 'Windows did not make {address}/{prefix_length} operational on adapter {index}.'
}}
$routes = @(Get-NetRoute -InterfaceIndex {index} -AddressFamily IPv4 -DestinationPrefix '{destination}' -ErrorAction SilentlyContinue)
if ($routes.Count -eq 0) {{
New-NetRoute -InterfaceIndex {index} -AddressFamily IPv4 -DestinationPrefix '{destination}' -NextHop '0.0.0.0' -RouteMetric 1 -PolicyStore ActiveStore -ErrorAction Stop | Out-Null
Write-Output 'created'
}} else {{
Write-Output 'existing'
}}
"""
result = _run_windows_powershell(prepare)
if result.returncode != 0:
if address_added:
restore_dedicated_adapter(
NetworkLease(str(index), True, "Windows", source_address=address)
)
raise NetworkConfigurationError(
result.stderr.strip() or "Cannot prepare the Windows route to the PACE."
)
route_added = result.stdout.strip().splitlines()[-1:] == ["created"]
return NetworkLease(str(index), address_added, "Windows", route_added, address)


def _windows_ips(item: dict[str, object]) -> list[str]:
values = item.get("IPs", [])
if isinstance(values, str):
return [values]
if not isinstance(values, list):
return []
return [str(value) for value in values if value]


def _windows_ipv4_addresses(item: dict[str, object]) -> list[ipaddress.IPv4Address]:
addresses: list[ipaddress.IPv4Address] = []
for value in _windows_ips(item):
try:
addresses.append(ipaddress.IPv4Address(value))
except ipaddress.AddressValueError:
continue
return addresses


def _run_windows_powershell(script: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise NetworkConfigurationError(
result.stderr.strip() or "Administrator privileges are required to configure Ethernet."
)
return NetworkLease(str(index), True, "Windows")


def _configure_linux(address: str, prefix_length: int) -> NetworkLease:
Expand Down Expand Up @@ -147,7 +244,7 @@ def _configure_linux(address: str, prefix_length: int) -> NetworkLease:
if entry.get("family") == "inet"
]
if address in addresses:
return NetworkLease(name, False, "Linux")
return NetworkLease(name, False, "Linux", source_address=address)
if all(str(value).startswith("169.254.") for value in addresses):
candidates.append(name)
if len(candidates) != 1:
Expand All @@ -159,7 +256,7 @@ def _configure_linux(address: str, prefix_length: int) -> NetworkLease:
["ip", "address", "add", f"{address}/{prefix_length}", "dev", name],
check=True,
)
return NetworkLease(name, True, "Linux")
return NetworkLease(name, True, "Linux", source_address=address)


def _run_linux_privileged(command: list[str], check: bool) -> None:
Expand All @@ -173,4 +270,3 @@ def _run_linux_privileged(command: list[str], check: bool) -> None:
raise NetworkConfigurationError(
result.stderr.strip() or "Failed to configure the dedicated Ethernet adapter."
)

8 changes: 5 additions & 3 deletions cross_platform/src/pace_controller/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,12 @@ def _connect(self, config: ConnectionConfig, module: int) -> None:
raise
self._network_lease = configure_dedicated_adapter()
self._write_log(
f"Temporarily configured dedicated adapter {self._network_lease.interface} for 192.168.10.1/24."
f"Prepared dedicated adapter {self._network_lease.interface} "
"and route for 192.168.10.1/24."
)
self._transport = create_transport(
config, source_address=self._network_lease.source_address
)
self._transport = create_transport(config)
self._transport.connect()

identity = self._query("*IDN?")
Expand Down Expand Up @@ -590,4 +593,3 @@ def _write_log(self, message: str) -> None:

def _emit_alarm(self, key: str, **values: object) -> None:
self.alarm.emit({"key": key, **values})

35 changes: 29 additions & 6 deletions cross_platform/src/pace_controller/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,37 @@ def query(self, command: str) -> str: ...


class TcpTransport(ScpiTransport):
def __init__(self, host: str, port: int = 5025, timeout: float = 2.0) -> None:
def __init__(
self,
host: str,
port: int = 5025,
timeout: float = 2.0,
source_address: str | None = None,
) -> None:
self.host = host
self.port = port
self.timeout = timeout
self.source_address = source_address
self._socket: socket.socket | None = None
self._buffer = bytearray()
self._lock = threading.Lock()

def connect(self) -> None:
self.close()
connection: socket.socket | None = None
try:
self._socket = socket.create_connection((self.host, self.port), self.timeout)
self._socket.settimeout(self.timeout)
source = (self.source_address, 0) if self.source_address else None
connection = socket.create_connection(
(self.host, self.port), self.timeout, source_address=source
)
connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
connection.settimeout(self.timeout)
self._socket = connection
except OSError as exc:
raise TransportError(f"TCP {self.host}:{self.port}: {exc}") from exc
if connection is not None:
connection.close()
via = f" via {self.source_address}" if self.source_address else ""
raise TransportError(f"TCP {self.host}:{self.port}{via}: {exc}") from exc

def close(self) -> None:
if self._socket is not None:
Expand Down Expand Up @@ -337,9 +353,16 @@ def _normalize(command: str) -> str:
return " ".join(command.strip().upper().split())


def create_transport(config: ConnectionConfig) -> ScpiTransport:
def create_transport(
config: ConnectionConfig, *, source_address: str | None = None
) -> ScpiTransport:
if config.kind == ConnectionKind.ETHERNET:
return TcpTransport(config.host, config.port, config.timeout)
return TcpTransport(
config.host,
config.port,
config.timeout,
source_address=source_address,
)
if config.kind == ConnectionKind.SERIAL:
if not config.serial_port:
raise TransportError("No serial port selected")
Expand Down
Loading