redfish-python-sdk 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. redfish_python_sdk-1.0.0.dist-info/METADATA +164 -0
  2. redfish_python_sdk-1.0.0.dist-info/RECORD +56 -0
  3. redfish_python_sdk-1.0.0.dist-info/WHEEL +5 -0
  4. redfish_python_sdk-1.0.0.dist-info/licenses/LICENSE +29 -0
  5. redfish_python_sdk-1.0.0.dist-info/top_level.txt +1 -0
  6. redfish_sdk/__init__.py +63 -0
  7. redfish_sdk/client.py +1894 -0
  8. redfish_sdk/exceptions.py +56 -0
  9. redfish_sdk/http_client.py +452 -0
  10. redfish_sdk/managers/__init__.py +21 -0
  11. redfish_sdk/managers/_log_helpers.py +144 -0
  12. redfish_sdk/managers/account.py +96 -0
  13. redfish_sdk/managers/chassis.py +327 -0
  14. redfish_sdk/managers/event.py +264 -0
  15. redfish_sdk/managers/managers.py +120 -0
  16. redfish_sdk/managers/registries.py +48 -0
  17. redfish_sdk/managers/session.py +130 -0
  18. redfish_sdk/managers/systems.py +630 -0
  19. redfish_sdk/managers/task.py +89 -0
  20. redfish_sdk/managers/update.py +99 -0
  21. redfish_sdk/managers/update_strategies/__init__.py +51 -0
  22. redfish_sdk/managers/update_strategies/base.py +101 -0
  23. redfish_sdk/managers/update_strategies/h3c.py +140 -0
  24. redfish_sdk/managers/update_strategies/inspur.py +89 -0
  25. redfish_sdk/managers/update_strategies/lenovo.py +59 -0
  26. redfish_sdk/managers/update_strategies/nettrix.py +56 -0
  27. redfish_sdk/managers/update_strategies/registry.py +72 -0
  28. redfish_sdk/managers/update_strategies/vendor_detect.py +111 -0
  29. redfish_sdk/managers/update_strategies/xfusion.py +60 -0
  30. redfish_sdk/managers/update_strategies/zte.py +68 -0
  31. redfish_sdk/models/__init__.py +55 -0
  32. redfish_sdk/models/account.py +60 -0
  33. redfish_sdk/models/chassis.py +86 -0
  34. redfish_sdk/models/check.py +231 -0
  35. redfish_sdk/models/common.py +102 -0
  36. redfish_sdk/models/drive.py +53 -0
  37. redfish_sdk/models/event.py +64 -0
  38. redfish_sdk/models/fru.py +59 -0
  39. redfish_sdk/models/gpu.py +33 -0
  40. redfish_sdk/models/logs.py +56 -0
  41. redfish_sdk/models/managers.py +153 -0
  42. redfish_sdk/models/memory.py +50 -0
  43. redfish_sdk/models/network_adapter.py +76 -0
  44. redfish_sdk/models/oem.py +173 -0
  45. redfish_sdk/models/pcie_device.py +89 -0
  46. redfish_sdk/models/power.py +92 -0
  47. redfish_sdk/models/processor.py +50 -0
  48. redfish_sdk/models/registry.py +34 -0
  49. redfish_sdk/models/resource_key.py +70 -0
  50. redfish_sdk/models/root.py +50 -0
  51. redfish_sdk/models/session.py +42 -0
  52. redfish_sdk/models/storage.py +77 -0
  53. redfish_sdk/models/systems.py +194 -0
  54. redfish_sdk/models/task.py +54 -0
  55. redfish_sdk/models/thermal.py +111 -0
  56. redfish_sdk/models/update.py +55 -0
redfish_sdk/client.py ADDED
@@ -0,0 +1,1894 @@
1
+ """
2
+ RedfishClient — the top-level entry point for the Redfish Python SDK.
3
+
4
+ Aggregates all service managers into a single object, providing a clean
5
+ and intuitive interface for interacting with a BMC via Redfish.
6
+
7
+ Usage:
8
+ import os
9
+ from redfish_sdk import RedfishClient
10
+
11
+ # Credentials are read from environment variables:
12
+ # BMC_IP, BMC_USERNAME, BMC_PASSWORD
13
+ client = RedfishClient(
14
+ host=os.environ["BMC_IP"],
15
+ username=os.environ["BMC_USERNAME"],
16
+ password=os.environ["BMC_PASSWORD"],
17
+ )
18
+
19
+ # Get system info
20
+ system = client.get_system()
21
+
22
+ # List CPUs
23
+ cpus = client.get_processors()
24
+
25
+ # Reset server
26
+ client.reset("GracefulRestart")
27
+
28
+ # Close when done
29
+ client.close()
30
+
31
+ # Or use as context manager
32
+ with RedfishClient(
33
+ host=os.environ["BMC_IP"],
34
+ username=os.environ["BMC_USERNAME"],
35
+ password=os.environ["BMC_PASSWORD"],
36
+ ) as client:
37
+ system = client.get_system()
38
+
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ from functools import cached_property
44
+ from typing import Any, Dict, List, Optional, Type, TypeVar
45
+
46
+ from .exceptions import RedfishException
47
+ from .http_client import RedfishHttpClient
48
+ from .models.account import Account, AccountService, Role
49
+ from .models.resource_key import RedfishResource
50
+ from .models.chassis import Chassis
51
+ from .models.drive import Drive
52
+ from .models.common import Collection, Entity, Link, RedfishResponse
53
+ from .models.event import EventService, Subscription
54
+ from .models.fru import Fru
55
+ from .models.logs import Log, LogEntry
56
+ from .models.oem import MainBoard
57
+ from .models.managers import EthernetInterface, HostInterface, Manager, NetworkProtocol
58
+ from .models.memory import Memory
59
+ from .models.power import Power, PowerSupply
60
+ from .models.processor import Processor
61
+ from .models.registry import Registry
62
+ from .models.root import RootService
63
+ from .models.session import Session, SessionService
64
+ from .models.systems import Bios, BootOption, System, SystemPatchSetting
65
+ from .models.task import Task, TaskService
66
+ from .models.thermal import Fan, InletHistoryTemperature, Thermal
67
+ from .models.update import ClientCertificate, FirmwareInventory, UpdateService
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+ T = TypeVar("T", bound=Entity)
72
+
73
+ # Names of all cached_property manager attributes, used by close()
74
+ _MANAGER_ATTRS = (
75
+ "_systems", "_chassis", "_managers", "_accounts",
76
+ "_sessions", "_events", "_updates", "_registries", "_tasks",
77
+ )
78
+
79
+
80
+ class RedfishClient:
81
+ """
82
+ Top-level Redfish Client.
83
+
84
+ Aggregates all service managers and provides the main entry point
85
+ for interacting with a BMC via the Redfish protocol.
86
+
87
+ All operations are accessible directly via ``client.get_xxx()`` /
88
+ ``client.xxx()`` style methods. The underlying service managers
89
+ are private implementation details and should not be accessed
90
+ directly.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ host: str,
96
+ username: str,
97
+ password: str,
98
+ verify_ssl: bool = False,
99
+ proxy: Optional[str] = None,
100
+ connect_timeout: int = 10,
101
+ read_timeout: int = 30,
102
+ scheme: str = "https",
103
+ ):
104
+ """
105
+ Initialize the Redfish Client.
106
+
107
+ Args:
108
+ host: BMC IP address or hostname (e.g., ``os.environ["BMC_IP"]``)
109
+ username: BMC username (e.g., ``os.environ["BMC_USERNAME"]``)
110
+ password: BMC password (e.g., ``os.environ["BMC_PASSWORD"]``)
111
+ verify_ssl: Whether to verify SSL certificates.
112
+ Default False — BMCs typically use self-signed certificates.
113
+ proxy: Optional HTTP/HTTPS proxy URL.
114
+ Example: "http://127.0.0.1:8080" (matches Java's proxy config)
115
+ connect_timeout: TCP connection timeout in seconds (default 10)
116
+ read_timeout: HTTP response read timeout in seconds (default 30)
117
+ scheme: URL scheme — "https" (default) or "http"
118
+ """
119
+ self._http_client = RedfishHttpClient(
120
+ host=host,
121
+ username=username,
122
+ password=password,
123
+ verify_ssl=verify_ssl,
124
+ proxy=proxy,
125
+ connect_timeout=connect_timeout,
126
+ read_timeout=read_timeout,
127
+ scheme=scheme,
128
+ )
129
+
130
+ # Root service cache
131
+ self._root_cache: Optional[RootService] = None
132
+
133
+ logger.info("RedfishClient initialized for host: %s", host)
134
+
135
+ # ------------------------------------------------------------------
136
+ # Private manager accessors (lazy-loaded via cached_property)
137
+ # ------------------------------------------------------------------
138
+
139
+ @cached_property
140
+ def _systems(self):
141
+ from .managers.systems import SystemsManager
142
+ return SystemsManager(self)
143
+
144
+ @cached_property
145
+ def _chassis(self):
146
+ from .managers.chassis import ChassisManager
147
+ return ChassisManager(self)
148
+
149
+ @cached_property
150
+ def _managers(self):
151
+ from .managers.managers import ManagersManager
152
+ return ManagersManager(self)
153
+
154
+ @cached_property
155
+ def _accounts(self):
156
+ from .managers.account import AccountServiceManager
157
+ return AccountServiceManager(self)
158
+
159
+ @cached_property
160
+ def _sessions(self):
161
+ from .managers.session import SessionServiceManager
162
+ return SessionServiceManager(self)
163
+
164
+ @cached_property
165
+ def _events(self):
166
+ from .managers.event import EventServiceManager
167
+ return EventServiceManager(self)
168
+
169
+ @cached_property
170
+ def _updates(self):
171
+ from .managers.update import UpdateServiceManager
172
+ return UpdateServiceManager(self)
173
+
174
+ @cached_property
175
+ def _registries(self):
176
+ from .managers.registries import RegistriesManager
177
+ return RegistriesManager(self)
178
+
179
+ @cached_property
180
+ def _tasks(self):
181
+ from .managers.task import TaskServiceManager
182
+ return TaskServiceManager(self)
183
+
184
+ # ------------------------------------------------------------------
185
+ # Lifecycle
186
+ # ------------------------------------------------------------------
187
+
188
+ @property
189
+ def host(self) -> str:
190
+ """Return the configured BMC host."""
191
+ return self._http_client.host
192
+
193
+ def root(self):
194
+ """
195
+ Get the Redfish root service document.
196
+ Returns the raw RootService resource with all top-level links.
197
+ """
198
+ return self._get_root()
199
+
200
+ def close(self) -> None:
201
+ """Close the underlying HTTP session and free resources."""
202
+ for attr in _MANAGER_ATTRS:
203
+ self.__dict__.pop(attr, None)
204
+ self._http_client.close()
205
+ logger.info("RedfishClient closed for host: %s", self._http_client.host)
206
+
207
+ def __enter__(self) -> RedfishClient:
208
+ return self
209
+
210
+ def __exit__(self, *args) -> None:
211
+ self.close()
212
+
213
+ def __repr__(self) -> str:
214
+ return f"RedfishClient(host={self._http_client.host!r})"
215
+
216
+ # ------------------------------------------------------------------
217
+ # Core methods (absorbed from RootServiceManager)
218
+ # ------------------------------------------------------------------
219
+
220
+ def _get_root(self) -> RootService:
221
+ """
222
+ Fetch the Redfish root service document.
223
+ Endpoint: GET /redfish/v1/
224
+ """
225
+ return self._http_client.get("/redfish/v1/", RootService)
226
+
227
+ def _get_collection(self, odata_id: str, model_class: Type[T]) -> List[T]:
228
+ """
229
+ Generic collection fetcher.
230
+
231
+ 1. Fetches the collection index (Members list with @odata.id links)
232
+ 2. Individually fetches each member to get full details
233
+ 3. Returns a list of fully populated model instances
234
+
235
+ Args:
236
+ odata_id: The @odata.id of the collection resource
237
+ model_class: The pydantic model class for collection members
238
+
239
+ Returns:
240
+ List of fully populated model instances
241
+ """
242
+ from pydantic import TypeAdapter
243
+
244
+ # Step 1: fetch collection index
245
+ collection_type = Collection[model_class]
246
+ try:
247
+ adapter = TypeAdapter(collection_type)
248
+ except Exception:
249
+ # Fallback: fetch raw and parse manually
250
+ raw = self._http_client.get_raw(odata_id)
251
+ members_raw = raw.get("Members", [])
252
+ members = []
253
+ for member_raw in members_raw:
254
+ link_id = member_raw.get("@odata.id")
255
+ if link_id:
256
+ try:
257
+ member = self._http_client.get(link_id, model_class)
258
+ members.append(member)
259
+ except RedfishException as exc:
260
+ logger.warning("Failed to fetch member %s: %s, skipping", link_id, exc)
261
+ return members
262
+
263
+ raw = self._http_client.get_raw(odata_id)
264
+ collection = adapter.validate_python(raw)
265
+
266
+ if not collection.members:
267
+ logger.warning("Collection %s has no members", odata_id)
268
+ return []
269
+
270
+ # Step 2: expand each member
271
+ expanded_members = []
272
+ for member in collection.members:
273
+ if not member.odata_id:
274
+ continue
275
+ try:
276
+ full_member = self._http_client.get(member.odata_id, model_class)
277
+ expanded_members.append(full_member)
278
+ except RedfishException as exc:
279
+ logger.warning(
280
+ "Failed to fetch collection member %s: %s, skipping",
281
+ member.odata_id, exc
282
+ )
283
+
284
+ return expanded_members
285
+
286
+ def _get_list(self, links: List[Link], model_class: Type[T]) -> List[T]:
287
+ """
288
+ Fetch a list of resources from a list of Link objects.
289
+ Skips items that fail to fetch (logs warning and continues).
290
+ """
291
+ results = []
292
+ for link in links:
293
+ if not link.odata_id:
294
+ continue
295
+ try:
296
+ item = self._http_client.get(link.odata_id, model_class)
297
+ if item is not None:
298
+ results.append(item)
299
+ except RedfishException as exc:
300
+ logger.warning("Failed to fetch %s: %s, skipping", link.odata_id, exc)
301
+ return results
302
+
303
+ # ------------------------------------------------------------------
304
+ # Cached service accessors (absorbed from RootServiceManager)
305
+ # ------------------------------------------------------------------
306
+
307
+ def _get_session_service(self) -> SessionService:
308
+ """Get the SessionService resource."""
309
+ root = self._get_root()
310
+ return self._http_client.get(root.session_service.odata_id, SessionService)
311
+
312
+ def _get_account_service(self) -> AccountService:
313
+ """Get the AccountService resource."""
314
+ root = self._get_root()
315
+ return self._http_client.get(root.account_service.odata_id, AccountService)
316
+
317
+ def _get_chassis_collection(self) -> List[Chassis]:
318
+ """
319
+ Get the Chassis collection.
320
+
321
+ Special handling for Lenovo servers: if the collection endpoint returns
322
+ an error, falls back to fetching /redfish/v1/Chassis/1 directly.
323
+ """
324
+ root = self._get_root()
325
+ try:
326
+ return self._get_collection(root.chassis.odata_id, Chassis)
327
+ except RedfishException as exc:
328
+ logger.warning(
329
+ "Chassis collection endpoint failed (%s), falling back to /redfish/v1/Chassis/1",
330
+ exc
331
+ )
332
+ # Lenovo workaround: /redfish/v1/Chassis returns 500
333
+ chassis = self._http_client.get("/redfish/v1/Chassis/1", Chassis)
334
+ return [chassis]
335
+
336
+ def _get_chassis_collection_odata_id(self) -> str:
337
+ """Get the Chassis collection @odata.id from the root service."""
338
+ root = self._get_root()
339
+ return root.chassis.odata_id or "/redfish/v1/Chassis"
340
+
341
+ def _get_event_service(self) -> EventService:
342
+ """Get the EventService resource."""
343
+ root = self._get_root()
344
+ return self._http_client.get(root.event_service.odata_id, EventService)
345
+
346
+ def _get_managers_collection(self) -> List[Manager]:
347
+ """Get the Managers collection."""
348
+ root = self._get_root()
349
+ return self._get_collection(root.managers.odata_id, Manager)
350
+
351
+ def _get_managers_collection_odata_id(self) -> str:
352
+ """Get the Managers collection @odata.id from the root service."""
353
+ root = self._get_root()
354
+ return root.managers.odata_id
355
+
356
+ def _get_registries_collection(self) -> List[Registry]:
357
+ """Get the Registries collection."""
358
+ root = self._get_root()
359
+ return self._get_collection(root.registries.odata_id, Registry)
360
+
361
+ def _get_registries_collection_odata_id(self) -> str:
362
+ """Get the Registries collection @odata.id from the root service."""
363
+ root = self._get_root()
364
+ return root.registries.odata_id
365
+
366
+ def _get_systems_collection(self) -> List[System]:
367
+ """Get the Systems collection."""
368
+ root = self._get_root()
369
+ return self._get_collection(root.systems.odata_id, System)
370
+
371
+ def _get_systems_collection_odata_id(self) -> str:
372
+ """Get the Systems collection @odata.id from the root service."""
373
+ root = self._get_root()
374
+ return root.systems.odata_id
375
+
376
+ def _get_task_service(self) -> TaskService:
377
+ """Get the TaskService resource."""
378
+ root = self._get_root()
379
+ return self._http_client.get(root.tasks.odata_id, TaskService)
380
+
381
+ def _get_update_service(self) -> UpdateService:
382
+ """Get the UpdateService resource."""
383
+ root = self._get_root()
384
+ return self._http_client.get(root.update_service.odata_id, UpdateService)
385
+
386
+ # ==================================================================
387
+ # Component query methods — Systems side
388
+ # ==================================================================
389
+
390
+ def get_system(self, system_id: Optional[str] = None) -> System:
391
+ """
392
+ Get a single system (physical server) resource.
393
+
394
+ If system_id is None and there is exactly one system, returns it
395
+ automatically. If there are multiple systems and no ID is specified,
396
+ raises an exception.
397
+
398
+ Args:
399
+ system_id: System ID (e.g., "1"). Auto-selected if only one system exists.
400
+
401
+ Returns:
402
+ System object with manufacturer, model, serial_number, power_state, etc.
403
+
404
+ Raises:
405
+ RedfishException: If system not found or multiple systems exist without ID
406
+ """
407
+ return self._systems.get(system_id)
408
+
409
+ def get_systems(self) -> List[System]:
410
+ """
411
+ Get all system (physical server) resources.
412
+
413
+ Returns:
414
+ List of System objects
415
+ """
416
+ return self._get_systems_collection()
417
+
418
+ def get_processors(self, system_id: Optional[str] = None) -> List[Processor]:
419
+ """
420
+ Get the list of processors (CPUs) for a system.
421
+
422
+ Args:
423
+ system_id: System ID. Auto-selected if only one system exists.
424
+
425
+ Returns:
426
+ List of Processor objects
427
+ """
428
+ return self._systems.processors(system_id)
429
+
430
+ def get_processor(self, processor_id: str, system_id: Optional[str] = None) -> Processor:
431
+ """
432
+ Get a single processor (CPU) by ID.
433
+
434
+ Args:
435
+ processor_id: Processor ID (e.g., "1")
436
+ system_id: System ID. Auto-selected if only one system exists.
437
+
438
+ Returns:
439
+ Processor resource
440
+ """
441
+ system = self._systems.get(system_id)
442
+ return self._http_client.get(
443
+ f"{system.processors.odata_id}/{processor_id}", Processor
444
+ )
445
+
446
+ def get_memory(self, system_id: Optional[str] = None) -> List[Memory]:
447
+ """
448
+ Get the list of memory modules (DIMMs) for a system.
449
+
450
+ Args:
451
+ system_id: System ID. Auto-selected if only one system exists.
452
+
453
+ Returns:
454
+ List of Memory objects
455
+ """
456
+ return self._systems.memory(system_id)
457
+
458
+ def get_memory_device(self, memory_id: str, system_id: Optional[str] = None) -> Memory:
459
+ """
460
+ Get a single memory module (DIMM) by ID.
461
+
462
+ Args:
463
+ memory_id: Memory ID (e.g., "1")
464
+ system_id: System ID. Auto-selected if only one system exists.
465
+
466
+ Returns:
467
+ Memory resource
468
+ """
469
+ system = self._systems.get(system_id)
470
+ return self._http_client.get(
471
+ f"{system.odata_id}/Memory/{memory_id}", Memory
472
+ )
473
+
474
+ def get_storages(self, system_id: Optional[str] = None) -> List:
475
+ """
476
+ Get the list of storage controllers for a system.
477
+
478
+ Args:
479
+ system_id: System ID. Auto-selected if only one system exists.
480
+
481
+ Returns:
482
+ List of Storage objects
483
+ """
484
+ return self._systems.storages(system_id)
485
+
486
+ def get_volumes(self, storage_id: str, system_id: Optional[str] = None) -> List:
487
+ """
488
+ Get the list of volumes for a given storage controller.
489
+
490
+ Args:
491
+ storage_id: Storage controller ID
492
+ system_id: System ID. Auto-selected if only one system exists.
493
+
494
+ Returns:
495
+ List of Volume objects
496
+ """
497
+ return self._systems.volumes(storage_id, system_id)
498
+
499
+ def get_gpus(self, system_id: Optional[str] = None) -> List:
500
+ """
501
+ Get GPU information for a system.
502
+
503
+ Uses multi-vendor fallback strategy:
504
+ 1. Try GraphicsControllers (standard path)
505
+ 2. Fall back to Chassis PCIeDevices filtered by GPU name
506
+ 3. Fall back to System.Links.PCIeDevices
507
+
508
+ Args:
509
+ system_id: System ID. Auto-selected if only one system exists.
510
+
511
+ Returns:
512
+ List of Gpu objects
513
+ """
514
+ return self._systems.gpus(system_id)
515
+
516
+ def get_bios(self, system_id: Optional[str] = None) -> Bios:
517
+ """
518
+ Get BIOS information for a system.
519
+
520
+ Args:
521
+ system_id: System ID. Auto-selected if only one system exists.
522
+
523
+ Returns:
524
+ Bios resource
525
+ """
526
+ return self._systems.bios(system_id)
527
+
528
+ def get_system_log_services(self, system_id: Optional[str] = None) -> List[Log]:
529
+ """
530
+ Get the list of log services for a system.
531
+
532
+ Args:
533
+ system_id: System ID. Auto-selected if only one system exists.
534
+
535
+ Returns:
536
+ List of Log objects
537
+ """
538
+ return self._systems.log_services(system_id)
539
+
540
+ def get_system_log_entries(
541
+ self,
542
+ log_id: Optional[str] = None,
543
+ system_id: Optional[str] = None,
544
+ ) -> List[LogEntry]:
545
+ """
546
+ Get log entries for a system log service.
547
+
548
+ Args:
549
+ log_id: Log service ID (e.g., "Log1", "Sel"). Optional
550
+ — when omitted and there is exactly one log service,
551
+ it is auto-selected.
552
+ system_id: System ID. Auto-selected if only one system exists.
553
+
554
+ Returns:
555
+ List of LogEntry objects (uses ``?$expand=.($levels=1)`` to
556
+ inline members in one HTTP round trip when supported).
557
+ """
558
+ return self._systems.log_entries(log_id, system_id)
559
+
560
+ # ------------------------------------------------------------------
561
+ # Log service single resource + ClearLog action
562
+ # ------------------------------------------------------------------
563
+
564
+ def get_system_log_service(
565
+ self,
566
+ log_id: Optional[str] = None,
567
+ system_id: Optional[str] = None,
568
+ ) -> Log:
569
+ """
570
+ Get a single LogService resource (includes Actions block such as
571
+ ``#LogService.ClearLog``).
572
+
573
+ Differs from :meth:`get_system_log_services` which returns the
574
+ collection members and may omit Actions on some BMCs.
575
+
576
+ ``log_id`` is optional (auto-selected when there is exactly one
577
+ log service) and the per-service URL is discovered from the
578
+ LogServices collection rather than built by string concatenation.
579
+ """
580
+ return self._systems.log_service(log_id, system_id)
581
+
582
+ def clear_system_log(
583
+ self,
584
+ log_id: Optional[str] = None,
585
+ system_id: Optional[str] = None,
586
+ ) -> None:
587
+ """
588
+ Invoke ``#LogService.ClearLog`` on a system log service.
589
+
590
+ Raises :class:`RedfishValidationError` if the BMC does not advertise
591
+ the ClearLog action.
592
+ """
593
+ return self._systems.clear_system_log(log_id, system_id)
594
+
595
+ # ------------------------------------------------------------------
596
+ # BootOptions collection
597
+ # ------------------------------------------------------------------
598
+
599
+ def get_boot_options(self, system_id: Optional[str] = None) -> List[BootOption]:
600
+ """
601
+ Get the BootOptions collection for a system (modern boot model).
602
+
603
+ Returns an empty list when the system does not expose a BootOptions
604
+ link (legacy ``BootSourceOverrideTarget`` only).
605
+ """
606
+ return self._systems.boot_options(system_id)
607
+
608
+ def get_boot_option(self, option_id: str, system_id: Optional[str] = None) -> BootOption:
609
+ """Get a single BootOption resource by ID."""
610
+ return self._systems.boot_option(option_id, system_id)
611
+
612
+ def set_boot_option_enabled(
613
+ self,
614
+ option_id: str,
615
+ enabled: bool,
616
+ system_id: Optional[str] = None,
617
+ ) -> BootOption:
618
+ """
619
+ Toggle a BootOption's ``BootOptionEnabled`` flag via PATCH and
620
+ return the re-read resource.
621
+ """
622
+ return self._systems.set_boot_option_enabled(option_id, enabled, system_id)
623
+
624
+ def get_system_fru(self, system_id: Optional[str] = None) -> Optional[Fru]:
625
+ """
626
+ Get FRU (Field Replaceable Unit) information for a system.
627
+
628
+ This is a vendor-specific extension. Returns None if not available.
629
+
630
+ Args:
631
+ system_id: System ID. Auto-selected if only one system exists.
632
+
633
+ Returns:
634
+ Fru object, or None if not available
635
+ """
636
+ return self._systems.fru_info(system_id)
637
+
638
+ def get_pcie_device(self, odata_id: str):
639
+ """
640
+ Get a specific PCIe device by its @odata.id.
641
+
642
+ Args:
643
+ odata_id: The @odata.id of the PCIe device
644
+
645
+ Returns:
646
+ PCIeDevice resource
647
+ """
648
+ return self._systems.pcie_device(odata_id)
649
+
650
+ def change_boot_source(
651
+ self,
652
+ target: str,
653
+ system_id: Optional[str] = None,
654
+ mode: str = "UEFI",
655
+ enabled: str = "Once",
656
+ ) -> SystemPatchSetting:
657
+ """
658
+ Change the boot source override target.
659
+
660
+ Validates the target against the system's allowable values,
661
+ then sends a PATCH request with the new boot settings.
662
+
663
+ Args:
664
+ target: Boot target (e.g., "Pxe", "Hdd", "Cd", "BiosSetup")
665
+ system_id: System ID. Auto-selected if only one system exists.
666
+ mode: Boot source override mode ("UEFI" or "Legacy")
667
+ enabled: Override enable mode ("Once", "Continuous", "Disabled")
668
+
669
+ Returns:
670
+ Updated SystemPatchSetting
671
+
672
+ Raises:
673
+ RedfishValidationError: If target is not in allowable values
674
+ """
675
+ return self._systems.change_boot_source(target, system_id, mode, enabled)
676
+
677
+ def reset(
678
+ self,
679
+ reset_type: str,
680
+ system_id: Optional[str] = None,
681
+ skip_power_state_check: bool = False,
682
+ ):
683
+ """
684
+ Perform a system reset (power on/off/restart).
685
+
686
+ Validates that the requested reset type is compatible with the current
687
+ power state before sending the request.
688
+
689
+ Args:
690
+ reset_type: Reset type string (e.g., "GracefulRestart", "ForceOff", "On")
691
+ system_id: System ID. Auto-selected if only one system exists.
692
+ skip_power_state_check: Skip power state compatibility check
693
+
694
+ Returns:
695
+ RedfishResponse
696
+
697
+ Raises:
698
+ RedfishValidationError: If reset_type is incompatible with current power state
699
+ """
700
+ return self._systems.reset(reset_type, system_id, skip_power_state_check)
701
+
702
+ # ==================================================================
703
+ # Component query methods — Chassis side
704
+ # ==================================================================
705
+
706
+ def get_chassis(self, chassis_id: str = "1") -> Chassis:
707
+ """
708
+ Get chassis (physical enclosure) information.
709
+
710
+ Args:
711
+ chassis_id: Chassis ID (default "1")
712
+
713
+ Returns:
714
+ Chassis resource with manufacturer, model, serial number, etc.
715
+ """
716
+ return self._chassis.get(chassis_id)
717
+
718
+ def get_drives(self, chassis_id: str = "1") -> List:
719
+ """
720
+ Get the list of physical drives (HDD/SSD/NVMe) in a chassis.
721
+
722
+ Args:
723
+ chassis_id: Chassis ID (default "1")
724
+
725
+ Returns:
726
+ List of Drive objects
727
+ """
728
+ return self._chassis.drives(chassis_id)
729
+
730
+ # ------------------------------------------------------------------
731
+ # Drive single-resource + Drive.Reset action
732
+ # ------------------------------------------------------------------
733
+
734
+ def get_drive(self, odata_id: str) -> Drive:
735
+ """
736
+ Get a single Drive resource directly by its ``@odata.id``.
737
+
738
+ Useful when a Storage controller only exposes Link references and the
739
+ caller needs the full Drive detail.
740
+ """
741
+ return self._systems.drive_by_odata_id(odata_id)
742
+
743
+ def drive_reset(self, drive_odata_id: str, reset_type: str) -> None:
744
+ """
745
+ Invoke ``#Drive.Reset`` on a drive (NVMe power cycle, etc.).
746
+
747
+ See :meth:`SystemsManager.drive_reset` for behaviour.
748
+ """
749
+ return self._systems.drive_reset(drive_odata_id, reset_type)
750
+
751
+ # ------------------------------------------------------------------
752
+ # IndicatorLED writes
753
+ # ------------------------------------------------------------------
754
+
755
+ def set_indicator_led(self, state: str, chassis_id: str = "1") -> str:
756
+ """
757
+ Set ``Chassis.IndicatorLED``. ``state`` must be one of
758
+ ``Lit`` / ``Blinking`` / ``Off``.
759
+ """
760
+ return self._chassis.set_indicator_led(state, chassis_id)
761
+
762
+ def set_drive_indicator_led(self, drive_odata_id: str, state: str) -> str:
763
+ """
764
+ Set ``Drive.IndicatorLED`` on a specific drive. ``state`` must be one
765
+ of ``Lit`` / ``Blinking`` / ``Off``.
766
+ """
767
+ return self._chassis.set_drive_indicator_led(drive_odata_id, state)
768
+
769
+ def get_network_adapters(self, chassis_id: str = "1") -> List:
770
+ """
771
+ Get the list of network adapters (NICs) in a chassis.
772
+
773
+ Args:
774
+ chassis_id: Chassis ID (default "1")
775
+
776
+ Returns:
777
+ List of NetworkAdapter objects
778
+ """
779
+ return self._chassis.network_adapters(chassis_id)
780
+
781
+ def get_pcie_devices(self, chassis_id: str = "1") -> List:
782
+ """
783
+ Get the list of PCIe devices in a chassis.
784
+
785
+ Args:
786
+ chassis_id: Chassis ID (default "1")
787
+
788
+ Returns:
789
+ List of PCIeDevice objects
790
+ """
791
+ return self._chassis.pcie_devices(chassis_id)
792
+
793
+ def get_power(self, chassis_id: str = "1") -> Power:
794
+ """
795
+ Get power information (PSUs, power controls, voltages) for a chassis.
796
+
797
+ Args:
798
+ chassis_id: Chassis ID (default "1")
799
+
800
+ Returns:
801
+ Power resource
802
+ """
803
+ return self._chassis.power(chassis_id)
804
+
805
+ def get_thermal(self, chassis_id: str = "1") -> Thermal:
806
+ """
807
+ Get thermal information (fans, temperatures) for a chassis.
808
+
809
+ Args:
810
+ chassis_id: Chassis ID (default "1")
811
+
812
+ Returns:
813
+ Thermal resource
814
+ """
815
+ return self._chassis.thermal(chassis_id)
816
+
817
+ def get_inlet_history_temperature(
818
+ self, chassis_id: str = "1"
819
+ ) -> Optional[InletHistoryTemperature]:
820
+ """
821
+ Get air inlet historical temperature samples for a chassis.
822
+
823
+ Wraps ``ChassisManager.inlet_history_temperature``. The method first
824
+ resolves the sub-resource URL via ``get_thermal()`` (Redfish link
825
+ discovery), then GETs the InletHistoryTemperature resource.
826
+
827
+ Returns ``None`` when the BMC does not advertise the sub-resource or
828
+ returns 404; other errors (auth/network/parse) propagate.
829
+
830
+ Args:
831
+ chassis_id: Chassis ID (default "1")
832
+
833
+ Returns:
834
+ InletHistoryTemperature model, or None when not supported.
835
+ """
836
+ return self._chassis.inlet_history_temperature(chassis_id)
837
+
838
+ def get_fru_service(self, chassis_id: str = "1") -> List[dict]:
839
+ """
840
+ Get FRU service data from the chassis OEM extension.
841
+
842
+ This is a vendor-specific feature (e.g., Huawei/xFusion iBMC).
843
+
844
+ Args:
845
+ chassis_id: Chassis ID (default "1")
846
+
847
+ Returns:
848
+ List of raw FRU service data dicts
849
+ """
850
+ return self._chassis.fru_service(chassis_id)
851
+
852
+ # ==================================================================
853
+ # Component query methods — Managers (BMC) side
854
+ # ==================================================================
855
+
856
+ def get_manager(self, manager_id: str = "1") -> Manager:
857
+ """
858
+ Get BMC manager information.
859
+
860
+ Args:
861
+ manager_id: Manager ID (default "1")
862
+
863
+ Returns:
864
+ Manager resource with firmware_version, model, etc.
865
+ """
866
+ return self._managers.get(manager_id)
867
+
868
+ def get_manager_log_services(self, manager_id: str = "1") -> List[Log]:
869
+ """
870
+ Get the list of log services for a BMC manager.
871
+
872
+ Args:
873
+ manager_id: Manager ID (default "1")
874
+
875
+ Returns:
876
+ List of Log objects
877
+ """
878
+ return self._managers.log_services(manager_id)
879
+
880
+ def get_manager_log_entries(
881
+ self,
882
+ log_id: Optional[str] = None,
883
+ manager_id: str = "1",
884
+ ) -> List[LogEntry]:
885
+ """
886
+ Get log entries for a BMC manager log service.
887
+
888
+ Args:
889
+ log_id: Log service ID (e.g., "Sel", "OperateLog"). Optional
890
+ — when omitted and there is exactly one log
891
+ service, it is auto-selected.
892
+ manager_id: Manager ID (default "1")
893
+
894
+ Returns:
895
+ List of LogEntry objects (uses ``?$expand=.($levels=1)`` to
896
+ inline members in one HTTP round trip when supported).
897
+ """
898
+ return self._managers.log_entries(log_id, manager_id)
899
+
900
+ def get_network_protocol(self, manager_id: str = "1") -> NetworkProtocol:
901
+ """
902
+ Get network protocol configuration for a BMC manager.
903
+
904
+ Args:
905
+ manager_id: Manager ID (default "1")
906
+
907
+ Returns:
908
+ NetworkProtocol resource
909
+ """
910
+ return self._managers.network_protocol(manager_id)
911
+
912
+ def get_manager_ethernet_interfaces(self, manager_id: str = "1") -> List[EthernetInterface]:
913
+ """
914
+ Get the list of Ethernet interfaces for a BMC manager.
915
+
916
+ Args:
917
+ manager_id: Manager ID (default "1")
918
+
919
+ Returns:
920
+ List of EthernetInterface objects
921
+ """
922
+ return self._managers.ethernet_interfaces(manager_id)
923
+
924
+ def get_host_interfaces(self, manager_id: str = "1") -> List[HostInterface]:
925
+ """
926
+ Get the list of host interfaces for a BMC manager.
927
+
928
+ Args:
929
+ manager_id: Manager ID (default "1")
930
+
931
+ Returns:
932
+ List of HostInterface objects
933
+ """
934
+ return self._managers.host_interfaces(manager_id)
935
+
936
+ # ==================================================================
937
+ # Component query methods — Account service
938
+ # ==================================================================
939
+
940
+ def get_accounts(self) -> List[Account]:
941
+ """
942
+ Get all user accounts.
943
+
944
+ Returns:
945
+ List of Account objects
946
+ """
947
+ return self._accounts.accounts()
948
+
949
+ def get_roles(self) -> List[Role]:
950
+ """
951
+ Get all user roles.
952
+
953
+ Returns:
954
+ List of Role objects
955
+ """
956
+ return self._accounts.roles()
957
+
958
+ def add_account(self, account: Account) -> Account:
959
+ """
960
+ Create a new user account.
961
+
962
+ Args:
963
+ account: Account model with UserName, Password, RoleId, Enabled fields
964
+
965
+ Returns:
966
+ Created Account resource
967
+ """
968
+ return self._accounts.add(account)
969
+
970
+ def update_account(self, username: str, account: Account) -> Account:
971
+ """
972
+ Update an existing user account.
973
+
974
+ Args:
975
+ username: Username of the account to update
976
+ account: Account model with fields to update
977
+
978
+ Returns:
979
+ Updated Account resource
980
+ """
981
+ return self._accounts.update(username, account)
982
+
983
+ def delete_account(self, username: str) -> str:
984
+ """
985
+ Delete a user account.
986
+
987
+ Args:
988
+ username: Username of the account to delete
989
+
990
+ Returns:
991
+ Response body (usually empty)
992
+ """
993
+ return self._accounts.delete(username)
994
+
995
+ # ==================================================================
996
+ # Component query methods — Session service
997
+ # ==================================================================
998
+
999
+ def get_sessions(self) -> List[Session]:
1000
+ """
1001
+ Get all active sessions.
1002
+
1003
+ Returns:
1004
+ List of Session objects
1005
+ """
1006
+ return self._sessions.sessions()
1007
+
1008
+ def get_session(self, session_id: str) -> Session:
1009
+ """
1010
+ Get a specific session by ID.
1011
+
1012
+ Args:
1013
+ session_id: Session ID
1014
+
1015
+ Returns:
1016
+ Session resource
1017
+ """
1018
+ return self._sessions.get(session_id)
1019
+
1020
+ def create_session(
1021
+ self,
1022
+ username: str,
1023
+ password: str,
1024
+ switch_to_token_auth: bool = False,
1025
+ ) -> Session:
1026
+ """
1027
+ Create a new session (login to the BMC).
1028
+
1029
+ After creation, the X-Auth-Token is returned in the response header.
1030
+ If switch_to_token_auth=True, the SDK client will use this token
1031
+ for subsequent requests instead of Basic Auth.
1032
+
1033
+ Args:
1034
+ username: BMC username
1035
+ password: BMC password
1036
+ switch_to_token_auth: If True, switch client to token-based auth
1037
+
1038
+ Returns:
1039
+ Session resource with x_auth_token populated
1040
+ """
1041
+ return self._sessions.create(username, password, switch_to_token_auth)
1042
+
1043
+ def delete_session(self, session_id: str) -> str:
1044
+ """
1045
+ Delete a session (logout).
1046
+
1047
+ Args:
1048
+ session_id: Session ID to delete
1049
+
1050
+ Returns:
1051
+ Response body (usually empty)
1052
+ """
1053
+ return self._sessions.delete(session_id)
1054
+
1055
+ # ==================================================================
1056
+ # Component query methods — Event service
1057
+ # ==================================================================
1058
+
1059
+ def get_subscriptions(self) -> List[Subscription]:
1060
+ """
1061
+ Get all event subscriptions (collection-expanded).
1062
+
1063
+ Internally lists the ``Subscriptions`` collection and fetches each
1064
+ member by its ``@odata.id``; members that fail to fetch are skipped
1065
+ with a warning.
1066
+
1067
+ Returns:
1068
+ List of Subscription objects
1069
+ """
1070
+ return self._events.subscriptions()
1071
+
1072
+ def get_subscription(self, id_or_uri: str) -> Subscription:
1073
+ """
1074
+ Get a single event subscription by Id or full ``@odata.id``.
1075
+
1076
+ Args:
1077
+ id_or_uri: Either a bare subscription Id (e.g. ``"1"``) or the
1078
+ full ``@odata.id`` path
1079
+ (e.g. ``"/redfish/v1/EventService/Subscriptions/1"``).
1080
+
1081
+ Returns:
1082
+ The Subscription resource.
1083
+ """
1084
+ return self._events.get_subscription(id_or_uri)
1085
+
1086
+ def subscribe(
1087
+ self,
1088
+ destination: str,
1089
+ event_types: Optional[List[str]] = None,
1090
+ context: Optional[str] = None,
1091
+ *,
1092
+ protocol: str = "Redfish",
1093
+ http_headers: Optional[Any] = None,
1094
+ origin_resources: Optional[List[Dict[str, Any]]] = None,
1095
+ subscription_type: Optional[str] = None,
1096
+ registry_prefixes: Optional[List[str]] = None,
1097
+ resource_types: Optional[List[str]] = None,
1098
+ message_ids: Optional[List[str]] = None,
1099
+ delivery_retry_policy: Optional[str] = None,
1100
+ event_format_type: Optional[str] = None,
1101
+ severities: Optional[List[str]] = None,
1102
+ oem_subscription_type: Optional[str] = None,
1103
+ extra: Optional[Dict[str, Any]] = None,
1104
+ raw_body: Optional[Dict[str, Any]] = None,
1105
+ ) -> Subscription:
1106
+ """
1107
+ Create a new event subscription (webhook).
1108
+
1109
+ Every Redfish ``EventDestination`` field observed in the wild is
1110
+ exposed as a keyword-only argument, and ``extra`` / ``raw_body``
1111
+ provide an escape hatch for OEM-specific payloads. The SDK does
1112
+ **not** apply any vendor-default values — callers wishing to support
1113
+ multiple BMC vendors may try several payload shapes in sequence
1114
+ (catching :class:`RedfishException` between attempts).
1115
+
1116
+ Args:
1117
+ destination: URL to receive events (e.g. ``"https://my-server/events"``).
1118
+ event_types: Optional list of event types (e.g. ``["Alert"]``).
1119
+ context: Optional context string identifying the subscription.
1120
+ protocol: Wire protocol; defaults to ``"Redfish"``.
1121
+ http_headers: Optional headers to send on the callback POST.
1122
+ Pass either a ``dict`` or a ``list[dict]`` — both forms are
1123
+ commonly seen across BMC vendors.
1124
+ origin_resources: Optional list of ``{"@odata.id": "..."}``.
1125
+ subscription_type: Optional ``SubscriptionType`` value.
1126
+ registry_prefixes: Optional message-registry filter list.
1127
+ resource_types: Optional list of resource-type filters.
1128
+ message_ids: Optional list of message Ids to filter on.
1129
+ delivery_retry_policy: Optional retry policy.
1130
+ event_format_type: Optional event format type.
1131
+ severities: Optional severity filter list.
1132
+ oem_subscription_type: Optional vendor-specific subscription type.
1133
+ extra: Optional dict shallow-merged into the request body.
1134
+ raw_body: If provided, replaces the entire auto-generated body.
1135
+
1136
+ Returns:
1137
+ The created Subscription resource (as echoed by the BMC).
1138
+ """
1139
+ return self._events.subscribe(
1140
+ destination,
1141
+ event_types,
1142
+ context,
1143
+ protocol=protocol,
1144
+ http_headers=http_headers,
1145
+ origin_resources=origin_resources,
1146
+ subscription_type=subscription_type,
1147
+ registry_prefixes=registry_prefixes,
1148
+ resource_types=resource_types,
1149
+ message_ids=message_ids,
1150
+ delivery_retry_policy=delivery_retry_policy,
1151
+ event_format_type=event_format_type,
1152
+ severities=severities,
1153
+ oem_subscription_type=oem_subscription_type,
1154
+ extra=extra,
1155
+ raw_body=raw_body,
1156
+ )
1157
+
1158
+ def delete_subscription(self, id_or_uri: str) -> str:
1159
+ """
1160
+ Delete an event subscription.
1161
+
1162
+ Args:
1163
+ id_or_uri: Either a bare subscription Id (e.g. ``"1"``) or the
1164
+ full ``@odata.id`` path
1165
+ (e.g. ``"/redfish/v1/EventService/Subscriptions/1"``).
1166
+
1167
+ Returns:
1168
+ Raw response body (typically empty on 204).
1169
+ """
1170
+ return self._events.delete(id_or_uri)
1171
+
1172
+ # ------------------------------------------------------------------
1173
+ # Event service — extra accessors
1174
+ # ------------------------------------------------------------------
1175
+
1176
+ def get_event_service(self) -> EventService:
1177
+ """
1178
+ Get the full EventService resource (includes Actions block).
1179
+
1180
+ Use this when you need ``event_service.actions`` (e.g. to discover
1181
+ the SubmitTestEvent target or its AllowableValues).
1182
+ """
1183
+ return self._events.service()
1184
+
1185
+ def submit_test_event(
1186
+ self,
1187
+ event_type: str,
1188
+ message: Optional[str] = None,
1189
+ message_id: Optional[str] = None,
1190
+ severity: Optional[str] = None,
1191
+ message_args: Optional[List[str]] = None,
1192
+ ) -> None:
1193
+ """
1194
+ Invoke ``#EventService.SubmitTestEvent`` on the BMC.
1195
+
1196
+ See :meth:`EventServiceManager.submit_test_event` for details.
1197
+ """
1198
+ return self._events.submit_test_event(
1199
+ event_type, message, message_id, severity, message_args
1200
+ )
1201
+
1202
+ # ==================================================================
1203
+ # Component query methods — Update service
1204
+ # ==================================================================
1205
+
1206
+ def get_firmware_inventory(self) -> List[FirmwareInventory]:
1207
+ """
1208
+ Get the list of firmware inventory entries.
1209
+
1210
+ Returns all firmware/software component versions installed on the system
1211
+ (BIOS, BMC, CPLD, NIC firmware, etc.).
1212
+
1213
+ Returns:
1214
+ List of FirmwareInventory objects
1215
+ """
1216
+ return self._updates.firmware_inventory()
1217
+
1218
+ def get_client_certificates(self) -> List[ClientCertificate]:
1219
+ """
1220
+ Get the list of client certificates for firmware update authentication.
1221
+
1222
+ Returns:
1223
+ List of ClientCertificate objects
1224
+ """
1225
+ return self._updates.client_certificates()
1226
+
1227
+ def simple_update(
1228
+ self,
1229
+ image_uri: str,
1230
+ transfer_protocol: str = "HTTP",
1231
+ targets: Optional[list] = None,
1232
+ vendor: Optional[str] = None,
1233
+ **kwargs,
1234
+ ) -> RedfishResponse:
1235
+ """
1236
+ Trigger a firmware update via a remote image URI (e.g., NFS, HTTP).
1237
+
1238
+ Automatically detects the server vendor and uses the appropriate
1239
+ request body format. The vendor can be manually overridden.
1240
+
1241
+ Args:
1242
+ image_uri: URI of the firmware image (e.g., "http://nas/fw/bmc.bin")
1243
+ transfer_protocol: Transfer protocol (e.g., "HTTP", "NFS", "TFTP")
1244
+ targets: Optional list of firmware target paths
1245
+ vendor: Optional vendor override (e.g., "inspur", "lenovo").
1246
+ If not set, the vendor is auto-detected.
1247
+ **kwargs: Vendor-specific parameters (e.g., preserve_config,
1248
+ username, password, flash_item, etc.)
1249
+
1250
+ Returns:
1251
+ RedfishResponse (may contain a task reference for async update)
1252
+ """
1253
+ return self._updates.simple_update(
1254
+ image_uri, transfer_protocol, targets, vendor, **kwargs
1255
+ )
1256
+
1257
+ # ==================================================================
1258
+ # Component query methods — Registries
1259
+ # ==================================================================
1260
+
1261
+ def get_registries(self) -> List[Registry]:
1262
+ """
1263
+ Get the list of message registries.
1264
+
1265
+ Returns:
1266
+ List of Registry objects
1267
+ """
1268
+ return self._registries.registries()
1269
+
1270
+ def get_registry(self, registry_id: str) -> Registry:
1271
+ """
1272
+ Get a specific message registry by ID.
1273
+
1274
+ Args:
1275
+ registry_id: Registry ID (e.g., "Base.1.15.0")
1276
+
1277
+ Returns:
1278
+ Registry resource
1279
+ """
1280
+ return self._registries.get(registry_id)
1281
+
1282
+ # ==================================================================
1283
+ # Component query methods — Tasks
1284
+ # ==================================================================
1285
+
1286
+ def get_tasks(self) -> List[Task]:
1287
+ """
1288
+ Get the list of all tasks.
1289
+
1290
+ Returns:
1291
+ List of Task objects
1292
+ """
1293
+ return self._tasks.tasks()
1294
+
1295
+ def get_task(self, task_id: str) -> Task:
1296
+ """
1297
+ Get a specific task by ID.
1298
+
1299
+ Args:
1300
+ task_id: Task ID
1301
+
1302
+ Returns:
1303
+ Task resource
1304
+ """
1305
+ return self._tasks.get(task_id)
1306
+
1307
+ def wait_for_task(
1308
+ self,
1309
+ task_id: str,
1310
+ poll_interval: int = 5,
1311
+ timeout: int = 600,
1312
+ ) -> Task:
1313
+ """
1314
+ Poll a task until it completes or times out.
1315
+
1316
+ Useful for monitoring long-running firmware update tasks.
1317
+
1318
+ Args:
1319
+ task_id: Task ID to monitor
1320
+ poll_interval: Seconds between polls (default 5)
1321
+ timeout: Maximum wait time in seconds (default 600)
1322
+
1323
+ Returns:
1324
+ Completed Task resource
1325
+
1326
+ Raises:
1327
+ TimeoutError: If task does not complete within timeout
1328
+ """
1329
+ return self._tasks.wait_for_task(task_id, poll_interval, timeout)
1330
+
1331
+ # ==================================================================
1332
+ # Component query methods — Firmware / FRU
1333
+ # ==================================================================
1334
+
1335
+ def get_baseboard_fru(self, chassis_id: str = "1") -> Optional[dict]:
1336
+ """
1337
+ Get baseboard (motherboard) FRU data.
1338
+
1339
+ Uses the Chassis OEM FRU service to retrieve FRU board info.
1340
+ This is a vendor-specific extension (e.g., Huawei/xFusion iBMC).
1341
+
1342
+ Args:
1343
+ chassis_id: Chassis ID (default "1")
1344
+
1345
+ Returns:
1346
+ Raw FRU board data dict, or None if not available
1347
+ """
1348
+ return self._chassis.fru_service_board(chassis_id)
1349
+
1350
+ def get_mainboard(
1351
+ self,
1352
+ system_id: Optional[str] = None,
1353
+ chassis_id: str = "1",
1354
+ ) -> Optional[MainBoard]:
1355
+ """
1356
+ Get mainboard (motherboard) information with multi-path fallback.
1357
+
1358
+ Fallback order:
1359
+ 1. System FRU board info
1360
+ 2. Chassis OEM FRU service board info
1361
+ 3. Chassis OEM mainboard field
1362
+
1363
+ Args:
1364
+ system_id: System ID. Auto-selected if only one system exists.
1365
+ chassis_id: Chassis ID (default "1")
1366
+
1367
+ Returns:
1368
+ MainBoard model, or None if not available from any supported source
1369
+ """
1370
+ try:
1371
+ system_fru = self.get_system_fru(system_id)
1372
+ if system_fru is not None and system_fru.board is not None:
1373
+ logger.debug("Mainboard found via system FRU")
1374
+ return system_fru.board
1375
+ except RedfishException as exc:
1376
+ logger.debug("Failed to get mainboard via system FRU: %s", exc)
1377
+
1378
+ try:
1379
+ board_raw = self.get_baseboard_fru(chassis_id)
1380
+ if board_raw:
1381
+ if "BoardInfo" in board_raw:
1382
+ mainboard_raw = board_raw["BoardInfo"]
1383
+ mainboard_raw["@odata.type"] = board_raw.get("@odata.type", "#MainBoard.v1_0_0.MainBoard")
1384
+ mainboard_raw["@odata.id"] = board_raw.get("@odata.id")
1385
+ mainboard_raw["@odata.context"] = board_raw.get("@odata.context")
1386
+ logger.debug("Mainboard found via chassis FRU service")
1387
+ return MainBoard.model_validate(mainboard_raw)
1388
+ return MainBoard.model_validate(board_raw)
1389
+ except RedfishException as exc:
1390
+ logger.debug("Failed to get mainboard via chassis FRU service: %s", exc)
1391
+ except Exception as exc:
1392
+ logger.warning("Failed to parse mainboard from chassis FRU service: %s", exc)
1393
+
1394
+ try:
1395
+ chassis = self.get_chassis(chassis_id)
1396
+ if chassis.oem and chassis.oem.bmc and chassis.oem.bmc.mainboard:
1397
+ logger.debug("Mainboard found via chassis OEM mainboard")
1398
+ return chassis.oem.bmc.mainboard
1399
+ except RedfishException as exc:
1400
+ logger.debug("Failed to get mainboard via chassis OEM data: %s", exc)
1401
+
1402
+ logger.debug("Mainboard not available from any supported source")
1403
+ return None
1404
+
1405
+ # ------------------------------------------------------------------
1406
+ # Component query methods — Extracted sub-resources
1407
+ # ------------------------------------------------------------------
1408
+
1409
+ def get_fan(self, chassis_id: str = "1") -> List[Fan]:
1410
+ """
1411
+ Get fan information with multi-path fallback.
1412
+
1413
+ Fallback order:
1414
+ 1. ``/redfish/v1/Chassis/{id}/ThermalSubsystem/Fans`` — newer Redfish schema,
1415
+ fetches the collection then GETs each member individually.
1416
+ 2. ``/redfish/v1/Chassis/{id}/Thermal`` — legacy schema,
1417
+ extracts the ``Fans`` array from the Thermal resource.
1418
+
1419
+ Args:
1420
+ chassis_id: Chassis ID (default "1")
1421
+
1422
+ Returns:
1423
+ List of Fan objects (empty list if not available from any supported path)
1424
+ """
1425
+ # Path 1: ThermalSubsystem/Fans (newer Redfish schema)
1426
+ try:
1427
+ subsystem_path = f"/redfish/v1/Chassis/{chassis_id}/ThermalSubsystem/Fans"
1428
+ collection = self.get_raw(subsystem_path)
1429
+ if collection is not None:
1430
+ members = collection.get("Members", [])
1431
+ fans: List[Fan] = []
1432
+ for member in members:
1433
+ odata_id = member.get("@odata.id")
1434
+ if odata_id:
1435
+ try:
1436
+ fan_raw = self.get_raw(odata_id)
1437
+ fans.append(Fan.model_validate(fan_raw))
1438
+ except Exception as exc:
1439
+ logger.debug("Failed to fetch fan member %s: %s", odata_id, exc)
1440
+ if fans:
1441
+ logger.debug("Fans found via %s (%d fans)", subsystem_path, len(fans))
1442
+ return fans
1443
+ except RedfishException as exc:
1444
+ logger.debug("Failed to get fans via ThermalSubsystem: %s", exc)
1445
+
1446
+ # Path 2: Thermal (legacy schema)
1447
+ try:
1448
+ thermal = self.get_thermal(chassis_id)
1449
+ if thermal.fans:
1450
+ logger.debug("Fans found via Thermal resource (%d fans)", len(thermal.fans))
1451
+ return thermal.fans
1452
+ except RedfishException as exc:
1453
+ logger.debug("Failed to get fans via Thermal: %s", exc)
1454
+
1455
+ logger.debug("Fan info not available from any supported source")
1456
+ return []
1457
+
1458
+ def get_power_supplies(self, chassis_id: str = "1") -> List[PowerSupply]:
1459
+ """
1460
+ Get the list of power supply units (PSUs) for a chassis.
1461
+
1462
+ Extracts the PowerSupplies array from the Power resource.
1463
+
1464
+ Args:
1465
+ chassis_id: Chassis ID (default "1")
1466
+
1467
+ Returns:
1468
+ List of PowerSupply objects (empty list if no PSUs found)
1469
+ """
1470
+ power = self.get_power(chassis_id)
1471
+ return power.power_supplies or []
1472
+
1473
+ # ------------------------------------------------------------------
1474
+ # Component query methods — System-level convenience
1475
+ # ------------------------------------------------------------------
1476
+
1477
+ def get_manufacturer(self, system_id: Optional[str] = None) -> str:
1478
+ """
1479
+ Get the server manufacturer name.
1480
+
1481
+ Args:
1482
+ system_id: System ID. Auto-selected if only one system exists.
1483
+
1484
+ Returns:
1485
+ Manufacturer name string (e.g., "Huawei", "Inspur", "H3C", "Lenovo")
1486
+
1487
+ Raises:
1488
+ RedfishException: If manufacturer field is not available
1489
+ """
1490
+ system = self._systems.get(system_id)
1491
+ if not system.manufacturer:
1492
+ raise RedfishException(500, "Manufacturer field not found in system resource")
1493
+ return system.manufacturer
1494
+
1495
+ # ------------------------------------------------------------------
1496
+ # Resource metadata methods (ETag / @odata.id)
1497
+ # ------------------------------------------------------------------
1498
+
1499
+ def get_etag(self, path: str) -> Optional[str]:
1500
+ """
1501
+ Get the cached ETag for a Redfish resource path.
1502
+
1503
+ ETag is used for concurrency control in Redfish:
1504
+ - GET responses include an ETag header (or @odata.etag in JSON body)
1505
+ - PATCH/PUT requests should include If-Match header with the ETag
1506
+ - This prevents "lost update" problems when multiple clients modify the same resource
1507
+
1508
+ If the resource has not been fetched yet (no cached ETag), this method
1509
+ will perform a HEAD-like GET to retrieve and cache the ETag.
1510
+
1511
+ Args:
1512
+ path: Redfish resource path (e.g., "/redfish/v1/Systems/1")
1513
+
1514
+ Returns:
1515
+ ETag string (e.g., '"W/12345"'), or None if the server does not provide ETags
1516
+ """
1517
+ # Check cached ETag first
1518
+ cached = self._http_client._last_etag.get(path)
1519
+ if cached:
1520
+ return cached
1521
+
1522
+ # Fetch the resource to populate the ETag cache
1523
+ try:
1524
+ self._http_client.get_raw(path)
1525
+ except RedfishException:
1526
+ return None
1527
+
1528
+ return self._http_client._last_etag.get(path)
1529
+
1530
+ def get_odata_id(self, key: RedfishResource) -> Optional[str]:
1531
+ """
1532
+ Look up the @odata.id for a Redfish resource by its ``RedfishResource`` key.
1533
+
1534
+ Automatically searches the Redfish resource tree in a fixed order:
1535
+
1536
+ 1. RootService (``/redfish/v1/``)
1537
+ 2. First member of the Systems collection
1538
+ 3. First member of the Chassis collection
1539
+ 4. First member of the Managers collection
1540
+
1541
+ The first match wins and is returned immediately.
1542
+
1543
+ Args:
1544
+ key: A ``RedfishResource`` enum member identifying the resource
1545
+ (e.g., ``RedfishResource.PROCESSORS``, ``RedfishResource.THERMAL``)
1546
+
1547
+ Returns:
1548
+ The @odata.id string (e.g., ``"/redfish/v1/Systems/1/Processors"``),
1549
+ or ``None`` if the key was not found in any layer.
1550
+
1551
+ Example::
1552
+
1553
+ from redfish_sdk import RedfishClient, RedfishResource
1554
+
1555
+ client = RedfishClient(host="10.0.0.1", username="admin", password="pwd")
1556
+ url = client.get_odata_id(RedfishResource.PROCESSORS)
1557
+ # → "/redfish/v1/Systems/1/Processors"
1558
+ """
1559
+ field_name = key.value # e.g. "Processors", "Thermal"
1560
+
1561
+ # Step 1: Search in RootService
1562
+ try:
1563
+ root_data = self._http_client.get_raw("/redfish/v1/")
1564
+ result = self._extract_odata_id(root_data, field_name)
1565
+ if result is not None:
1566
+ return result
1567
+ except Exception as exc:
1568
+ logger.warning("get_odata_id: failed to fetch RootService: %s", exc)
1569
+
1570
+ # Step 2–4: Search in first member of Systems, Chassis, Managers
1571
+ collection_keys = ["Systems", "Chassis", "Managers"]
1572
+ for col_key in collection_keys:
1573
+ try:
1574
+ first_member_data = self._get_first_collection_member_raw(col_key)
1575
+ if first_member_data is None:
1576
+ continue
1577
+ result = self._extract_odata_id(first_member_data, field_name)
1578
+ if result is not None:
1579
+ return result
1580
+ except Exception as exc:
1581
+ logger.warning(
1582
+ "get_odata_id: failed to search in %s collection: %s",
1583
+ col_key, exc,
1584
+ )
1585
+
1586
+ return None
1587
+
1588
+ @staticmethod
1589
+ def get_resource_odata_id(resource) -> Optional[str]:
1590
+ """
1591
+ Extract the @odata.id from a Redfish resource object.
1592
+
1593
+ This is a backward-compatible convenience method that works with any
1594
+ SDK model object (Entity, Link, or any pydantic model with an odata_id field).
1595
+
1596
+ Args:
1597
+ resource: Any Redfish resource model instance (e.g., System, Chassis, Processor)
1598
+
1599
+ Returns:
1600
+ The @odata.id string (e.g., "/redfish/v1/Systems/1"), or None if not present
1601
+ """
1602
+ return getattr(resource, "odata_id", None)
1603
+
1604
+ # ------------------------------------------------------------------
1605
+ # get_odata_id internal helpers
1606
+ # ------------------------------------------------------------------
1607
+
1608
+ def _get_first_collection_member_raw(self, collection_key: str) -> Optional[dict]:
1609
+ """
1610
+ Fetch the raw JSON of the first member in a top-level collection.
1611
+
1612
+ Args:
1613
+ collection_key: Top-level collection name (e.g., "Systems", "Chassis", "Managers")
1614
+
1615
+ Returns:
1616
+ Raw JSON dict of the first member, or None if not available
1617
+ """
1618
+ # Get collection @odata.id from root
1619
+ root_data = self._http_client.get_raw("/redfish/v1/")
1620
+ col_ref = root_data.get(collection_key)
1621
+ if col_ref is None:
1622
+ return None
1623
+
1624
+ col_odata_id = col_ref.get("@odata.id") if isinstance(col_ref, dict) else None
1625
+ if not col_odata_id:
1626
+ return None
1627
+
1628
+ # Get collection members list
1629
+ col_data = self._http_client.get_raw(col_odata_id)
1630
+ members = col_data.get("Members", [])
1631
+ if not members:
1632
+ return None
1633
+
1634
+ # Get first member
1635
+ first_member_id = members[0].get("@odata.id")
1636
+ if not first_member_id:
1637
+ return None
1638
+
1639
+ return self._http_client.get_raw(first_member_id)
1640
+
1641
+ @staticmethod
1642
+ def _extract_odata_id(data: dict, field_name: str) -> Optional[str]:
1643
+ """
1644
+ Extract @odata.id for a given field name from a resource JSON dict.
1645
+
1646
+ Search order:
1647
+ 1. Top-level fields
1648
+ 2. Links section
1649
+ 3. Oem section (recursive)
1650
+
1651
+ Args:
1652
+ data: Raw JSON dict of a Redfish resource
1653
+ field_name: The field name to look for (e.g., "Processors", "Thermal")
1654
+
1655
+ Returns:
1656
+ The @odata.id string, or None if not found
1657
+ """
1658
+ # 1. Top-level field
1659
+ value = data.get(field_name)
1660
+ if value is not None:
1661
+ odata_id = RedfishClient._resolve_odata_id(value)
1662
+ if odata_id:
1663
+ return odata_id
1664
+
1665
+ # 2. Links section
1666
+ links = data.get("Links")
1667
+ if isinstance(links, dict):
1668
+ value = links.get(field_name)
1669
+ if value is not None:
1670
+ odata_id = RedfishClient._resolve_odata_id(value)
1671
+ if odata_id:
1672
+ return odata_id
1673
+
1674
+ # 3. Oem section (recursive search)
1675
+ oem = data.get("Oem")
1676
+ if isinstance(oem, dict):
1677
+ odata_id = RedfishClient._search_oem_for_key(oem, field_name)
1678
+ if odata_id:
1679
+ return odata_id
1680
+
1681
+ return None
1682
+
1683
+ @staticmethod
1684
+ def _resolve_odata_id(value) -> Optional[str]:
1685
+ """
1686
+ Resolve @odata.id from a field value.
1687
+
1688
+ Handles:
1689
+ - dict with "@odata.id" key
1690
+ - str (direct path)
1691
+ - list of dicts (takes first element's @odata.id)
1692
+ """
1693
+ if isinstance(value, dict):
1694
+ return value.get("@odata.id")
1695
+ if isinstance(value, str) and value.startswith("/"):
1696
+ return value
1697
+ if isinstance(value, list) and value:
1698
+ first = value[0]
1699
+ if isinstance(first, dict):
1700
+ return first.get("@odata.id")
1701
+ return None
1702
+
1703
+ @staticmethod
1704
+ def _search_oem_for_key(oem_data: dict, field_name: str) -> Optional[str]:
1705
+ """
1706
+ Recursively search the Oem section for a field matching field_name.
1707
+ """
1708
+ for k, v in oem_data.items():
1709
+ if k == field_name:
1710
+ return RedfishClient._resolve_odata_id(v)
1711
+ if isinstance(v, dict):
1712
+ result = RedfishClient._search_oem_for_key(v, field_name)
1713
+ if result:
1714
+ return result
1715
+ return None
1716
+
1717
+ # ------------------------------------------------------------------
1718
+ # Raw JSON access (generic CRUD)
1719
+ # ------------------------------------------------------------------
1720
+
1721
+ def get_raw(self, odata_id: str) -> dict:
1722
+ """
1723
+ Fetch the raw JSON data for any Redfish resource by its @odata.id.
1724
+
1725
+ Unlike typed getter methods (e.g., ``get_system()``), this returns the
1726
+ unprocessed JSON dict exactly as the BMC returns it — useful for
1727
+ inspecting vendor-specific (OEM) fields, debugging, or accessing
1728
+ resources that the SDK does not yet model.
1729
+
1730
+ Args:
1731
+ odata_id: The @odata.id path of the resource
1732
+ (e.g., ``"/redfish/v1/Systems/1"``)
1733
+
1734
+ Returns:
1735
+ Raw JSON dict of the resource
1736
+
1737
+ Raises:
1738
+ RedfishException: On HTTP errors (404, 500, etc.)
1739
+
1740
+ Example::
1741
+
1742
+ from redfish_sdk import RedfishClient
1743
+
1744
+ client = RedfishClient(host="10.0.0.1", username="admin", password="pwd")
1745
+ data = client.get_raw("/redfish/v1/Systems/1")
1746
+ print(data["Manufacturer"])
1747
+ # → "Huawei"
1748
+ """
1749
+ return self._http_client.get_raw(odata_id)
1750
+
1751
+ def patch(self, odata_id: str, body: dict) -> dict:
1752
+ """
1753
+ Send a PATCH request to partially update a Redfish resource.
1754
+
1755
+ Automatically handles ETag-based concurrency control:
1756
+ if no ETag is cached for the target path, a GET is issued first
1757
+ to obtain one. The ETag is then sent as the ``If-Match`` header.
1758
+
1759
+ Args:
1760
+ odata_id: The @odata.id path of the resource to update
1761
+ (e.g., ``"/redfish/v1/Systems/1"``)
1762
+ body: A dict containing only the fields to modify
1763
+
1764
+ Returns:
1765
+ Raw JSON dict of the BMC response (empty dict ``{}`` on 204 No Content)
1766
+
1767
+ Raises:
1768
+ RedfishException: On HTTP errors (412 Precondition Failed, 500, etc.)
1769
+
1770
+ Example::
1771
+
1772
+ client.patch("/redfish/v1/Systems/1", {
1773
+ "Boot": {
1774
+ "BootSourceOverrideEnabled": "Once",
1775
+ "BootSourceOverrideTarget": "Pxe",
1776
+ }
1777
+ })
1778
+ """
1779
+ # Ensure ETag is cached before PATCH (auto-GET if missing)
1780
+ if odata_id not in self._http_client._last_etag:
1781
+ try:
1782
+ self._http_client.get_raw(odata_id)
1783
+ except RedfishException:
1784
+ pass # proceed with '*' wildcard if GET fails
1785
+
1786
+ response = self._http_client.patch_raw(odata_id, body)
1787
+
1788
+ if response.status_code == 204 or not response.text.strip():
1789
+ return {}
1790
+ return response.json()
1791
+
1792
+ def post(self, odata_id: str, body: Optional[dict] = None) -> dict:
1793
+ """
1794
+ Send a POST request to create a resource or trigger an action.
1795
+
1796
+ Args:
1797
+ odata_id: The target path
1798
+ (e.g., ``"/redfish/v1/AccountService/Accounts"`` or
1799
+ ``"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset"``)
1800
+ body: Optional request body dict
1801
+
1802
+ Returns:
1803
+ Raw JSON dict of the BMC response (empty dict ``{}`` on 204 No Content)
1804
+
1805
+ Raises:
1806
+ RedfishException: On HTTP errors (400, 500, etc.)
1807
+
1808
+ Example::
1809
+
1810
+ # Trigger a system reset
1811
+ client.post(
1812
+ "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
1813
+ {"ResetType": "GracefulRestart"},
1814
+ )
1815
+
1816
+ # Create a user account
1817
+ client.post("/redfish/v1/AccountService/Accounts", {
1818
+ "UserName": "operator",
1819
+ "Password": "Op3r@tor!",
1820
+ "RoleId": "Operator",
1821
+ })
1822
+ """
1823
+ response = self._http_client.post_raw(odata_id, body)
1824
+
1825
+ if response.status_code == 204 or not response.text.strip():
1826
+ return {}
1827
+ return response.json()
1828
+
1829
+ def delete(self, odata_id: str) -> None:
1830
+ """
1831
+ Send a DELETE request to remove a Redfish resource.
1832
+
1833
+ Args:
1834
+ odata_id: The @odata.id path of the resource to delete
1835
+ (e.g., ``"/redfish/v1/SessionService/Sessions/abc123"``)
1836
+
1837
+ Returns:
1838
+ None
1839
+
1840
+ Raises:
1841
+ RedfishException: On HTTP errors (404, 500, etc.)
1842
+
1843
+ Example::
1844
+
1845
+ client.delete("/redfish/v1/SessionService/Sessions/abc123")
1846
+ """
1847
+ self._http_client.delete(odata_id)
1848
+
1849
+ # ------------------------------------------------------------------
1850
+ # Convenience aggregation method
1851
+ # ------------------------------------------------------------------
1852
+
1853
+ def get_all_components_summary(
1854
+ self, system_id: Optional[str] = None, chassis_id: str = "1"
1855
+ ) -> dict:
1856
+ """
1857
+ Get a summary of all hardware components in a single call.
1858
+
1859
+ Args:
1860
+ system_id: System ID. Auto-selected if only one system exists.
1861
+ chassis_id: Chassis ID (default "1")
1862
+
1863
+ Returns:
1864
+ Dictionary with all component lists/resources::
1865
+
1866
+ {
1867
+ "processors": List[Processor],
1868
+ "memory": List[Memory],
1869
+ "storages": List[Storage],
1870
+ "gpus": List[Gpu],
1871
+ "drives": List[Drive],
1872
+ "network_adapters": List[NetworkAdapter],
1873
+ "pcie_devices": List[PCIeDevice],
1874
+ "power": Power,
1875
+ "thermal": Thermal,
1876
+ "fans": List[Fan],
1877
+ "power_supplies": List[PowerSupply],
1878
+ "firmware_inventory": List[FirmwareInventory],
1879
+ }
1880
+ """
1881
+ return {
1882
+ "processors": self.get_processors(system_id),
1883
+ "memory": self.get_memory(system_id),
1884
+ "storages": self.get_storages(system_id),
1885
+ "gpus": self.get_gpus(system_id),
1886
+ "drives": self.get_drives(chassis_id),
1887
+ "network_adapters": self.get_network_adapters(chassis_id),
1888
+ "pcie_devices": self.get_pcie_devices(chassis_id),
1889
+ "power": self.get_power(chassis_id),
1890
+ "thermal": self.get_thermal(chassis_id),
1891
+ "fans": self.get_fan(chassis_id),
1892
+ "power_supplies": self.get_power_supplies(chassis_id),
1893
+ "firmware_inventory": self.get_firmware_inventory(),
1894
+ }