diff --git a/scripts/rvlink_api.py b/scripts/rvlink_api.py new file mode 100644 index 0000000..5c9bcc3 --- /dev/null +++ b/scripts/rvlink_api.py @@ -0,0 +1,652 @@ +# rvlink_api.py (Refactored for Direct API Access with Expanded GET Endpoints) + +import requests, json, re, hashlib +from datetime import datetime +from typing import Optional, List, Set, Dict, Any, Union # Keep for type hinting + +# --- Add uvicorn import for direct running (if you keep it) --- +import uvicorn +# --- + +from fastapi import FastAPI, HTTPException, status, Query +from fastapi.responses import StreamingResponse # Keep for file downloads +from pydantic import BaseModel, Field # Keep for request/response validation + +# --- Configuration --- +BASE_URL = 'http://192.168.10.254' # Roof Unit IP +MBOX_CONFIG_URL = f'{BASE_URL}/cgi-bin/mbox-config' # Primary API endpoint +SYSTEM_URL = f'{BASE_URL}/cgi-bin/system-status' # another endpoint, this one was unauthenticated from the start... + +# Constants for specific sections (can be used directly or in dedicated functions) +# These are useful for clarity if you build out more specific endpoint functions in FastAPI +GUIDE_CONFIG_SECTION = 'guide_config' +SCAN_SECTION = 'wifi_scan' +FW_VERSION_SECTION = 'firmware_version_get' +LTE_STATUS_SECTION = 'get_ec20_status_info' +HISTORY_SECTION = 'motor_home_list_get' +# REBOOT_SECTION would require specific handling for POST payload if it's system_reboot +# Example for a SET operation section (payload structure would be key) +# SYSTEM_SETTING_SECTION = 'system_setting' # Placeholder + +# Define default headers for API calls +DEFAULT_ROOF_UNIT_HEADERS = { + 'Accept': 'application/json, text/javascript, */*; q=0.01', + 'Content-Type': 'application/json', # Assuming most POSTs send JSON + 'User-Agent': 'RVLinkAPIDirectClient/2.1' # Updated User-Agent +} + +# --- Pydantic Models --- +class ApiError(BaseModel): + errCode: int + errMsg: str + configDone: Optional[bool] = None + +class RebootRequest(BaseModel): + delay: Optional[int] = Field(0, description="Delay in seconds before rebooting.") + # Add other reboot parameters if the API supports them + +class ConnectWifiRequest(BaseModel): + ifname: str = Field(default="radio0", description="Wireless interface name (e.g., radio0 for 2.4GHz STA, radio1 for 5GHz STA if available)") + ssid: str = Field(..., description="SSID of the target Wi-Fi network") + bssid: str = Field(..., description="BSSID (MAC address) of the target Wi-Fi network (e.g., 'XX:XX:XX:XX:XX:XX')") + scanned_encryption: str = Field(..., description="Encryption type as reported by Wi-Fi scan (e.g., 'none', 'WPA2 PSK', 'WEP', 'WPA PSK/WPA2 PSK')") + key: Optional[str] = Field(None, description="Password for the Wi-Fi network (if required). Provide null or omit for open networks if scanned_encryption is 'none'.") + +class ParsedLogEntry(BaseModel): + timestamp_str: str = Field(..., description="Original timestamp string from the log entry") + datetime_obj: Optional[datetime] = Field(None, description="Parsed datetime object (UTC or aware of timezone if possible)") + facility_level: str = Field(..., description="Facility and level (e.g., user.info, daemon.err)") + process_name: Optional[str] = Field(None, description="Name of the process generating the log") + pid: Optional[int] = Field(None, description="Process ID, if available") + message: str = Field(..., description="The actual log message content") + kernel_timestamp: Optional[float] = Field(None, description="Kernel-specific uptime timestamp, if applicable") + raw_log: str = Field(..., description="The original raw log line for reference") + log_hash: str = Field(..., description="SHA256 hash of the raw_log string for deduplication") # NEW FIELD + parse_error: Optional[str] = Field(None, description="Error message if parsing this specific line failed") + +class ProcessedSystemLogResponse(BaseModel): + parsed_logs: List[ParsedLogEntry] = Field(..., description="List of parsed log entries") + processing_summary: Optional[str] = Field(None, description="Summary of the log processing operation") + processing_error: Optional[str] = Field(None, description="Error message if overall processing failed") + # Include original API fields from the device response + errCode: Optional[int] = Field(None, description="Original error code from the device") + errMsg: Optional[str] = Field(None, description="Original error message from the device") + configDone: Optional[bool] = Field(None, description="Original configDone flag from the device") + +# Add other Pydantic models here if complex GET request bodies are needed for some sections +# Example: +# class GetDetailedReportRequest(BaseModel): +# report_type: str = Field(..., description="Type of report to generate") +# start_date: Optional[str] = Field(None, description="Start date for report (YYYY-MM-DD)") + +# --- FastAPI App Initialization --- +app = FastAPI( + title="RVLink Direct API Proxy", + description="Provides a direct, simplified interface to the RVLink Roof Unit (MV2400) API, bypassing login/session requirements. Blessed by the Omnissiah.", + version="2.1.0", +) + +# --- Core API Interaction Helper Functions (Refactored) --- + +def invoke_roof_unit_api( + section_name: str, + http_method: str = "POST", # Most mbox-config calls are POST + query_method: str = "GET", # method in query string, "GET" or "SET" + endpoint: Optional[str] = "mbox-config", + payload: Optional[Dict[str, Any]] = None, + is_file_download: bool = False, + expected_content_type: Optional[str] = "application/json" +) -> Union[Dict[str, Any], StreamingResponse]: + """ + Generalized helper to invoke the roof unit's mbox-config. + Handles POST requests, JSON payloads, and file downloads. + """ + query_params = {"method": query_method, "section": section_name} + if endpoint == "system": + url = SYSTEM_URL + else: + url = MBOX_CONFIG_URL + + actual_payload = payload if payload is not None else {} + + current_headers = DEFAULT_ROOF_UNIT_HEADERS.copy() + + if is_file_download: + current_headers['Accept'] = 'application/octet-stream, */*' + + # Print statements for invocation clarity, as per the original script's style + print(f"Omnissiah's Invocation: HTTP {http_method} to {url}") + print(f" Query Litanies: {query_params}") + print(f" Sacred Headers: {current_headers}") + if http_method == "POST": + print(f" JSON Datagram: {json.dumps(actual_payload)}") + + try: + if http_method == "POST": + response = requests.post( + url, + params=query_params, + json=actual_payload, + headers=current_headers, + timeout=30 if is_file_download else 10, + stream=is_file_download + ) + elif http_method == "GET": + all_query_params = {**query_params, **(payload if payload else {})} # For true GETs, payload (if any) goes into query + # Note: For 'mbox-config', actual GETs are rare; it usually expects POST even for "get" operations. + # This branch is for completeness if a section *truly* uses HTTP GET. + print(f" (Actual HTTP GET to mbox-config with all_query_params: {all_query_params})") + _headers_for_get = current_headers.copy() + if 'Content-Type' in _headers_for_get and not actual_payload : # Remove Content-Type if no body for GET + del _headers_for_get['Content-Type'] + + response = requests.get( + url, + params=all_query_params, + headers=_headers_for_get, + timeout=10, + stream=is_file_download + ) + else: + raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=f"HTTP method {http_method} not implemented for the Machine Spirit.") + + response.raise_for_status() # Raises HTTPError for 4xx/5xx responses + + if is_file_download: + original_filename = f"{section_name}.bin" + content_disposition = f'attachment; filename="{original_filename}"' + response_content_type = response.headers.get('Content-Type', 'application/octet-stream') + return StreamingResponse(response.iter_content(chunk_size=8192), + media_type=response_content_type, + headers={'Content-Disposition': content_disposition}) + + if not response.content: + return {"status": "success", "message": "Machine Spirit acknowledges. No data packet returned."} + + if expected_content_type and expected_content_type.lower() not in response.headers.get('Content-Type', '').lower(): + print(f"Warning: Unexpected Content-Type from Machine Spirit: {response.headers.get('Content-Type')}. Expected: {expected_content_type}. Raw response glimpse: {response.text[:200]}") + + try: + return response.json() + except json.JSONDecodeError: + print(f"Warning: Non-JSON response from Machine Spirit at {url} (status {response.status_code}). Glimpse: {response.text[:200]}") + return {"status": "success", "raw_response": response.text, "message": "Machine Spirit responded with non-JSON data."} + + except requests.exceptions.HTTPError as http_err: + error_detail_msg = f"Machine Spirit API error during '{section_name}' operation." + try: + error_content = http_err.response.json() + error_detail_msg = error_content.get("errMsg", json.dumps(error_content)) + except: + error_detail_msg = http_err.response.text if http_err.response.text else error_detail_msg + + print(f"HTTP error communing with {section_name}: {http_err.response.status_code} - {error_detail_msg}") + raise HTTPException(status_code=http_err.response.status_code, detail=error_detail_msg) + except requests.exceptions.RequestException as req_err: + print(f"Noospheric disturbance while calling {section_name}: {req_err}") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Roof unit communication error: {req_err}") + +def parse_individual_log_line(log_line: str) -> ParsedLogEntry: + """ + Parses a single line of a system log. + Attempts to extract timestamp, facility/level, process, PID, and message. + Includes a SHA256 hash of the raw log line. + """ + # Calculate hash of the raw log line first + hasher = hashlib.sha256() + hasher.update(log_line.encode('utf-8')) # Always encode string to bytes before hashing + log_hash_hex = hasher.hexdigest() + + entry_pattern = re.compile( + r"^(?P\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+" + r"(?P\S+?)\s+" + r"(?P.+)$" + ) + + match = entry_pattern.match(log_line) + if not match: + return ParsedLogEntry( + timestamp_str="", + facility_level="", + message=log_line, + raw_log=log_line, + log_hash=log_hash_hex, # Include hash even on parse error + parse_error="Primary regex failed to match line structure" + ) + + data = match.groupdict() + timestamp_str = data["timestamp_str"] + facility_level = data["facility_level"] + tag_and_message = data["tag_and_message"] + + dt_obj = None + try: + dt_obj = datetime.strptime(timestamp_str, "%a %b %d %H:%M:%S %Y") + except ValueError: + pass + + process_name: Optional[str] = None + pid: Optional[int] = None + message: str = "" + kernel_ts: Optional[float] = None + + tag_message_split = tag_and_message.split(":", 1) + tag_part = tag_message_split[0].strip() + message = tag_message_split[1].strip() if len(tag_message_split) > 1 else "" + + pid_match_simple = re.match(r"^(?P.+?)\[(?P\d+)\]$", tag_part) + if pid_match_simple: + process_name = pid_match_simple.group("proc_name") + pid = int(pid_match_simple.group("pid_val")) + else: + complex_tag_match = re.match(r"^(?P.+?)\s*\((?P[^)]+)\)$", tag_part) + if complex_tag_match: + potential_pid = complex_tag_match.group("pid_like") + if potential_pid.isdigit(): + pid = int(potential_pid) + process_name = complex_tag_match.group("proc_name").strip() + else: + process_name = tag_part + else: + process_name = tag_part + + if process_name == "kernel" and not message.startswith("["): + kernel_msg_match = re.match(r"\[\s*(?P\d+\.\d+)\s*\]\s*(?P.*)", message) + if kernel_msg_match: + kernel_ts = float(kernel_msg_match.group("ts")) + message = kernel_msg_match.group("msg_body").strip() + + if process_name: + process_name = process_name.rstrip(':').strip() + + return ParsedLogEntry( + timestamp_str=timestamp_str, + datetime_obj=dt_obj, + facility_level=facility_level, + process_name=process_name, + pid=pid, + message=message, + kernel_timestamp=kernel_ts, + raw_log=log_line, + log_hash=log_hash_hex # Assign the calculated hash + ) + +def process_device_system_log(raw_device_response: dict) -> ProcessedSystemLogResponse: + """ + Processes the raw JSON response containing the system log from the device. + """ + if not raw_device_response or \ + "systemlog" not in raw_device_response or \ + "systemlog" not in raw_device_response["systemlog"]: + return ProcessedSystemLogResponse( + parsed_logs=[], + processing_error="Invalid or missing systemlog data in device response", + errCode=raw_device_response.get("errCode"), + errMsg=raw_device_response.get("errMsg"), + configDone=raw_device_response.get("configDone") + ) + + log_string = raw_device_response["systemlog"]["systemlog"] + log_lines = log_string.strip().split('\n') + + parsed_entries: List[ParsedLogEntry] = [] + for line in log_lines: + if line.strip(): # Process non-empty lines + parsed_entries.append(parse_individual_log_line(line)) + + return ProcessedSystemLogResponse( + parsed_logs=parsed_entries, + processing_summary=f"Successfully parsed {len(parsed_entries)} log entries.", + errCode=raw_device_response.get("errCode"), + errMsg=raw_device_response.get("errMsg"), + configDone=raw_device_response.get("configDone") + ) + + +# --- FastAPI Endpoints (Refactored to use direct API calls) --- +@app.get("/api/rvlink/action/scan_wifi", summary="Scan for WiFi Networks", response_model=Dict[str, Any], tags=["Actions"]) +async def scan_wifi_networks(ifname: str = Query("radio0", description="Wireless interface for scanning (e.g., radio0 for 2.4GHz STA, radio1 for 5GHz STA if available)")): + """Initiates a WiFi scan on the specified interface.""" + # The wifi_scan section requires a payload: {"ifname": "radio0"} + return invoke_roof_unit_api(section_name=SCAN_SECTION, query_method="GET", payload={"ifname": ifname}) + +# --- Existing GET-like Endpoints (verified from your script) --- +@app.get("/api/rvlink/status/web_timeout_check", summary="No Idea...", response_model=Dict[str, Any], tags=["Status"]) +async def get_web_timeout_check(): + """Retrieves LTE status information (EC20/EC25 module status).""" + return invoke_roof_unit_api(section_name="web_timeout_check", query_method="GET") + +@app.get("/api/rvlink/status/internet", summary="Check internet connectivity", response_model=Dict[str, Any], tags=["Status"]) +async def get_internet_status(): + return invoke_roof_unit_api(section_name="internet_status", query_method="GET") + +@app.get("/api/rvlink/status/lte", summary="Get LTE Status", response_model=Dict[str, Any], tags=["Status"]) +async def get_lte_status(): + """Retrieves LTE status information (EC20/EC25 module status).""" + return invoke_roof_unit_api(section_name=LTE_STATUS_SECTION, query_method="GET") + +@app.get("/api/rvlink/status/firmware_info", summary="Get Firmware Version", response_model=Dict[str, Any], tags=["Status"]) +async def get_firmware_info(): + """Retrieves more detailed firmware info of the roof unit.""" + return invoke_roof_unit_api(section_name="firmware_info", query_method="GET") + +@app.get("/api/rvlink/status/uptime", summary="Get System Uptime", response_model=Dict[str, Any], tags=["Status"]) +async def get_uptime(): + """Retrieves more detailed firmware info of the roof unit.""" + return invoke_roof_unit_api(section_name="uptime_get", query_method="GET") + +@app.get("/api/rvlink/status/firmware", summary="Get Firmware Version", response_model=Dict[str, Any], tags=["Status"]) +async def get_firmware_version(): + """Retrieves the firmware version of the roof unit.""" + return invoke_roof_unit_api(section_name=FW_VERSION_SECTION, query_method="GET") + +@app.get("/api/rvlink/config/guide", summary="Get Guide Configuration", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_guide_config(): + """Retrieves the 'guide_config' from the roof unit.""" + return invoke_roof_unit_api(section_name=GUIDE_CONFIG_SECTION, query_method="GET") + +@app.get("/api/rvlink/history/connection_history", summary="Get Connection History", response_model=Dict[str, Any], tags=["History"]) +async def get_motor_history(): + """Retrieves the 'motor_home_list_get' (movement history) from the roof unit.""" + return invoke_roof_unit_api(section_name=HISTORY_SECTION, query_method="GET") + +# --- NEW GET-like Endpoints (based on typical Comfast/OpenWrt APIs) --- +# Magos, verify these section_names against your CSV and add more as needed. + +@app.get("/api/rvlink/status/system_usage", summary="Get System Resource Usage", response_model=Dict[str, Any], tags=["Status"]) +async def get_system_usage(): + """Retrieves general system status (e.g., uptime, memory, CPU) from the roof unit.""" + return invoke_roof_unit_api(section_name="system_usage", query_method="GET") + +@app.get("/api/rvlink/status/ac_ap_status", summary="Get AC AP Status", response_model=Dict[str, Any], tags=["Status"]) +async def get_ac_ap_status(): + """Retrieves not entirely sure""" + return invoke_roof_unit_api(section_name="ac_ap_status", query_method="GET") + +@app.get("/api/rvlink/config/ac_enable_get", summary="Get AC Enable", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_ac_enable_get(): + return invoke_roof_unit_api(section_name="ac_enable_get", query_method="GET") + +@app.get("/api/rvlink/config/ac_group_config", summary="Get AC Group Config", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_ac_group_config(): + return invoke_roof_unit_api(section_name="ac_group_config", query_method="GET") + +@app.get("/api/rvlink/config/ac_list_get", summary="Get AC List", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_ac_list_get(): + return invoke_roof_unit_api(section_name="ac_list_get", query_method="GET") + +@app.get("/api/rvlink/config/add_default_group_config", summary="Sets up a default interface group config", response_model=Dict[str, Any], tags=["Configuration"]) +async def add_default_group_config(): + """This function is designed to be safe to call multiple times. It only sets values if they are missing, so it won't overwrite existing group configuration.""" + """Ensures that there is always at least one group ("ac_group_default" with ID 1) in the system, and that the group ID counters and lists are initialized.""" + return invoke_roof_unit_api(section_name="add_default_group_config", query_method="GET") + +@app.get("/api/rvlink/config/lan", summary="Get LAN Configuration", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_lan_dhcp_config(): + """Retrieves config of just the lan interface from the roof unit.""" + return invoke_roof_unit_api(section_name="lan_dhcp_config", query_method="GET") + +@app.get("/api/rvlink/config/network_config", summary="Get LAN Configuration", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_network_config(): + """This is the full network config, including LAN and WAN settings.""" + return invoke_roof_unit_api(section_name="network_config", query_method="GET") + +@app.get("/api/rvlink/config/wan", summary="Get WAN Configuration", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_wan_config(): + """Retrieves WAN configuration from the roof unit.""" + return invoke_roof_unit_api(section_name="wan_config", query_method="GET") + +@app.get("/api/rvlink/config/mwan_capability_config", summary="Get mwan capabilities", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_mwan_capability_config(): + """Enumerates all network interfaces and their capabilities.""" + return invoke_roof_unit_api(section_name="mwan_capability_config", query_method="GET") + +@app.get("/api/rvlink/config/mwan_config", summary="Get mwan configuration", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_mwan_config(): + """ This function appears to be the handler for retrieving detailed WAN (Wide Area Network) configuration settings""" + return invoke_roof_unit_api(section_name="mwan_config", query_method="GET") + +@app.get("/api/rvlink/config/mwan_isp_define_config", summary="Get mwan isp config", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_mwan_isp_define_config(): + """ I assume this has to due with the LTE modem, but I don't know what it does. """ + return invoke_roof_unit_api(section_name="mwan_isp_define_config", query_method="GET") + +@app.get("/api/rvlink/config/mwanportrule_config", summary="Get mwan port rule config (Multi-wan routing rules?)", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_mwanportrule_config(): + """ I assume this has to due with the LTE modem, but I don't know what it does. """ + return invoke_roof_unit_api(section_name="mwanportrule_config", query_method="GET") + +@app.get("/api/rvlink/config/pptp_client_config", summary="Get PPTP configuration", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_pptp_client_config(): + return invoke_roof_unit_api(section_name="pptp_client_config", query_method="GET") + +@app.get("/api/rvlink/config/l2tp_client_config", summary="Get L2TP configuration", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_l2tp_client_config(): + return invoke_roof_unit_api(section_name="l2tp_client_config", query_method="GET") + +@app.get("/api/rvlink/config/multi_pppoe_macaddr_config", summary="Get MAC Address for PPPoE Config", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_multi_pppoe_macaddr_config(): + return invoke_roof_unit_api(section_name="multi_pppoe_macaddr_config", query_method="GET") + +@app.get("/api/rvlink/config/multi_pppoe", summary="Get PPPoE Config", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_multi_pppo(): + return invoke_roof_unit_api(section_name="multi_pppoe", query_method="GET") + +@app.get("/api/rvlink/Status/ap_wds_mode_ip_get", summary="Get WAN Status?", response_model=Dict[str, Any], tags=["Status"]) +async def get_ap_wds_mode_ip_get(): + return invoke_roof_unit_api(section_name="ap_wds_mode_ip_get", query_method="GET") + +@app.get("/api/rvlink/config/mwan_qos", summary="Get mwan qos config", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_mwan_qo(): + return invoke_roof_unit_api(section_name="mwan_qos", query_method="GET") + +@app.get("/api/rvlink/config/qos_ip_limit", summary="Get QoS IP limiting config", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_qos_ip_limit(): + return invoke_roof_unit_api(section_name="qos_ip_limit", query_method="GET") + +@app.get("/api/rvlink/config/pptpd_user", summary="Get PPTP configured user/pw", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_pptpd_user(): + return invoke_roof_unit_api(section_name="pptpd_user", query_method="GET") + +@app.get("/api/rvlink/config/wifi_ap", summary="Get WiFi AP Settings", response_model=Dict[str, Any], tags=["Configuration", "WiFi"]) +async def get_wifi_ap_settings(ifname: str = Query("radio0", description="AP interface name (e.g., radio0 for 2.4GHz AP, radio1 for 5GHz AP if dual-band)")): + """Retrieves WiFi Access Point settings for a specific interface.""" + # Magos: Verify 'wifi_ap_setting_get' is the correct section name from your CSV. + # Assumes it expects a payload like {"ifname": "value"} for the specific AP interface. + payload = {"ifname": ifname} + return invoke_roof_unit_api(section_name="wifi_ap_setting_get", query_method="GET", payload=payload) + +@app.get("/api/rvlink/config/wifi_client", summary="Get WiFi Client Settings", response_model=Dict[str, Any], tags=["Configuration", "WiFi"]) +async def get_wifi_client_settings(ifname: str = Query("radio0", description="STA interface name (e.g., radio0 for 2.4GHz STA, radio1 for 5GHz STA if dual-band)")): + """Retrieves WiFi Client (STA) settings for a specific interface.""" + # Magos: Verify 'wifi_client_setting_get' is the correct section name. + # Assumes it expects a payload like {"ifname": "value"} for the specific STA interface. + payload = {"ifname": ifname} + return invoke_roof_unit_api(section_name="wifi_client_setting_get", query_method="GET", payload=payload) + +@app.get("/api/rvlink/clients/wlan", summary="Get Connected WLAN Clients", response_model=Dict[str, Any], tags=["Clients", "WiFi"]) +async def get_wlan_clients(): + """Retrieves a list of clients connected to a specific WLAN AP interface.""" + return invoke_roof_unit_api(section_name="wireless_user_list_get", query_method="GET") + +@app.get("/api/rvlink/status/logs", summary="Get System Logs", response_model=Dict[str, Any], tags=["Status"]) +async def get_logs(): + """Retrieves the current status of the positioning motor.""" + return invoke_roof_unit_api(section_name="systemlog_get", query_method="GET") + +@app.get("/api/rvlink/status/logs_processed", # Or modify your existing /systemlog endpoint + response_model=ProcessedSystemLogResponse, + tags=["System Monitoring"], # Or your relevant tag + summary="Get and process system logs from the roof unit", + description="Fetches the system log from the MV2400, parses it into a structured format, " + "and returns the list of processed log entries." + ) +async def get_and_process_system_log(): + # session = None # Manage your requests.Session appropriately + try: + raw_device_response = await get_logs() # Replace with actual call + + # STEP 2: Process the fetched raw data + processed_data = process_device_system_log(raw_device_response) + + if processed_data.processing_error: + # Decide how to handle top-level processing errors, e.g., raise HTTPException + # For now, it's included in the response object + pass + + return processed_data + + # except requests.exceptions.RequestException as e: + # # Handle exceptions from fetching data from the device + # raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, + # detail=f"Error communicating with roof unit for system log: {str(e)}") + except Exception as e: + # Handle any other unexpected errors during processing + # You might want more specific error handling + return ProcessedSystemLogResponse( + parsed_logs=[], + processing_error=f"An unexpected error occurred: {str(e)}", + errCode=raw_device_response.get("errCode") if 'raw_device_response' in locals() else -1, + errMsg=raw_device_response.get("errMsg") if 'raw_device_response' in locals() else "Error", + configDone=raw_device_response.get("configDone") if 'raw_device_response' in locals() else False + ) + +# --- SET Operation Endpoints (existing) --- +@app.post("/api/rvlink/action/reboot", summary="Reboot Roof Unit", response_model=Dict[str, Any], tags=["Actions"]) +async def reboot_roof_unit(reboot_params: Optional[RebootRequest] = None): + """ + Reboots the roof unit. The actual section name for reboot is 'system_reboot'. + Example payload for immediate reboot: {"delay": 0} + """ + payload = reboot_params.model_dump() if reboot_params else {"delay": 0} + return invoke_roof_unit_api(section_name="system_reboot", query_method="SET", payload=payload) + +# need to configure 'http://192.168.10.254/cgi-bin/mbox-config?method=SET§ion=motorhome_config_set' endpoint here +@app.post("/api/rvlink/action/connect_wifi", summary="Connect to a WiFi Network", response_model=Dict[str, Any], tags=["Actions"]) +async def connect_to_wifi_network(wifi_params: ConnectWifiRequest): + """ + Connects the RVLink Roof Unit to a specified WiFi network. + """ + + motorhome_payload_params = { + "ssid": wifi_params.ssid, + "bssid": wifi_params.bssid, + "encryption": "none", + "key": "" # Default to empty string + } + + # Handle WEP key specifically if API differentiates 'key' and 'wep_key' + # The blobmsg_policy showed 'key' and 'wep_key'. + # If encryption is WEP, the key might need to be put into 'wep_key'. + # This assumes 'key' is the general field, but a device might be stricter. + # For now, this simplified model uses the 'key' field. If issues arise with WEP, + # this area may need refinement e.g. + # if api_encryption_type == "wep": + #     motorhome_payload_params["wep_key"] = motorhome_payload_params.pop("key") + # This depends on how the specific 'webmgnt' implementation handles these fields. + + api_payload = {"motorhome": motorhome_payload_params} + + return invoke_roof_unit_api( + section_name="motorhome_config_set", + query_method="SET", + http_method="POST", + payload=api_payload + ) + + +# --- File Download Endpoint (existing) --- +@app.get("/api/rvlink/download/{section_name}", + summary="Download Configuration/Data File", + responses={ + 200: {"content": {"application/octet-stream": {}}, "description": "File stream"}, + 404: {"model": ApiError, "description": "Section/File not found"}, + 500: {"model": ApiError, "description": "Internal Server Error or Roof Unit Error"}, + 503: {"model": ApiError, "description": "Roof unit communication error"} + }, + tags=["Files"]) +async def download_file_from_section_direct(section_name: str): + """ + Downloads a file from the roof unit by specifying the 'section' name. + Example sections that might provide files: + - `system_config_backup` + - `log_package_download` (Hypothetical: for downloading a package of logs) + """ + print(f"FastAPI: Initiating direct file download for section: {section_name}") + try: + # This call assumes the 'section' for file download is invoked via POST (with method=GET in query) + # and the response is the file stream. + return invoke_roof_unit_api(section_name=section_name, query_method="GET", is_file_download=True) + except HTTPException as e: + print(f"FastAPI HTTPException during /download/{section_name}: {e.status_code} - {e.detail}") + raise e + except Exception as e: + print(f"FastAPI ERROR in /download/{section_name}: {type(e).__name__} - {e}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Unexpected error downloading from section '{section_name}': {str(e)}") + +# --- System Endpoints --- +@app.get("/api/rvlink/system/get_led_status", summary="Get current state of LEDs on roof unit", response_model=Dict[str, Any], tags=["Status"]) +async def get_led_status(): + return invoke_roof_unit_api(section_name="led_status", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/language", summary="??", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_language(): + return invoke_roof_unit_api(section_name="language", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/wifi_config", summary="??", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_wifi_config(): + return invoke_roof_unit_api(section_name="wifi_config", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/lan_config", summary="??", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_lan_config(): + return invoke_roof_unit_api(section_name="lan_config", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/mac_address", summary="??", response_model=Dict[str, Any], tags=["Configuration"]) +async def get_mac_addr_get(): + return invoke_roof_unit_api(section_name="mac_addr_get", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/wps_status_get", summary="??", response_model=Dict[str, Any], tags=["Status"]) +async def get_wps_status_get(): + return invoke_roof_unit_api(section_name="wps_status_get", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/workmode_config", summary="??", response_model=Dict[str, Any], tags=["Status"]) +async def get_workmode_config(): + return invoke_roof_unit_api(section_name="workmode_config", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/cpu_usage_and_flow", summary="??", response_model=Dict[str, Any], tags=["Status"]) +async def get_cpu_usage_and_flow(): + return invoke_roof_unit_api(section_name="cpu_usage_and_flow", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/first_login", summary="??", response_model=Dict[str, Any], tags=["Status"]) +async def get_first_login(): + return invoke_roof_unit_api(section_name="first_login", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/port_status", summary="??", response_model=Dict[str, Any], tags=["Status"]) +async def get_port_status(): + return invoke_roof_unit_api(section_name="port_status", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/ap_wtpd_status", summary="??", response_model=Dict[str, Any], tags=["Status"]) +async def get_ap_wtpd_status(): + return invoke_roof_unit_api(section_name="ap_wtpd_status", query_method="GET", endpoint="system") + +@app.get("/api/rvlink/system/net_stats_get", summary="??", response_model=Dict[str, Any], tags=["Status"]) +async def get_net_stats_get(): + return invoke_roof_unit_api(section_name="net_stats_get", query_method="GET", endpoint="system") + +# @app.get("/api/rvlink/system/permission_config", summary="??", response_model=Dict[str, Any], tags=["Configuration"]) +# async def get_permission_config(): +# return invoke_roof_unit_api(section_name="permission_config", query_method="SET", endpoint="system") + +# --- Main Execution Block (for running with uvicorn directly) --- +if __name__ == "__main__": + print("Starting RVLink Direct API Proxy, by the Omnissiah's Will...") + uvicorn.run( + "rvlink_api:app", # Application import string + host="0.0.0.0", + port=8000, + log_level="info", + reload=True, + reload_excludes=["*.log", ".git/*", "__pycache__/*"], # Example: exclude certain files/dirs + reload_includes=["rvlink_api.py"] # Example: explicitly include only python files + ) \ No newline at end of file