1212
1313from .data import AirOS8Data as AirOSData
1414from .exceptions import (
15- ConnectionAuthenticationError ,
16- ConnectionSetupError ,
17- DataMissingError ,
18- DeviceConnectionError ,
19- KeyDataMissingError ,
15+ AirOSConnectionAuthenticationError ,
16+ AirOSConnectionSetupError ,
17+ AirOSDataMissingError ,
18+ AirOSDeviceConnectionError ,
19+ AirOSKeyDataMissingError ,
2020)
2121
2222_LOGGER = logging .getLogger (__name__ )
@@ -52,6 +52,7 @@ def __init__(
5252 self ._login_url = f"{ self .base_url } /api/auth" # AirOS 8
5353 self ._status_cgi_url = f"{ self .base_url } /status.cgi" # AirOS 8
5454 self ._stakick_cgi_url = f"{ self .base_url } /stakick.cgi" # AirOS 8
55+ self ._provmode_url = f"{ self .base_url } /api/provmode" # AirOS 8
5556 self .current_csrf_token = None
5657
5758 self ._use_json_for_login_post = False
@@ -103,10 +104,10 @@ async def login(self) -> bool:
103104 ) as response :
104105 if response .status == 403 :
105106 _LOGGER .error ("Authentication denied." )
106- raise ConnectionAuthenticationError from None
107+ raise AirOSConnectionAuthenticationError from None
107108 if not response .cookies :
108109 _LOGGER .exception ("Empty cookies after login, bailing out." )
109- raise ConnectionSetupError from None
110+ raise AirOSConnectionSetupError from None
110111 else :
111112 for _ , morsel in response .cookies .items ():
112113 # If the AIROS_ cookie was parsed but isn't automatically added to the jar, add it manually
@@ -159,15 +160,15 @@ async def login(self) -> bool:
159160 _LOGGER .exception (
160161 "COOKIE JAR IS EMPTY after login POST. This is a major issue."
161162 )
162- raise ConnectionSetupError from None
163+ raise AirOSConnectionSetupError from None
163164 for cookie in self .session .cookie_jar : # pragma: no cover
164165 if cookie .key .startswith ("AIROS_" ):
165166 airos_cookie_found = True
166167 if cookie .key == "ok" :
167168 ok_cookie_found = True
168169
169170 if not airos_cookie_found and not ok_cookie_found :
170- raise ConnectionSetupError from None # pragma: no cover
171+ raise AirOSConnectionSetupError from None # pragma: no cover
171172
172173 response_text = await response .text ()
173174
@@ -178,18 +179,18 @@ async def login(self) -> bool:
178179 return True
179180 except json .JSONDecodeError as err :
180181 _LOGGER .exception ("JSON Decode Error" )
181- raise DataMissingError from err
182+ raise AirOSDataMissingError from err
182183
183184 else :
184185 log = f"Login failed with status { response .status } . Full Response: { response .text } "
185186 _LOGGER .error (log )
186- raise ConnectionAuthenticationError from None
187+ raise AirOSConnectionAuthenticationError from None
187188 except (
188189 aiohttp .ClientError ,
189190 aiohttp .client_exceptions .ConnectionTimeoutError ,
190191 ) as err :
191192 _LOGGER .exception ("Error during login" )
192- raise DeviceConnectionError from err
193+ raise AirOSDeviceConnectionError from err
193194
194195 def derived_data (
195196 self , response : dict [str , Any ] | None = None
@@ -202,7 +203,7 @@ def derived_data(
202203
203204 # No interfaces, no mac, no usability
204205 if not interfaces :
205- raise KeyDataMissingError from None
206+ raise AirOSKeyDataMissingError from None
206207
207208 for interface in interfaces :
208209 if interface ["enabled" ]: # Only consider if enabled
@@ -227,7 +228,7 @@ async def status(self) -> AirOSData:
227228 """Retrieve status from the device."""
228229 if not self .connected :
229230 _LOGGER .error ("Not connected, login first" )
230- raise DeviceConnectionError from None
231+ raise AirOSDeviceConnectionError from None
231232
232233 # --- Step 2: Verify authenticated access by fetching status.cgi ---
233234 authenticated_get_headers = {** self ._common_headers }
@@ -248,14 +249,14 @@ async def status(self) -> AirOSData:
248249 airos_data = AirOSData .from_dict (adjusted_json )
249250 except (MissingField , InvalidFieldValue ) as err :
250251 _LOGGER .exception ("Failed to deserialize AirOS data" )
251- raise KeyDataMissingError from err
252+ raise AirOSKeyDataMissingError from err
252253
253254 return airos_data
254255 except json .JSONDecodeError :
255256 _LOGGER .exception (
256257 "JSON Decode Error in authenticated status response"
257258 )
258- raise DataMissingError from None
259+ raise AirOSDataMissingError from None
259260 else :
260261 log = f"Authenticated status.cgi failed: { response .status } . Response: { response_text } "
261262 _LOGGER .error (log )
@@ -264,33 +265,32 @@ async def status(self) -> AirOSData:
264265 aiohttp .client_exceptions .ConnectionTimeoutError ,
265266 ) as err :
266267 _LOGGER .exception ("Error during authenticated status.cgi call" )
267- raise DeviceConnectionError from err
268+ raise AirOSDeviceConnectionError from err
268269
269270 async def stakick (self , mac_address : str = None ) -> bool :
270271 """Reconnect client station."""
271272 if not self .connected :
272273 _LOGGER .error ("Not connected, login first" )
273- raise DeviceConnectionError from None
274+ raise AirOSDeviceConnectionError from None
274275 if not mac_address :
275276 _LOGGER .error ("Device mac-address missing" )
276- raise DataMissingError from None
277+ raise AirOSDataMissingError from None
277278
278- kick_request_headers = {** self ._common_headers }
279+ request_headers = {** self ._common_headers }
279280 if self .current_csrf_token :
280- kick_request_headers ["X-CSRF-ID" ] = self .current_csrf_token
281+ request_headers ["X-CSRF-ID" ] = self .current_csrf_token
281282
282- kick_payload = {"staif" : "ath0" , "staid" : mac_address .upper ()}
283+ payload = {"staif" : "ath0" , "staid" : mac_address .upper ()}
283284
284- kick_request_headers ["Content-Type" ] = (
285+ request_headers ["Content-Type" ] = (
285286 "application/x-www-form-urlencoded; charset=UTF-8"
286287 )
287- post_data = kick_payload
288288
289289 try :
290290 async with self .session .post (
291291 self ._stakick_cgi_url ,
292- headers = kick_request_headers ,
293- data = post_data ,
292+ headers = request_headers ,
293+ data = payload ,
294294 ) as response :
295295 if response .status == 200 :
296296 return True
@@ -302,5 +302,44 @@ async def stakick(self, mac_address: str = None) -> bool:
302302 aiohttp .ClientError ,
303303 aiohttp .client_exceptions .ConnectionTimeoutError ,
304304 ) as err :
305- _LOGGER .exception ("Error during reconnect stakick.cgi call" )
306- raise DeviceConnectionError from err
305+ _LOGGER .exception ("Error during reconnect request call" )
306+ raise AirOSDeviceConnectionError from err
307+
308+ async def provmode (self , active : bool = False ) -> bool :
309+ """Set provisioning mode."""
310+ if not self .connected :
311+ _LOGGER .error ("Not connected, login first" )
312+ raise AirOSDeviceConnectionError from None
313+
314+ request_headers = {** self ._common_headers }
315+ if self .current_csrf_token :
316+ request_headers ["X-CSRF-ID" ] = self .current_csrf_token
317+
318+ action = "stop"
319+ if active :
320+ action = "start"
321+
322+ payload = {"action" : action }
323+
324+ request_headers ["Content-Type" ] = (
325+ "application/x-www-form-urlencoded; charset=UTF-8"
326+ )
327+
328+ try :
329+ async with self .session .post (
330+ self ._provmode_url ,
331+ headers = request_headers ,
332+ data = payload ,
333+ ) as response :
334+ if response .status == 200 :
335+ return True
336+ response_text = await response .text ()
337+ log = f"Unable to change provisioning mode response status { response .status } with { response_text } "
338+ _LOGGER .error (log )
339+ return False
340+ except (
341+ aiohttp .ClientError ,
342+ aiohttp .client_exceptions .ConnectionTimeoutError ,
343+ ) as err :
344+ _LOGGER .exception ("Error during provisioning mode call" )
345+ raise AirOSDeviceConnectionError from err
0 commit comments