Skip to content

Commit 36c43be

Browse files
committed
Initial commit
0 parents  commit 36c43be

File tree

10 files changed

+825
-0
lines changed

10 files changed

+825
-0
lines changed

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
venv
2+
__pycache__
3+
*.pyc
4+
.*.swp
5+
/*.egg_info
6+
/build/
7+
tests/__pycache__
8+
.mypy_cache
9+
.vscode
10+
.coverage
11+
tmp

.pre-commit-config.yaml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
default_language_version:
2+
# force all unspecified python hooks to run python3
3+
python: python3.13
4+
5+
repos:
6+
# Run manually in CI skipping the branch checks
7+
- repo: https://github.com/astral-sh/ruff-pre-commit
8+
rev: v0.12.1
9+
hooks:
10+
- id: ruff
11+
name: "Ruff check"
12+
args:
13+
- --fix
14+
- id: ruff-format
15+
name: "Ruff format"
16+
- repo: https://github.com/pre-commit/pre-commit-hooks
17+
rev: v5.0.0
18+
hooks:
19+
- id: check-executables-have-shebangs
20+
name: "Check scripts"
21+
stages: [manual]
22+
- id: no-commit-to-branch
23+
name: "Check branch"
24+
args:
25+
- --branch=main
26+
- repo: https://github.com/asottile/pyupgrade
27+
rev: v3.20.0
28+
hooks:
29+
- id: pyupgrade
30+
name: "Check Py upgrade"
31+
args: [--py311-plus]
32+
- repo: https://github.com/codespell-project/codespell
33+
rev: v2.4.1
34+
hooks:
35+
- id: codespell
36+
name: "Check Code Spelling"
37+
args:
38+
- --ignore-words-list=aiport,astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn
39+
- --skip="./.*,*.csv,*.json,*.ambr"
40+
- --quiet-level=2
41+
exclude_types: [csv, json]
42+
exclude: ^userdata/|^fixtures/
43+
- repo: https://github.com/PyCQA/bandit
44+
rev: 1.8.5
45+
hooks:
46+
- id: bandit
47+
name: "Bandit checking"
48+
args:
49+
- --quiet
50+
- --format=custom
51+
- --configfile=tests/bandit.yaml
52+
files: ^(airos|tests)/.+\.py$
53+
- repo: https://github.com/adrienverge/yamllint.git
54+
rev: v1.37.1
55+
hooks:
56+
- id: yamllint
57+
name: "YAML linting"
58+
- repo: https://github.com/shellcheck-py/shellcheck-py
59+
rev: v0.10.0.1
60+
hooks:
61+
- id: shellcheck
62+
name: "Shell checking"
63+
args:
64+
- --external-sources
65+
- repo: https://github.com/cdce8p/python-typing-update
66+
rev: v0.7.2
67+
hooks:
68+
# Run `python-typing-update` hook manually from time to time
69+
# to update python typing syntax.
70+
# Will require manual work, before submitting changes!
71+
- id: python-typing-update
72+
name: "Python typing"
73+
stages: [manual]
74+
args:
75+
- --py39-plus
76+
- --force
77+
- --keep-updates
78+
files: ^(airos|tests)/.+\.py$
79+
- repo: https://github.com/igorshubovych/markdownlint-cli
80+
rev: v0.45.0
81+
hooks:
82+
- id: markdownlint

airos/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Ubiquity AirOS python module."""
2+
3+

airos/airos8.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Ubiquiti AirOS 8 module for Home Assistant Core."""
2+
3+
from __future__ import annotations
4+
5+
from typing import cast
6+
7+
import asyncio
8+
import json
9+
import logging
10+
import ssl
11+
12+
import aiohttp
13+
14+
from .exceptions import ConnectionFailedError, DataMissingError
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class AirOS8:
20+
"""Set up connection to AirOS."""
21+
22+
def __init__(self, host: str, username: str, password: str): -> None
23+
"""Initialize AirOS8 class."""
24+
self.username = username
25+
self.password = password
26+
self.base_url = f"https://{host}"
27+
28+
self._login_url = f"{self.base_url}/api/auth" # AirOS 8
29+
self._status_cgi_url = f"{self.base_url}/status.cgi" # AirOS 8
30+
31+
self._use_json_for_login_post = False
32+
33+
self._common_headers = {
34+
"Accept": "application/json, text/javascript, */*; q=0.01",
35+
"Sec-Fetch-Site": "same-origin",
36+
"Accept-Language": "en-US,nl;q=0.9",
37+
"Accept-Encoding": "gzip, deflate, br",
38+
"Sec-Fetch-Mode": "cors",
39+
"Origin": self.base_url,
40+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15",
41+
"Referer": self.base_url + "/",
42+
"Connection": "keep-alive",
43+
"Sec-Fetch-Dest": "empty",
44+
"X-Requested-With": "XMLHttpRequest",
45+
}
46+
47+
async def login(self): -> bool
48+
"""Log in to the device assuring cookies and tokens set correctly."""
49+
loop = asyncio.get_running_loop()
50+
ssl_context = await loop.run_in_executor(
51+
None,
52+
ssl.create_default_context
53+
)
54+
ssl_context.check_hostname = False
55+
ssl_context.verify_mode = ssl.CERT_NONE
56+
connector = aiohttp.TCPConnector(ssl=ssl_context)
57+
58+
async with aiohttp.ClientSession(connector=connector) as self.session:
59+
current_csrf_token = None
60+
61+
# --- Step 0: Pre-inject the 'ok=1' cookie before login POST (mimics curl) ---
62+
self.session.cookie_jar.update_cookies({"ok": "1"})
63+
64+
# --- Step 1: Attempt Login to /api/auth (This now sets all session cookies and the CSRF token) ---
65+
login_payload = {
66+
"username": self.username,
67+
"password": self.password,
68+
}
69+
70+
login_request_headers = {**self._common_headers}
71+
72+
post_data = None
73+
if self._use_json_for_login_post:
74+
login_request_headers["Content-Type"] = "application/json"
75+
post_data = json.dumps(login_payload)
76+
else:
77+
login_request_headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
78+
post_data = login_payload
79+
80+
try:
81+
async with self.session.post(self._login_url, data=post_data, headers=login_request_headers) as response:
82+
if not response.cookies:
83+
logger.exception("Empty cookies after login, bailing out.")
84+
raise DataMissingError
85+
else:
86+
for _, morsel in response.cookies.items():
87+
88+
# If the AIROS_ cookie was parsed but isn't automatically added to the jar, add it manually
89+
if morsel.key.startswith("AIROS_") and morsel.key not in self.session.cookie_jar:
90+
# `SimpleCookie`'s Morsel objects are designed to be compatible with cookie jars.
91+
# We need to set the domain if it's missing, otherwise the cookie might not be sent.
92+
# For IP addresses, the domain is typically blank.
93+
# aiohttp's jar should handle it, but for explicit control:
94+
if not morsel.get("domain"):
95+
morsel["domain"] = response.url.host # Set to the host that issued it
96+
self.session.cookie_jar.update_cookies({morsel.key: morsel.output(header="")[len(morsel.key)+1:].split(";")[0].strip()}, response.url)
97+
# The update_cookies method can take a SimpleCookie morsel directly or a dict.
98+
# The morsel.output method gives 'NAME=VALUE; Path=...; HttpOnly'
99+
# We just need 'NAME=VALUE' or the morsel object itself.
100+
# Let's use the morsel directly which is more robust.
101+
# Alternatively: self.session.cookie_jar.update_cookies({morsel.key: morsel.value}) might work if it's simpler.
102+
# Aiohttp's update_cookies takes a dict mapping name to value.
103+
# To pass the full morsel with its attributes, we need to add it to the jar's internal structure.
104+
# Simpler: just ensure the key-value pair is there for simple jar.
105+
106+
# Let's try the direct update of the key-value
107+
self.session.cookie_jar.update_cookies({morsel.key: morsel.value})
108+
109+
new_csrf_token = response.headers.get("X-CSRF-ID")
110+
if new_csrf_token:
111+
current_csrf_token = new_csrf_token
112+
else:
113+
return
114+
115+
# Re-check cookies in self.session.cookie_jar AFTER potential manual injection
116+
airos_cookie_found = False
117+
ok_cookie_found = False
118+
if not self.session.cookie_jar:
119+
logger.exception("COOKIE JAR IS EMPTY after login POST. This is a major issue.")
120+
raise DataMissingError
121+
for cookie in self.session.cookie_jar:
122+
if cookie.key.startswith("AIROS_"):
123+
airos_cookie_found = True
124+
if cookie.key == "ok":
125+
ok_cookie_found = True
126+
127+
if not airos_cookie_found and not ok_cookie_found:
128+
raise DataMissingError
129+
130+
response_text = await response.text()
131+
132+
if response.status == 200:
133+
try:
134+
json.loads(response_text)
135+
return True
136+
except json.JSONDecodeError:
137+
logger.exception("JSON Decode Error")
138+
raise DataMissingError
139+
140+
else:
141+
log = f"Login failed with status {response.status}. Full Response: {response.text}"
142+
logger.error(log)
143+
raise ConnectionFailedError
144+
except aiohttp.ClientError:
145+
logger.exception("Error during login")
146+
raise ConnectionFailedError
147+
148+
async def status(self): -> dict
149+
"""Retrieve status from the device."""
150+
# --- Step 2: Verify authenticated access by fetching status.cgi ---
151+
authenticated_get_headers = {**self._common_headers}
152+
if current_csrf_token:
153+
authenticated_get_headers["X-CSRF-ID"] = current_csrf_token
154+
155+
try:
156+
async with self.session.get(self._status_cgi_url, headers=authenticated_get_headers) as response:
157+
status_response_text = await response.text()
158+
159+
if response.status == 200:
160+
try:
161+
return json.loads(status_response_text)
162+
except json.JSONDecodeError:
163+
logger.exception("JSON Decode Error in authenticated status response")
164+
raise DataMissingError
165+
else:
166+
log = f"Authenticated status.cgi failed: {response.status}. Response: {status_response_text}"
167+
logger.error(log)
168+
except aiohttp.ClientError:
169+
logger.exception("Error during authenticated status.cgi call")
170+
raise ConnectionFailedError
171+

airos/exceptions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Ubiquiti AirOS Exceptions."""
2+
3+
4+
class AirOSException(Exception):
5+
"""Base error class for this AirOS library."""
6+
7+
8+
class ConnectionFailedError(AirOSException):
9+
"""Raised when unable to connect."""
10+
11+
12+
class DataMissingError(AirOSException):
13+
"""Raised when expected data is missing."""

airos/py.typed

Whitespace-only changes.

fixtures/ap-ptp.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"chain_names":[{"number":1,"name": "Chain 0"},{"number":2,"name": "Chain 1"}],"host":{"hostname": "NanoStation 5AC ap name ","device_id": "03aa0d0b40fed0a47088293584ef5432","uptime":264888,"power_time":268683,"time": "2025-06-23 23:06:42","timestamp":2668313184,"fwversion": "v8.7.17","devmodel": "NanoStation 5AC loco","netrole": "bridge","loadavg":0.412598,"totalram":63447040,"freeram":16564224,"temperature":0,"cpuload":10.101010,"height":3},"genuine": "/images/genuine.png","services":{"dhcpc":false,"dhcpd":false,"dhcp6d_stateful":false,"pppoe":false,"airview":2},"firewall":{"iptables":false,"ebtables":false,"ip6tables":false,"eb6tables":false},"portfw":false,"wireless":{"essid": "DemoSSID","mode": "ap-ptp","ieeemode": "11ACVHT80","band":2,"compat_11n":0,"hide_essid":0,"apmac":"01:23:45:67:89:AB","antenna_gain":13,"frequency":5500,"center1_freq":5530,"dfs":1,"distance":0,"security": "WPA2","noisef":-89,"txpower":-3,"aprepeater":false,"rstatus":5,"chanbw":80,"rx_chainmask":3,"tx_chainmask":3,"nol_state":0,"nol_timeout":0,"cac_state":0,"cac_timeout":0,"rx_idx":8,"rx_nss":2,"tx_idx":9,"tx_nss":2,"throughput":{"tx":222,"rx":9907},"service":{"time":267181,"link":266003},"polling":{"cb_capacity":593970,"dl_capacity":647400,"ul_capacity":540540,"use":48,"tx_use":6,"rx_use":42,"atpc_status":2,"fixed_frame":false,"gps_sync":false,"ff_cap_rep":false},"count":1,"sta":[{"mac":"01:23:45:67:89:AB","lastip":"192.168.1.2","signal":-59,"rssi":37,"noisefloor":-89,"chainrssi":[35,32,0],"tx_idx":9,"rx_idx":8,"tx_nss":2,"rx_nss":2,"tx_latency":0,"distance":1,"tx_packets":0,"tx_lretries":0,"tx_sretries":0,"uptime":170281,"dl_signal_expect":-80,"ul_signal_expect":-55,"cb_capacity_expect":416000,"dl_capacity_expect":208000,"ul_capacity_expect":624000,"dl_rate_expect":3,"ul_rate_expect":8,"dl_linkscore":100,"ul_linkscore":86,"dl_avg_linkscore":100,"ul_avg_linkscore":88,"tx_ratedata":[175,4,47,200,673,158,163,138,68895,19577430],"stats":{"rx_bytes":206938324814,"rx_packets":149767200,"rx_pps":846,"tx_bytes":5265602739,"tx_packets":52980390,"tx_pps":0},"airmax":{"actual_priority":0,"beam":0,"desired_priority":0,"cb_capacity":593970,"dl_capacity":647400,"ul_capacity":540540,"atpc_status":2,"rx":{"usage":42,"cinr":31,"evm":[[31,28,33,32,32,32,31,31,31,29,30,32,30,27,34,31,31,30,32,29,31,29,31,33,31,31,32,30,31,34,33,31,30,31,30,31,31,32,31,30,33,31,30,31,27,31,30,30,30,30,30,29,32,34,31,30,28,30,29,35,31,33,32,29],[34,34,35,34,35,35,34,34,34,34,34,34,34,34,35,35,34,34,35,34,33,33,35,34,34,35,34,35,34,34,35,34,34,33,34,34,34,34,34,35,35,35,34,35,33,34,34,34,34,35,35,34,34,34,34,34,34,34,34,34,34,34,35,35]]},"tx":{"usage":6,"cinr":31,"evm":[[32,34,28,33,35,30,31,33,30,30,32,30,29,33,31,29,33,31,31,30,33,34,33,31,33,32,32,31,29,31,30,32,31,30,29,32,31,32,31,31,32,29,31,29,30,32,32,31,32,32,33,31,28,29,31,31,33,32,33,32,32,32,31,33],[37,37,37,38,38,37,36,38,38,37,37,37,37,37,39,37,37,37,37,37,37,36,37,37,37,37,37,37,37,38,37,37,38,37,37,37,38,37,38,37,37,37,37,37,36,37,37,37,37,37,37,38,37,37,38,37,36,37,37,37,37,37,37,37]]}},"last_disc":1,"remote":{"age":1,"device_id": "d4f4cdf82961e619328a8f72f8d7653b","hostname": "NanoStation 5AC sta name","platform": "NanoStation 5AC loco","version": "WA.ar934x.v8.7.17.48152.250620.2132","time": "2025-06-23 23:13:54","cpuload":43.564301,"temperature":0,"totalram":63447040,"freeram":14290944,"netrole": "bridge","mode": "sta-ptp","sys_id":"0xe7fa","tx_throughput":16023,"rx_throughput":251,"uptime":265320,"power_time":268512,"compat_11n":0,"signal":-58,"rssi":38,"noisefloor":-90,"tx_power":-4,"distance":1,"rx_chainmask":3,"chainrssi":[33,37,0],"tx_ratedata":[14,4,372,2223,4708,4037,8142,485763,29420892,24748154],"tx_bytes":212308148210,"rx_bytes":3624206478,"antenna_gain":13,"cable_loss":0,"height":2,"ethlist":[{"ifname": "eth0","enabled":true,"plugged":true,"duplex":true,"speed":1000,"snr":[30,30,29,30],"cable_len":14}],"ipaddr":["192.168.1.2"],"ip6addr":["fe80::eea:14ff:fea4:806"],"gps":{"lat": "52.020100","lon": "5.071400","fix":0},"oob":false,"unms":{"status":0},"airview":2,"service":{"time":267195,"link":265996}}}],"sta_disconnected":[]},"interfaces":[{"ifname": "eth0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":209900085624,"rx_bytes":3984971949,"tx_packets":185866883,"rx_packets":73564835,"tx_errors":0,"rx_errors":4,"tx_dropped":10,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":1000,"duplex":true,"snr":[30,30,30,30],"cable_len":18}},{"ifname": "ath0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":false,"tx_bytes":5265602738,"rx_bytes":206938324766,"tx_packets":52980390,"rx_packets":149767200,"tx_errors":0,"rx_errors":0,"tx_dropped":2005,"rx_dropped":0,"ipaddr":"0.0.0.0","speed":0,"duplex":false}},{"ifname": "br0","hwaddr":"01:23:45:67:89:AB","enabled":true,"mtu":1500,"status":{"plugged":true,"tx_bytes":236295176,"rx_bytes":204802727,"tx_packets":298119,"rx_packets":1791592,"tx_errors":0,"rx_errors":0,"tx_dropped":0,"rx_dropped":0,"ipaddr":"192.168.1.2","ip6addr":[{"addr":"fe80::eea:14ff:fea4:7b8","plen":64}],"speed":0,"duplex":false}}],"provmode":{},"ntpclient":{},"unms":{"status":0},"gps":{"lat":52.222651,"lon":4.532288,"fix":0}}

0 commit comments

Comments
 (0)