Coverage for src / server_list / spec / ups_collector.py: 88%
128 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-31 11:45 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-31 11:45 +0000
1#!/usr/bin/env python3
2"""
3NUT (Network UPS Tools) data collector.
5Communicates with NUT server via TCP socket to collect UPS information.
6"""
8import logging
9import socket
11import server_list.spec.models as models
13DEFAULT_PORT = 3493
14SOCKET_TIMEOUT = 10
17def _send_command(sock: socket.socket, command: str) -> list[str]:
18 """Send a command to NUT server and receive response.
20 Args:
21 sock: Connected socket
22 command: NUT command to send
24 Returns:
25 List of response lines
26 """
27 sock.sendall(f"{command}\n".encode("utf-8"))
29 response = b""
30 while True:
31 chunk = sock.recv(4096)
32 if not chunk: 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true
33 break
34 response += chunk
35 # Check for end of response
36 decoded = response.decode("utf-8")
37 if "END LIST" in decoded or decoded.startswith("ERR "): 37 ↛ 30line 37 didn't jump to line 30 because the condition on line 37 was always true
38 break
40 return response.decode("utf-8").strip().split("\n")
43def _parse_list_ups(lines: list[str]) -> list[tuple[str, str]]:
44 """Parse LIST UPS response.
46 Args:
47 lines: Response lines from LIST UPS command
49 Returns:
50 List of (ups_name, description) tuples
51 """
52 ups_list = []
53 for line in lines:
54 if line.startswith("UPS "):
55 # Format: UPS <upsname> "<description>"
56 parts = line.split(" ", 2)
57 if len(parts) >= 2: 57 ↛ 53line 57 didn't jump to line 53 because the condition on line 57 was always true
58 ups_name = parts[1]
59 description = parts[2].strip('"') if len(parts) > 2 else ""
60 ups_list.append((ups_name, description))
61 return ups_list
64def _parse_list_var(lines: list[str]) -> dict[str, str]:
65 """Parse LIST VAR response.
67 Args:
68 lines: Response lines from LIST VAR command
70 Returns:
71 Dict mapping variable name to value
72 """
73 variables = {}
74 for line in lines:
75 if line.startswith("VAR "):
76 # Format: VAR <upsname> <varname> "<value>"
77 parts = line.split(" ", 3)
78 if len(parts) >= 4: 78 ↛ 74line 78 didn't jump to line 74 because the condition on line 78 was always true
79 var_name = parts[2]
80 value = parts[3].strip('"')
81 variables[var_name] = value
82 return variables
85def _parse_list_client(lines: list[str]) -> list[str]:
86 """Parse LIST CLIENT response.
88 Args:
89 lines: Response lines from LIST CLIENT command
91 Returns:
92 List of client IP addresses
93 """
94 clients = []
95 for line in lines:
96 if line.startswith("CLIENT "):
97 # Format: CLIENT <upsname> <client_ip>
98 parts = line.split(" ")
99 if len(parts) >= 3: 99 ↛ 95line 99 didn't jump to line 95 because the condition on line 99 was always true
100 clients.append(parts[2])
101 return clients
104def _safe_float(value: str | None) -> float | None:
105 """Convert string to float safely."""
106 if value is None:
107 return None
108 try:
109 return float(value)
110 except (ValueError, TypeError):
111 return None
114def _safe_int(value: str | None) -> int | None:
115 """Convert string to int safely."""
116 if value is None:
117 return None
118 try:
119 return int(float(value))
120 except (ValueError, TypeError):
121 return None
124def connect_to_nut(host: str, port: int = DEFAULT_PORT) -> socket.socket | None:
125 """Connect to NUT server.
127 Args:
128 host: NUT server hostname
129 port: NUT server port (default: 3493)
131 Returns:
132 Connected socket or None if connection failed
133 """
134 try:
135 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
136 sock.settimeout(SOCKET_TIMEOUT)
137 sock.connect((host, port))
138 return sock
139 except (OSError, socket.error) as e:
140 logging.warning("Failed to connect to NUT server %s:%d: %s", host, port, e)
141 return None
144def list_ups(sock: socket.socket) -> list[tuple[str, str]]:
145 """List all UPS devices on the NUT server.
147 Args:
148 sock: Connected socket
150 Returns:
151 List of (ups_name, description) tuples
152 """
153 try:
154 lines = _send_command(sock, "LIST UPS")
155 return _parse_list_ups(lines)
156 except (OSError, socket.error) as e:
157 logging.warning("Failed to list UPS: %s", e)
158 return []
161def get_ups_variables(sock: socket.socket, ups_name: str) -> dict[str, str]:
162 """Get all variables for a UPS.
164 Args:
165 sock: Connected socket
166 ups_name: UPS name
168 Returns:
169 Dict mapping variable name to value
170 """
171 try:
172 lines = _send_command(sock, f"LIST VAR {ups_name}")
173 return _parse_list_var(lines)
174 except (OSError, socket.error) as e:
175 logging.warning("Failed to get UPS variables for %s: %s", ups_name, e)
176 return {}
179def get_ups_clients(sock: socket.socket, ups_name: str) -> list[str]:
180 """Get all clients connected to a UPS.
182 Args:
183 sock: Connected socket
184 ups_name: UPS name
186 Returns:
187 List of client IP addresses
188 """
189 try:
190 lines = _send_command(sock, f"LIST CLIENT {ups_name}")
191 return _parse_list_client(lines)
192 except (OSError, socket.error) as e:
193 logging.warning("Failed to get UPS clients for %s: %s", ups_name, e)
194 return []
197def fetch_ups_info(host: str, ups_name: str, port: int = DEFAULT_PORT) -> models.UPSInfo | None:
198 """Fetch UPS information from NUT server.
200 Args:
201 host: NUT server hostname
202 ups_name: UPS name
203 port: NUT server port
205 Returns:
206 UPSInfo or None if failed
207 """
208 sock = connect_to_nut(host, port)
209 if not sock:
210 return None
212 try:
213 variables = get_ups_variables(sock, ups_name)
214 if not variables: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 return None
217 return models.UPSInfo(
218 ups_name=ups_name,
219 host=host,
220 model=variables.get("ups.model"),
221 battery_charge=_safe_float(variables.get("battery.charge")),
222 battery_runtime=_safe_int(variables.get("battery.runtime")),
223 ups_load=_safe_float(variables.get("ups.load")),
224 ups_status=variables.get("ups.status"),
225 ups_temperature=_safe_float(variables.get("ups.temperature")),
226 input_voltage=_safe_float(variables.get("input.voltage")),
227 output_voltage=_safe_float(variables.get("output.voltage")),
228 )
229 finally:
230 sock.close()
233def fetch_ups_clients(host: str, ups_name: str, port: int = DEFAULT_PORT) -> list[models.UPSClient]:
234 """Fetch UPS client information from NUT server.
236 Args:
237 host: NUT server hostname
238 ups_name: UPS name
239 port: NUT server port
241 Returns:
242 List of UPSClient objects
243 """
244 sock = connect_to_nut(host, port)
245 if not sock:
246 return []
248 try:
249 client_ips = get_ups_clients(sock, ups_name)
250 return [
251 models.UPSClient(
252 ups_name=ups_name,
253 host=host,
254 client_ip=ip,
255 client_hostname=None, # Hostname resolution can be added if needed
256 )
257 for ip in client_ips
258 ]
259 finally:
260 sock.close()
263def fetch_all_ups_from_host(
264 host: str, port: int = DEFAULT_PORT, ups_name_filter: str | None = None
265) -> tuple[list[models.UPSInfo], list[models.UPSClient]]:
266 """Fetch all UPS information from a NUT host.
268 Args:
269 host: NUT server hostname
270 port: NUT server port
271 ups_name_filter: If specified, only fetch this UPS
273 Returns:
274 Tuple of (list of UPSInfo, list of UPSClient)
275 """
276 sock = connect_to_nut(host, port)
277 if not sock:
278 return [], []
280 try:
281 # Get list of UPS devices
282 if ups_name_filter: 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true
283 ups_list = [(ups_name_filter, "")]
284 else:
285 ups_list = list_ups(sock)
287 all_ups_info: list[models.UPSInfo] = []
288 all_clients: list[models.UPSClient] = []
290 for ups_name, _ in ups_list:
291 # Get UPS variables
292 variables = get_ups_variables(sock, ups_name)
293 if variables: 293 ↛ 309line 293 didn't jump to line 309 because the condition on line 293 was always true
294 ups_info = models.UPSInfo(
295 ups_name=ups_name,
296 host=host,
297 model=variables.get("ups.model"),
298 battery_charge=_safe_float(variables.get("battery.charge")),
299 battery_runtime=_safe_int(variables.get("battery.runtime")),
300 ups_load=_safe_float(variables.get("ups.load")),
301 ups_status=variables.get("ups.status"),
302 ups_temperature=_safe_float(variables.get("ups.temperature")),
303 input_voltage=_safe_float(variables.get("input.voltage")),
304 output_voltage=_safe_float(variables.get("output.voltage")),
305 )
306 all_ups_info.append(ups_info)
308 # Get UPS clients
309 client_ips = get_ups_clients(sock, ups_name)
310 for ip in client_ips:
311 client = models.UPSClient(
312 ups_name=ups_name,
313 host=host,
314 client_ip=ip,
315 client_hostname=None,
316 )
317 all_clients.append(client)
319 return all_ups_info, all_clients
321 finally:
322 sock.close()