22
33from __future__ import annotations
44
5- import asyncio
65import json
76import logging
8- import ssl
97
108import aiohttp
119
1715class AirOS8 :
1816 """Set up connection to AirOS."""
1917
20- def __init__ (self , host : str , username : str , password : str ):
18+ def __init__ (
19+ self ,
20+ host : str ,
21+ username : str ,
22+ password : str ,
23+ session : aiohttp .ClientSession ,
24+ verify_ssl : bool = True ,
25+ ):
2126 """Initialize AirOS8 class."""
2227 self .username = username
2328 self .password = password
2429 self .base_url = f"https://{ host } "
2530
31+ self .session = session
32+ self .verify_ssl = verify_ssl
33+
2634 self ._login_url = f"{ self .base_url } /api/auth" # AirOS 8
2735 self ._status_cgi_url = f"{ self .base_url } /status.cgi" # AirOS 8
2836 self .current_csrf_token = None
@@ -45,120 +53,116 @@ def __init__(self, host: str, username: str, password: str):
4553
4654 async def login (self ) -> bool :
4755 """Log in to the device assuring cookies and tokens set correctly."""
48- loop = asyncio .get_running_loop ()
49- ssl_context = await loop .run_in_executor (None , ssl .create_default_context )
50- ssl_context .check_hostname = False
51- ssl_context .verify_mode = ssl .CERT_NONE
52- connector = aiohttp .TCPConnector (ssl = ssl_context )
53-
54- async with aiohttp .ClientSession (connector = connector ) as self .session :
55- # --- Step 0: Pre-inject the 'ok=1' cookie before login POST (mimics curl) ---
56- self .session .cookie_jar .update_cookies ({"ok" : "1" })
57-
58- # --- Step 1: Attempt Login to /api/auth (This now sets all session cookies and the CSRF token) ---
59- login_payload = {
60- "username" : self .username ,
61- "password" : self .password ,
62- }
63-
64- login_request_headers = {** self ._common_headers }
65-
66- post_data = None
67- if self ._use_json_for_login_post :
68- login_request_headers ["Content-Type" ] = "application/json"
69- post_data = json .dumps (login_payload )
70- else :
71- login_request_headers ["Content-Type" ] = (
72- "application/x-www-form-urlencoded; charset=UTF-8"
73- )
74- post_data = login_payload
75-
76- try :
77- async with self .session .post (
78- self ._login_url , data = post_data , headers = login_request_headers
79- ) as response :
80- if not response .cookies :
81- logger .exception ("Empty cookies after login, bailing out." )
82- raise DataMissingError from None
83- else :
84- for _ , morsel in response .cookies .items ():
85- # If the AIROS_ cookie was parsed but isn't automatically added to the jar, add it manually
86- if (
87- morsel .key .startswith ("AIROS_" )
88- and morsel .key not in self .session .cookie_jar
89- ):
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" ] = (
96- response .url .host
97- ) # Set to the host that issued it
98- self .session .cookie_jar .update_cookies (
99- {
100- morsel .key : morsel .output (header = "" )[
101- len (morsel .key ) + 1 :
102- ]
103- .split (";" )[0 ]
104- .strip ()
105- },
106- response .url ,
107- )
108- # The update_cookies method can take a SimpleCookie morsel directly or a dict.
109- # The morsel.output method gives 'NAME=VALUE; Path=...; HttpOnly'
110- # We just need 'NAME=VALUE' or the morsel object itself.
111- # Let's use the morsel directly which is more robust.
112- # Alternatively: self.session.cookie_jar.update_cookies({morsel.key: morsel.value}) might work if it's simpler.
113- # Aiohttp's update_cookies takes a dict mapping name to value.
114- # To pass the full morsel with its attributes, we need to add it to the jar's internal structure.
115- # Simpler: just ensure the key-value pair is there for simple jar.
116-
117- # Let's try the direct update of the key-value
118- self .session .cookie_jar .update_cookies (
119- {morsel .key : morsel .value }
120- )
121-
122- new_csrf_token = response .headers .get ("X-CSRF-ID" )
123- if new_csrf_token :
124- self .current_csrf_token = new_csrf_token
125- else :
126- return
127-
128- # Re-check cookies in self.session.cookie_jar AFTER potential manual injection
129- airos_cookie_found = False
130- ok_cookie_found = False
131- if not self .session .cookie_jar :
132- logger .exception (
133- "COOKIE JAR IS EMPTY after login POST. This is a major issue."
134- )
135- raise DataMissingError from None
136- for cookie in self .session .cookie_jar :
137- if cookie .key .startswith ("AIROS_" ):
138- airos_cookie_found = True
139- if cookie .key == "ok" :
140- ok_cookie_found = True
56+ # --- Step 0: Pre-inject the 'ok=1' cookie before login POST (mimics curl) ---
57+ self .session .cookie_jar .update_cookies ({"ok" : "1" })
14158
142- if not airos_cookie_found and not ok_cookie_found :
143- raise DataMissingError from None
59+ # --- Step 1: Attempt Login to /api/auth (This now sets all session cookies and the CSRF token) ---
60+ login_payload = {
61+ "username" : self .username ,
62+ "password" : self .password ,
63+ }
14464
145- response_text = await response . text ()
65+ login_request_headers = { ** self . _common_headers }
14666
147- if response .status == 200 :
148- try :
149- json .loads (response_text )
150- return True
151- except json .JSONDecodeError as err :
152- logger .exception ("JSON Decode Error" )
153- raise DataMissingError from err
67+ post_data = None
68+ if self ._use_json_for_login_post :
69+ login_request_headers ["Content-Type" ] = "application/json"
70+ post_data = json .dumps (login_payload )
71+ else :
72+ login_request_headers ["Content-Type" ] = (
73+ "application/x-www-form-urlencoded; charset=UTF-8"
74+ )
75+ post_data = login_payload
15476
155- else :
156- log = f"Login failed with status { response .status } . Full Response: { response .text } "
157- logger .error (log )
158- raise ConnectionFailedError from None
159- except aiohttp .ClientError as err :
160- logger .exception ("Error during login" )
161- raise ConnectionFailedError from err
77+ try :
78+ async with self .session .post (
79+ self ._login_url ,
80+ data = post_data ,
81+ headers = login_request_headers ,
82+ ssl = self .verify_ssl ,
83+ ) as response :
84+ if not response .cookies :
85+ logger .exception ("Empty cookies after login, bailing out." )
86+ raise DataMissingError from None
87+ else :
88+ for _ , morsel in response .cookies .items ():
89+ # If the AIROS_ cookie was parsed but isn't automatically added to the jar, add it manually
90+ if (
91+ morsel .key .startswith ("AIROS_" )
92+ and morsel .key not in self .session .cookie_jar
93+ ):
94+ # `SimpleCookie`'s Morsel objects are designed to be compatible with cookie jars.
95+ # We need to set the domain if it's missing, otherwise the cookie might not be sent.
96+ # For IP addresses, the domain is typically blank.
97+ # aiohttp's jar should handle it, but for explicit control:
98+ if not morsel .get ("domain" ):
99+ morsel ["domain" ] = (
100+ response .url .host
101+ ) # Set to the host that issued it
102+ self .session .cookie_jar .update_cookies (
103+ {
104+ morsel .key : morsel .output (header = "" )[
105+ len (morsel .key ) + 1 :
106+ ]
107+ .split (";" )[0 ]
108+ .strip ()
109+ },
110+ response .url ,
111+ )
112+ # The update_cookies method can take a SimpleCookie morsel directly or a dict.
113+ # The morsel.output method gives 'NAME=VALUE; Path=...; HttpOnly'
114+ # We just need 'NAME=VALUE' or the morsel object itself.
115+ # Let's use the morsel directly which is more robust.
116+ # Alternatively: self.session.cookie_jar.update_cookies({morsel.key: morsel.value}) might work if it's simpler.
117+ # Aiohttp's update_cookies takes a dict mapping name to value.
118+ # To pass the full morsel with its attributes, we need to add it to the jar's internal structure.
119+ # Simpler: just ensure the key-value pair is there for simple jar.
120+
121+ # Let's try the direct update of the key-value
122+ self .session .cookie_jar .update_cookies (
123+ {morsel .key : morsel .value }
124+ )
125+
126+ new_csrf_token = response .headers .get ("X-CSRF-ID" )
127+ if new_csrf_token :
128+ self .current_csrf_token = new_csrf_token
129+ else :
130+ return
131+
132+ # Re-check cookies in self.session.cookie_jar AFTER potential manual injection
133+ airos_cookie_found = False
134+ ok_cookie_found = False
135+ if not self .session .cookie_jar :
136+ logger .exception (
137+ "COOKIE JAR IS EMPTY after login POST. This is a major issue."
138+ )
139+ raise DataMissingError from None
140+ for cookie in self .session .cookie_jar :
141+ if cookie .key .startswith ("AIROS_" ):
142+ airos_cookie_found = True
143+ if cookie .key == "ok" :
144+ ok_cookie_found = True
145+
146+ if not airos_cookie_found and not ok_cookie_found :
147+ raise DataMissingError from None
148+
149+ response_text = await response .text ()
150+
151+ if response .status == 200 :
152+ try :
153+ json .loads (response_text )
154+ return True
155+ except json .JSONDecodeError as err :
156+ logger .exception ("JSON Decode Error" )
157+ raise DataMissingError from err
158+
159+ else :
160+ log = f"Login failed with status { response .status } . Full Response: { response .text } "
161+ logger .error (log )
162+ raise ConnectionFailedError from None
163+ except aiohttp .ClientError as err :
164+ logger .exception ("Error during login" )
165+ raise ConnectionFailedError from err
162166
163167 async def status (self ) -> dict :
164168 """Retrieve status from the device."""
@@ -169,7 +173,9 @@ async def status(self) -> dict:
169173
170174 try :
171175 async with self .session .get (
172- self ._status_cgi_url , headers = authenticated_get_headers
176+ self ._status_cgi_url ,
177+ headers = authenticated_get_headers ,
178+ ssl = self .verify_ssl ,
173179 ) as response :
174180 status_response_text = await response .text ()
175181
0 commit comments