aiofortiosapi 0.1.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.
- aiofortiosapi/__init__.py +31 -0
- aiofortiosapi/client.py +200 -0
- aiofortiosapi/const.py +29 -0
- aiofortiosapi/exceptions.py +35 -0
- aiofortiosapi/models.py +192 -0
- aiofortiosapi/py.typed +0 -0
- aiofortiosapi-0.1.0.dist-info/METADATA +125 -0
- aiofortiosapi-0.1.0.dist-info/RECORD +10 -0
- aiofortiosapi-0.1.0.dist-info/WHEEL +4 -0
- aiofortiosapi-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""aiofortiosapi — async FortiOS REST API client for Home Assistant."""
|
|
2
|
+
|
|
3
|
+
from .client import FortiOSClient
|
|
4
|
+
from .const import DEFAULT_ONLINE_THRESHOLD
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
FortiOSAuthenticationError,
|
|
7
|
+
FortiOSConnectionError,
|
|
8
|
+
FortiOSError,
|
|
9
|
+
FortiOSNotFoundError,
|
|
10
|
+
FortiOSResponseError,
|
|
11
|
+
)
|
|
12
|
+
from .models import DetectedDevice, ResourceUsage, SystemStatus
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FortiOSClient",
|
|
18
|
+
# exceptions
|
|
19
|
+
"FortiOSError",
|
|
20
|
+
"FortiOSConnectionError",
|
|
21
|
+
"FortiOSAuthenticationError",
|
|
22
|
+
"FortiOSNotFoundError",
|
|
23
|
+
"FortiOSResponseError",
|
|
24
|
+
# models
|
|
25
|
+
"SystemStatus",
|
|
26
|
+
"ResourceUsage",
|
|
27
|
+
"DetectedDevice",
|
|
28
|
+
# constants
|
|
29
|
+
"DEFAULT_ONLINE_THRESHOLD",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
aiofortiosapi/client.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""FortiOSClient — async HTTP client for the FortiOS REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from .const import (
|
|
14
|
+
DEFAULT_ONLINE_THRESHOLD,
|
|
15
|
+
DEFAULT_PORT,
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
17
|
+
EP_DETECTED_DEVICES,
|
|
18
|
+
EP_RESOURCE_USAGE,
|
|
19
|
+
EP_SYSTEM_STATUS,
|
|
20
|
+
)
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
FortiOSAuthenticationError,
|
|
23
|
+
FortiOSConnectionError,
|
|
24
|
+
FortiOSNotFoundError,
|
|
25
|
+
FortiOSResponseError,
|
|
26
|
+
)
|
|
27
|
+
from .models import DetectedDevice, ResourceUsage, SystemStatus
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# FortiOS error envelopes carry the useful string under different keys
|
|
32
|
+
# depending on firmware and failure mode (e.g. bad token, trusted-host
|
|
33
|
+
# violation, CMDB error). Checked in priority order.
|
|
34
|
+
_ERROR_DETAIL_KEYS: tuple[str, ...] = (
|
|
35
|
+
"cli_error",
|
|
36
|
+
"message",
|
|
37
|
+
"error",
|
|
38
|
+
"http_message",
|
|
39
|
+
)
|
|
40
|
+
_ERROR_DETAIL_MAX_CHARS = 200
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_error_detail(body_text: str) -> str:
|
|
44
|
+
"""Return the most useful human-readable string from an error body.
|
|
45
|
+
|
|
46
|
+
Falls back to the raw body (truncated) when the payload is not JSON or
|
|
47
|
+
carries none of the known detail keys.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
parsed: Any = json.loads(body_text)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return body_text.strip()[:_ERROR_DETAIL_MAX_CHARS]
|
|
53
|
+
if isinstance(parsed, dict):
|
|
54
|
+
for key in _ERROR_DETAIL_KEYS:
|
|
55
|
+
if value := parsed.get(key):
|
|
56
|
+
return str(value)[:_ERROR_DETAIL_MAX_CHARS]
|
|
57
|
+
return body_text.strip()[:_ERROR_DETAIL_MAX_CHARS]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FortiOSClient:
|
|
61
|
+
"""Async client for the FortiOS REST API.
|
|
62
|
+
|
|
63
|
+
The caller (e.g. Home Assistant) owns the aiohttp.ClientSession lifecycle.
|
|
64
|
+
This class never creates or closes the session it receives.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
host: str,
|
|
70
|
+
token: str,
|
|
71
|
+
*,
|
|
72
|
+
session: aiohttp.ClientSession,
|
|
73
|
+
port: int = DEFAULT_PORT,
|
|
74
|
+
verify_ssl: bool = True,
|
|
75
|
+
vdom: str | None = None,
|
|
76
|
+
request_timeout: float = DEFAULT_TIMEOUT,
|
|
77
|
+
device_online_threshold: float = DEFAULT_ONLINE_THRESHOLD,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._host = host
|
|
80
|
+
self._token = token
|
|
81
|
+
self._session = session
|
|
82
|
+
self._port = port
|
|
83
|
+
self._verify_ssl = verify_ssl
|
|
84
|
+
self._vdom = vdom
|
|
85
|
+
self._request_timeout = request_timeout
|
|
86
|
+
self._device_online_threshold = device_online_threshold
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Internal request plumbing
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _build_url(self, path: str) -> str:
|
|
93
|
+
return f"https://{self._host}:{self._port}/{path}"
|
|
94
|
+
|
|
95
|
+
async def _request(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
path: str,
|
|
99
|
+
*,
|
|
100
|
+
params: dict[str, Any] | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Single choke-point for all HTTP.
|
|
103
|
+
|
|
104
|
+
Builds URL, injects auth header, merges vdom param, applies timeout,
|
|
105
|
+
and maps all error conditions to the exception hierarchy.
|
|
106
|
+
"""
|
|
107
|
+
url = self._build_url(path)
|
|
108
|
+
merged_params: dict[str, Any] = dict(params) if params else {}
|
|
109
|
+
if self._vdom is not None:
|
|
110
|
+
merged_params["vdom"] = self._vdom
|
|
111
|
+
|
|
112
|
+
headers = {"Authorization": f"Bearer {self._token}"}
|
|
113
|
+
ssl: bool = self._verify_ssl
|
|
114
|
+
|
|
115
|
+
_LOGGER.debug("FortiOS %s %s params=%s", method, url, list(merged_params.keys()))
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
async with (
|
|
119
|
+
asyncio.timeout(self._request_timeout),
|
|
120
|
+
self._session.request(
|
|
121
|
+
method,
|
|
122
|
+
url,
|
|
123
|
+
headers=headers,
|
|
124
|
+
params=merged_params or None,
|
|
125
|
+
ssl=ssl,
|
|
126
|
+
) as response,
|
|
127
|
+
):
|
|
128
|
+
if response.status in (401, 403):
|
|
129
|
+
raise FortiOSAuthenticationError(
|
|
130
|
+
f"Authentication failed (HTTP {response.status})"
|
|
131
|
+
)
|
|
132
|
+
if response.status == 404:
|
|
133
|
+
raise FortiOSNotFoundError(f"Endpoint not found: {path}")
|
|
134
|
+
|
|
135
|
+
if response.status >= 400:
|
|
136
|
+
detail = _extract_error_detail(await response.text())
|
|
137
|
+
raise FortiOSResponseError(f"HTTP {response.status} from {url}: {detail}")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
data: dict[str, Any] = await response.json()
|
|
141
|
+
except (aiohttp.ClientError, ValueError) as exc:
|
|
142
|
+
raise FortiOSResponseError(f"Invalid JSON from {url}") from exc
|
|
143
|
+
|
|
144
|
+
return data
|
|
145
|
+
except TimeoutError as exc:
|
|
146
|
+
raise FortiOSConnectionError(f"Request timed out: {url}") from exc
|
|
147
|
+
except aiohttp.ClientError as exc:
|
|
148
|
+
raise FortiOSConnectionError(f"Connection error: {exc}") from exc
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# Public API methods
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
async def get(
|
|
155
|
+
self,
|
|
156
|
+
path: str,
|
|
157
|
+
*,
|
|
158
|
+
params: dict[str, Any] | None = None,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Perform a raw GET and return the parsed JSON envelope.
|
|
161
|
+
|
|
162
|
+
Escape hatch for endpoints without a dedicated typed method: returns
|
|
163
|
+
the full ``{"results": ..., "status": ..., ...}`` dict so the caller
|
|
164
|
+
can extract whatever it needs. Inherits Bearer auth, timeout, and
|
|
165
|
+
the typed exception hierarchy from ``_request``.
|
|
166
|
+
"""
|
|
167
|
+
return await self._request("GET", path, params=params)
|
|
168
|
+
|
|
169
|
+
async def get_system_status(self) -> SystemStatus:
|
|
170
|
+
"""Return basic device identity: hostname, version, serial, model."""
|
|
171
|
+
raw = await self._request("GET", EP_SYSTEM_STATUS)
|
|
172
|
+
return SystemStatus.from_api(raw)
|
|
173
|
+
|
|
174
|
+
async def get_resource_usage(self) -> ResourceUsage:
|
|
175
|
+
"""Return CPU, memory, session count, and uptime."""
|
|
176
|
+
raw = await self._request("GET", EP_RESOURCE_USAGE)
|
|
177
|
+
return ResourceUsage.from_api(raw)
|
|
178
|
+
|
|
179
|
+
async def get_detected_devices(self) -> list[DetectedDevice]:
|
|
180
|
+
"""Return devices seen on the network (for device_tracker).
|
|
181
|
+
|
|
182
|
+
``is_online`` uses the API-reported flag; when a firmware omits it,
|
|
183
|
+
it is derived from ``last_seen`` freshness using the client's
|
|
184
|
+
``device_online_threshold``.
|
|
185
|
+
"""
|
|
186
|
+
raw = await self._request("GET", EP_DETECTED_DEVICES)
|
|
187
|
+
return DetectedDevice.list_from_api(
|
|
188
|
+
raw,
|
|
189
|
+
now=time.time(),
|
|
190
|
+
online_threshold=self._device_online_threshold,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def async_validate(self) -> SystemStatus:
|
|
194
|
+
"""Validate credentials cheaply by calling get_system_status.
|
|
195
|
+
|
|
196
|
+
Raises FortiOSAuthenticationError for bad tokens, or
|
|
197
|
+
FortiOSConnectionError for unreachable devices. Used by the HA
|
|
198
|
+
config_flow to confirm a token works before saving the entry.
|
|
199
|
+
"""
|
|
200
|
+
return await self.get_system_status()
|
aiofortiosapi/const.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Constants: endpoint paths and defaults.
|
|
2
|
+
|
|
3
|
+
WARNING: FortiOS monitor endpoint paths vary by firmware version and must be
|
|
4
|
+
verified against the target version's API reference (FNDN — Fortinet Developer
|
|
5
|
+
Network). The paths below are known-good starting points for FortiOS 6.4 –
|
|
6
|
+
8.x (verified on v8.0.1); do NOT assume they exist on older or newer
|
|
7
|
+
firmware without checking.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
API_BASE = "api/v2"
|
|
11
|
+
|
|
12
|
+
EP_SYSTEM_STATUS = f"{API_BASE}/monitor/system/status"
|
|
13
|
+
EP_RESOURCE_USAGE = f"{API_BASE}/monitor/system/resource/usage"
|
|
14
|
+
|
|
15
|
+
# 7.0+: older firmware used monitor/user/device/select
|
|
16
|
+
EP_DETECTED_DEVICES = f"{API_BASE}/monitor/user/device/query"
|
|
17
|
+
|
|
18
|
+
# Future endpoints (add intentionally, verify paths per target version):
|
|
19
|
+
# monitor/vpn/ipsec — IPsec/VPN tunnel state
|
|
20
|
+
# monitor/system/interface — per-interface traffic stats
|
|
21
|
+
# monitor/system/ha-statistics — HA cluster status
|
|
22
|
+
|
|
23
|
+
DEFAULT_PORT: int = 443
|
|
24
|
+
DEFAULT_TIMEOUT: float = 10.0
|
|
25
|
+
|
|
26
|
+
# Fallback window for deriving device online state: when an API response
|
|
27
|
+
# omits the is_online field, a device counts as online if it was last seen
|
|
28
|
+
# within this many seconds (mirrors HA device_tracker "consider_home").
|
|
29
|
+
DEFAULT_ONLINE_THRESHOLD: float = 300.0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exception hierarchy for aiofortiosapi.
|
|
2
|
+
|
|
3
|
+
Map to Home Assistant config entry states:
|
|
4
|
+
FortiOSConnectionError -> ConfigEntryNotReady (retry)
|
|
5
|
+
FortiOSAuthenticationError -> ConfigEntryAuthFailed (reauth)
|
|
6
|
+
FortiOSNotFoundError -> inspect / log
|
|
7
|
+
FortiOSResponseError -> log / raise
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FortiOSError(Exception):
|
|
12
|
+
"""Base exception for all aiofortiosapi errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FortiOSConnectionError(FortiOSError):
|
|
16
|
+
"""Transport-level or timeout failure.
|
|
17
|
+
|
|
18
|
+
Raised for aiohttp.ClientError and asyncio.TimeoutError.
|
|
19
|
+
Home Assistant should map this to ConfigEntryNotReady.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FortiOSAuthenticationError(FortiOSError):
|
|
24
|
+
"""HTTP 401 or 403 response.
|
|
25
|
+
|
|
26
|
+
Home Assistant should map this to ConfigEntryAuthFailed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FortiOSNotFoundError(FortiOSError):
|
|
31
|
+
"""HTTP 404 response — endpoint does not exist on this FortiOS version."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FortiOSResponseError(FortiOSError):
|
|
35
|
+
"""Unexpected or unparseable response (bad JSON, 5xx, other 4xx)."""
|
aiofortiosapi/models.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Frozen dataclasses for parsed FortiOS API responses.
|
|
2
|
+
|
|
3
|
+
FortiOS wraps all payloads as:
|
|
4
|
+
{"results": <dict|list>, "status": "success", "version": "v7.6.1",
|
|
5
|
+
"serial": "FGT60F...", "vdom": "root", ...}
|
|
6
|
+
|
|
7
|
+
Each model's from_api() accepts the full envelope dict and parses defensively:
|
|
8
|
+
missing keys, null values, and unexpected shapes all fall back to safe
|
|
9
|
+
defaults instead of raising.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .const import DEFAULT_ONLINE_THRESHOLD
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _first(lst: Any, key: str, default: Any = None) -> Any:
|
|
22
|
+
"""Extract key from the first element of a list of dicts, or default.
|
|
23
|
+
|
|
24
|
+
FortiOS returns cpu/memory/session stats as single-element lists of
|
|
25
|
+
objects; tolerate scalar lists, empty lists, and non-list values.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(lst, list) and lst and isinstance(lst[0], dict):
|
|
28
|
+
return lst[0].get(key, default)
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _as_str(value: Any) -> str:
|
|
33
|
+
"""Coerce an optional API string field to str; null and non-str become ''."""
|
|
34
|
+
return value if isinstance(value, str) else ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _to_int(value: Any, default: int = 0) -> int:
|
|
38
|
+
try:
|
|
39
|
+
return int(value)
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _to_float(value: Any, default: float = 0.0) -> float:
|
|
45
|
+
try:
|
|
46
|
+
return float(value)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class SystemStatus:
|
|
53
|
+
"""Parsed response from monitor/system/status."""
|
|
54
|
+
|
|
55
|
+
hostname: str
|
|
56
|
+
version: str
|
|
57
|
+
serial: str
|
|
58
|
+
model: str
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_api(cls, raw: dict[str, Any]) -> SystemStatus:
|
|
62
|
+
results: Any = raw.get("results")
|
|
63
|
+
if not isinstance(results, dict):
|
|
64
|
+
results = {}
|
|
65
|
+
serial = _as_str(raw.get("serial") or results.get("Serial-Number"))
|
|
66
|
+
version = _as_str(raw.get("version") or results.get("Version"))
|
|
67
|
+
hostname = _as_str(results.get("Hostname") or results.get("hostname"))
|
|
68
|
+
# Model name: use FGVM/FGT prefix from serial when not explicit
|
|
69
|
+
model = _as_str(results.get("Model-Name") or results.get("model") or serial[:6])
|
|
70
|
+
return cls(hostname=hostname, version=version, serial=serial, model=model)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class ResourceUsage:
|
|
75
|
+
"""Parsed response from monitor/system/resource/usage.
|
|
76
|
+
|
|
77
|
+
Real FortiOS (verified on v8.0.1) returns each metric as a single-element
|
|
78
|
+
list with a ``current`` value, and splits sessions/setup-rate into IPv4
|
|
79
|
+
and IPv6 buckets:
|
|
80
|
+
|
|
81
|
+
``{"cpu": [{"current": 0}], "mem": [{"current": 34}],
|
|
82
|
+
"session": [{"current": 279}], "session6": [{"current": 57}],
|
|
83
|
+
"setuprate": [{"current": 7}], "setuprate6": [{"current": 0}], ...}``
|
|
84
|
+
|
|
85
|
+
``sessions`` and ``session_setup_rate`` are the sum of the v4 + v6
|
|
86
|
+
buckets.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
cpu_percent: float
|
|
90
|
+
memory_percent: float
|
|
91
|
+
sessions: int # session + session6
|
|
92
|
+
session_setup_rate: int # setuprate + setuprate6
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_api(cls, raw: dict[str, Any]) -> ResourceUsage:
|
|
96
|
+
results: Any = raw.get("results")
|
|
97
|
+
if not isinstance(results, dict):
|
|
98
|
+
results = {}
|
|
99
|
+
return cls(
|
|
100
|
+
cpu_percent=_to_float(_first(results.get("cpu"), "current")),
|
|
101
|
+
memory_percent=_to_float(_first(results.get("mem"), "current")),
|
|
102
|
+
sessions=(
|
|
103
|
+
_to_int(_first(results.get("session"), "current"))
|
|
104
|
+
+ _to_int(_first(results.get("session6"), "current"))
|
|
105
|
+
),
|
|
106
|
+
session_setup_rate=(
|
|
107
|
+
_to_int(_first(results.get("setuprate"), "current"))
|
|
108
|
+
+ _to_int(_first(results.get("setuprate6"), "current"))
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class DetectedDevice:
|
|
115
|
+
"""A single device from monitor/user/device/query.
|
|
116
|
+
|
|
117
|
+
Consumed by the Home Assistant device_tracker platform.
|
|
118
|
+
|
|
119
|
+
FortiOS reports an ``is_online`` flag for detected devices, which is used
|
|
120
|
+
verbatim. When a firmware omits the field, ``is_online`` is derived from
|
|
121
|
+
``last_seen`` freshness instead: True when the device was seen within
|
|
122
|
+
``online_threshold`` seconds of the reference time (default
|
|
123
|
+
``DEFAULT_ONLINE_THRESHOLD``, mirroring HA's consider_home behaviour).
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
mac: str
|
|
127
|
+
hostname: str
|
|
128
|
+
ip: str # primary IP; may be empty string if unknown
|
|
129
|
+
os_name: str
|
|
130
|
+
interface: str
|
|
131
|
+
last_seen: int # unix timestamp; 0 when not available
|
|
132
|
+
is_online: bool # API-reported; derived from last_seen when absent
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def _from_entry(
|
|
136
|
+
cls,
|
|
137
|
+
entry: dict[str, Any],
|
|
138
|
+
*,
|
|
139
|
+
now: float,
|
|
140
|
+
online_threshold: float,
|
|
141
|
+
) -> DetectedDevice:
|
|
142
|
+
# IPs may come as a list, a plain string, or null
|
|
143
|
+
ips: Any = entry.get("ipv4_address", entry.get("ip"))
|
|
144
|
+
if isinstance(ips, list):
|
|
145
|
+
ip = _as_str(ips[0]) if ips else ""
|
|
146
|
+
elif isinstance(ips, str):
|
|
147
|
+
ip = ips
|
|
148
|
+
else:
|
|
149
|
+
ip = ""
|
|
150
|
+
|
|
151
|
+
last_seen = _to_int(entry.get("last_seen"))
|
|
152
|
+
|
|
153
|
+
api_flag = entry.get("is_online")
|
|
154
|
+
if api_flag is None:
|
|
155
|
+
# Field absent on this firmware: fall back to freshness.
|
|
156
|
+
is_online = last_seen > 0 and (now - float(last_seen)) <= online_threshold
|
|
157
|
+
else:
|
|
158
|
+
is_online = bool(api_flag)
|
|
159
|
+
|
|
160
|
+
return cls(
|
|
161
|
+
mac=_as_str(entry.get("mac")),
|
|
162
|
+
hostname=_as_str(entry.get("hostname") or entry.get("name")),
|
|
163
|
+
ip=ip,
|
|
164
|
+
os_name=_as_str(entry.get("os_name") or entry.get("os")),
|
|
165
|
+
interface=_as_str(entry.get("interface")),
|
|
166
|
+
last_seen=last_seen,
|
|
167
|
+
is_online=is_online,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def list_from_api(
|
|
172
|
+
cls,
|
|
173
|
+
raw: dict[str, Any],
|
|
174
|
+
*,
|
|
175
|
+
now: float | None = None,
|
|
176
|
+
online_threshold: float = DEFAULT_ONLINE_THRESHOLD,
|
|
177
|
+
) -> list[DetectedDevice]:
|
|
178
|
+
"""Parse the device list envelope.
|
|
179
|
+
|
|
180
|
+
``now`` and ``online_threshold`` only affect the freshness-based
|
|
181
|
+
fallback used when an entry carries no ``is_online`` field. ``now``
|
|
182
|
+
defaults to the current wall clock; pass it explicitly in tests.
|
|
183
|
+
"""
|
|
184
|
+
results = raw.get("results", [])
|
|
185
|
+
if not isinstance(results, list):
|
|
186
|
+
return []
|
|
187
|
+
reference_now = time.time() if now is None else now
|
|
188
|
+
return [
|
|
189
|
+
cls._from_entry(e, now=reference_now, online_threshold=online_threshold)
|
|
190
|
+
for e in results
|
|
191
|
+
if isinstance(e, dict)
|
|
192
|
+
]
|
aiofortiosapi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aiofortiosapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async FortiOS REST API client for Home Assistant
|
|
5
|
+
Project-URL: Homepage, https://github.com/kimfrellsen/aiofortiosapi
|
|
6
|
+
Project-URL: Repository, https://github.com/kimfrellsen/aiofortiosapi
|
|
7
|
+
Project-URL: Changelog, https://github.com/kimfrellsen/aiofortiosapi/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/kimfrellsen/aiofortiosapi/issues
|
|
9
|
+
Author-email: Kim <kim@frellsen.se>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api-client,asyncio,fortigate,fortinet,fortios,home-assistant
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: aiohttp>=3.9
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: aresponses>=3; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest-cov>=5; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# aiofortiosapi
|
|
32
|
+
|
|
33
|
+
Async Python client for the FortiOS REST API, built for the [Home Assistant](https://www.home-assistant.io/) `fortios` integration.
|
|
34
|
+
|
|
35
|
+
> **Community project** — not affiliated with or supported by Fortinet TAC.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install aiofortiosapi
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
The library requires an injected `aiohttp.ClientSession` — Home Assistant provides one via
|
|
46
|
+
`async_get_clientsession(hass)`. You own the session lifecycle; this library never creates or closes it.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
import aiohttp
|
|
51
|
+
from aiofortiosapi import FortiOSClient, FortiOSAuthenticationError, FortiOSConnectionError
|
|
52
|
+
|
|
53
|
+
async def main() -> None:
|
|
54
|
+
session = aiohttp.ClientSession()
|
|
55
|
+
try:
|
|
56
|
+
client = FortiOSClient(
|
|
57
|
+
host="192.168.1.1",
|
|
58
|
+
token="your-rest-api-token",
|
|
59
|
+
session=session,
|
|
60
|
+
verify_ssl=False, # set True in production with a valid cert
|
|
61
|
+
)
|
|
62
|
+
status = await client.get_system_status()
|
|
63
|
+
print(status.hostname, status.version)
|
|
64
|
+
|
|
65
|
+
usage = await client.get_resource_usage()
|
|
66
|
+
print(f"CPU {usage.cpu_percent}% MEM {usage.memory_percent}%")
|
|
67
|
+
|
|
68
|
+
devices = await client.get_detected_devices()
|
|
69
|
+
for d in devices:
|
|
70
|
+
print(d.mac, d.hostname, d.ip, "online" if d.is_online else "offline")
|
|
71
|
+
except FortiOSAuthenticationError:
|
|
72
|
+
print("Bad token — re-enter credentials")
|
|
73
|
+
except FortiOSConnectionError:
|
|
74
|
+
print("Cannot reach the FortiGate — check host/port")
|
|
75
|
+
finally:
|
|
76
|
+
await session.close()
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Generating a FortiOS REST API token
|
|
82
|
+
|
|
83
|
+
1. In the FortiGate GUI go to **System → Administrators → Create New → REST API Admin**.
|
|
84
|
+
2. Set a **Trusted Host** (the IP of your Home Assistant instance) to restrict token use.
|
|
85
|
+
3. Assign a read-only profile (`prof_admin` or custom).
|
|
86
|
+
4. Copy the generated token — it is shown only once.
|
|
87
|
+
|
|
88
|
+
The library uses `Authorization: Bearer <token>` (not the legacy `?access_token=` query string).
|
|
89
|
+
|
|
90
|
+
### Device online state
|
|
91
|
+
|
|
92
|
+
`DetectedDevice.is_online` uses the flag reported by FortiOS. On firmware that
|
|
93
|
+
omits the field, it falls back to deriving online state from `last_seen`
|
|
94
|
+
freshness: a device counts as online when seen within
|
|
95
|
+
`DEFAULT_ONLINE_THRESHOLD` seconds (300). Tune the fallback per client:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
client = FortiOSClient(..., device_online_threshold=600)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Scope
|
|
102
|
+
|
|
103
|
+
`aiofortiosapi` is intentionally minimal:
|
|
104
|
+
|
|
105
|
+
- **Three typed monitor endpoints** (`get_system_status`, `get_resource_usage`,
|
|
106
|
+
`get_detected_devices`) used by the Home Assistant integration.
|
|
107
|
+
- A generic `get(path)` for any other endpoint that returns the raw JSON envelope.
|
|
108
|
+
- **No** config-write, CMDB, file upload, SSH fallback, or CLI helpers.
|
|
109
|
+
- **No** session/cookie login flow — Bearer token only.
|
|
110
|
+
|
|
111
|
+
This keeps the dependency tree small (runtime dep: `aiohttp` only) and passes HA integration
|
|
112
|
+
quality review requirements.
|
|
113
|
+
|
|
114
|
+
## Exception hierarchy
|
|
115
|
+
|
|
116
|
+
| Exception | When raised | HA mapping |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `FortiOSConnectionError` | Transport/timeout failure | `ConfigEntryNotReady` |
|
|
119
|
+
| `FortiOSAuthenticationError` | HTTP 401 / 403 | `ConfigEntryAuthFailed` |
|
|
120
|
+
| `FortiOSNotFoundError` | HTTP 404 | log / inspect |
|
|
121
|
+
| `FortiOSResponseError` | Bad JSON, 5xx, other 4xx | log / raise |
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
aiofortiosapi/__init__.py,sha256=OVDrncRnDTp8NawKHHbgLMndKzOatMvgWJPzNMsD7JE,734
|
|
2
|
+
aiofortiosapi/client.py,sha256=D-Dv4PWwoqAedc2W2s-ZSxwXkwgDoHIJL69vZSILCrg,6954
|
|
3
|
+
aiofortiosapi/const.py,sha256=RH-WBpYQaRNr6U5EvqRCtBoLQ2IkdUMp1pLR_jNjUGs,1205
|
|
4
|
+
aiofortiosapi/exceptions.py,sha256=26VmAw_n3I3Cr4eeCDA9zwGS9oLJeyt1XmDtDfWSMqE,1009
|
|
5
|
+
aiofortiosapi/models.py,sha256=HTy3zpGDXfsbWc3M8mJ06uo6zmFdS9tHExVeZQvLNfY,6534
|
|
6
|
+
aiofortiosapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
aiofortiosapi-0.1.0.dist-info/METADATA,sha256=c0NaGu3jINvsjxI2rIojOgj-tQZyDeuWjjWGDhSFoLg,4557
|
|
8
|
+
aiofortiosapi-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
aiofortiosapi-0.1.0.dist-info/licenses/LICENSE,sha256=5Kxo88fmjRi-gGYqqoUnD2FTR1IfW7lmpyWiVi-GTmw,1078
|
|
10
|
+
aiofortiosapi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kim <kim@frellsen.se>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|