Talking to AMV FRITZ!Box Routers in Python

Python
sb4dfritz
Author

Stefan Behrens

Published

September 10, 2025

Ever wondered how you can get Python to communicate with your AVM FRITZ!Box router? So have I. In this post I will discuss example implementations using both the TR-064 and the AHA-HTTP interfaces.

The code is extracted from my ongoing project sb4dfritz which is available on GitHub.

Introduction

I have a small home automation system controlled by my AVM FRITZ!Box router. Since I had a few specific use cases that were not easily handled with the available mobile and web apps, I started working on custom Python tools to make my life easier. I might write more about the project in other posts. For now, I just want to focus on the basic communication between FRITZ!Box routers and Python.

AVM offers to two different APIs for developers to interact with home automation devices:

Both interfaces have methods to read out basic device statistics such as current power consumption for smart plugs or temperature for radiator controls. I will lay down the implementation in Python below.

The TR-064 Interface

Using the TR-064 interface involves parsing an XML message in SOAP message format which is then sent via an HTTP POST request. The available actions relating to home automation are described here. However, I struggled to get anywhere with the official documentation. The code below is based on a working example which I found in a Google search.

The following data is needed for a request:

  • Valid login information (user name and password).
  • The IP address of the FRTITZ!Box router
  • The name of the SOAP action as listed in the documentation (below: GetSpecificDeviceInfos).
  • Additional parameters needed for the specific action also listed in the documentation (below: the device identifier number known as AIN).

Here’s an implementation of the SOAP action GetSpecificDeviceInfos using the requests module:

import requests, warnings
from requests.auth import HTTPDigestAuth

def get_specific_device_info(user:str, pwd:str, ip:str, device_ain:str)->requests.Response:
    """GetSpecificDeviceInfos action for TR-064 interfaces."""
    # constants needed for the request
    UPNP_URL = "https://" + ip + ":49443/upnp/control/x_homeauto"
    TR064_SERVICE = "urn:dslforum-org:service:X_AVM-DE_Homeauto:1"
    SOAP_ACTION = "GetSpecificDeviceInfos"
    # header for POST request
    request_headers = {
        'Content-Type': 'text/xml; charset="utf-8"', 
        'SoapAction': TR064_SERVICE + "#" + SOAP_ACTION
    }
    # data for POST request
    request_data = f"""
        <?xml version=\"1.0\"?> 
        <s:Envelope 
         xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" 
         s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"> 
            <s:Body> 
                <u:{SOAP_ACTION} xmlns:u=\"{TR064_SERVICE}\"> 
                    <NewAIN>{device_ain}</NewAIN> 
                </u:{SOAP_ACTION}> 
            </s:Body> 
        </s:Envelope>
        """
    # temporary ignore warnings (caused by self-signed certificate of FRITZ!Box)
    warnings.simplefilter('ignore')
    # send POST request
    request_result = requests.post(
        url=UPNP_URL, 
        auth=HTTPDigestAuth(user, pwd), 
        headers=request_headers, 
        data=request_data, 
        verify=False
    )
    # allow warning again
    warnings.resetwarnings()
    return request_result

A call to get_specific_device_info(...) returns a requests.Response object. The .text attribute contains the requested data, again in XML format. Here’s an example for illustration:

results = get_specific_device_info("user", "pwd", "ip", "12345 6789012")

print(results.text)
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetSpecificDeviceInfosResponse xmlns:u="urn:dslforum-org:service:X_AVM-DE_Homeauto:1">
<NewDeviceId>18</NewDeviceId>
<NewFunctionBitMask>35712</NewFunctionBitMask>
<NewFirmwareVersion>04.27</NewFirmwareVersion>
<NewManufacturer>AVM</NewManufacturer>
<NewProductName>FRITZ!DECT 200</NewProductName>
<NewDeviceName>TV etc</NewDeviceName>
<NewPresent>CONNECTED</NewPresent>
<NewMultimeterIsEnabled>ENABLED</NewMultimeterIsEnabled>
<NewMultimeterIsValid>VALID</NewMultimeterIsValid>
<NewMultimeterPower>1130</NewMultimeterPower>
<NewMultimeterEnergy>1023099</NewMultimeterEnergy>
<NewTemperatureIsEnabled>ENABLED</NewTemperatureIsEnabled>
<NewTemperatureIsValid>VALID</NewTemperatureIsValid>
<NewTemperatureCelsius>235</NewTemperatureCelsius>
<NewTemperatureOffset>0</NewTemperatureOffset>
<NewSwitchIsEnabled>ENABLED</NewSwitchIsEnabled>
<NewSwitchIsValid>VALID</NewSwitchIsValid>
<NewSwitchState>ON</NewSwitchState>
<NewSwitchMode>MANUAL</NewSwitchMode>
<NewSwitchLock>1</NewSwitchLock>
<NewHkrIsEnabled>DISABLED</NewHkrIsEnabled>
<NewHkrIsValid>INVALID</NewHkrIsValid>
<NewHkrIsTemperature>0</NewHkrIsTemperature>
<NewHkrSetVentilStatus>CLOSED</NewHkrSetVentilStatus>
<NewHkrSetTemperature>0</NewHkrSetTemperature>
<NewHkrReduceVentilStatus>CLOSED</NewHkrReduceVentilStatus>
<NewHkrReduceTemperature>0</NewHkrReduceTemperature>
<NewHkrComfortVentilStatus>CLOSED</NewHkrComfortVentilStatus>
<NewHkrComfortTemperature>0</NewHkrComfortTemperature>
</u:GetSpecificDeviceInfosResponse>
</s:Body>
</s:Envelope>

The AHA-HTTP Interface

Using AHA-HTTP interface is syntactically much simpler. It involves parsing a URL with parameters which is then sent as an HTTP GET request. The caveat is that one of the parameters is a valid session ID (SID) which has to be obtained by a separate login procedure. The implementation of the latter is somewhat involved. I’ll get to it later.

Assuming that a valid SID is already available, the implementation is straight forward. I’ll use the command 'getbasicdevicestats' which requires the additional parameters 'sid' and 'ain'.

import requests

URL_BASE = 'http://fritz.box/webservices/homeautoswitch.lua'

def getbasicdevicestats(ain:str, sid:str)->dict:
    """Get basic statistic (temperature, power, voltage, energy) of device."""
    # assemble parameter dictionary according to AHA-HTTP documentation
    params = {
        'switchcmd': 'getbasicdevicestats', 
        'ain':ain,
        'sid':sid, 
    }
    # send GET request (verify=False for self-signed certificate)
    response = requests.get(url=URL_BASE, params=params, verify=False)
    return response

As with the TR-064 implementation, the response comes in the form of a requests.Response object with XML formatted data in the .text attribute. Here’s an example. The get_sid(...) function is provided in the next section.

# get a session ID
sid = get_sid("user", "pwd")
# call the implementation of 'getbasicdevicestats'
results = getbasicdevicestats("12345 6789012", sid)
print(results.text)
<devicestats>
    <temperature>
        <stats count="96" grid="900" datatime="1757511566">
            235,235,235,230,230,225,235,235,235,235,235,235,235,235,235,235,
            235,235,235,235,235,235,235,230,230,230,230,225,220,225,235,235,
            235,235,235,235,235,235,235,235,235,235,235,235,235,235,235,235,
            235,235,235,235,235,235,235,235,235,235,235,235,235,235,235,235,
            235,235,235,235,235,235,235,235,235,235,235,235,235,235,235,235,
            235,235,235,235,235,235,235,235,235,235,235,235,235,235,235,235
        </stats>
    </temperature>

    <voltage>
        <stats count="360" grid="10" datatime="1757511566">
            231075,231075,231075,231075,231075,231075,231075,231075,231075,231075,
            231075,231075,231136,231136,231136,231136,231136,231136,231136,231136,
            231136,231136,231136,231136,229981,229981,229981,229981,229981,229981,
            ...
            230001,230001,230001,230001,230001,230001,230001,230001,230001,230001,
            230001,230001
        </stats>
    </voltage>

    <power>
        <stats count="360" grid="10" datatime="1757511566">
            1144,1144,1144,1144,1144,1144,1144,1144,1144,1144,1144,1144,
            1130,1130,1130,1130,1130,1130,1130,1130,1130,1130,1130,1130,
            ...
            1137,1137,1137,1137,1137,1137,1137,1137,1137,1137,1137,1137
        </stats>
    </power>

    <energy>
        <stats count="12" grid="2678400" datatime="1757462405">
            3876,11670,10987,12726,12952,11345,10704,13675,12599,15803,8513,7917
        </stats>
        <stats count="31" grid="86400" datatime="1757462404">
            148,259,493,99,98,137,384,481,1117,660,233,188,591,558,350,444,
            521,671,756,264,204,392,312,326,0,0,58,323,375,400,489
        </stats>
    </energy>
</devicestats>

Obtaining a session ID

Lastly, it remains to discuss login procedure to obtain a valid session ID. An official Python implementation is provided in this document.

"""
FRITZ!OS WebGUI Login (modified)
Get a sid (session ID) via PBKDF2 based challenge response algorithm.
Fallback to MD5 if FRITZ!OS has no PBKDF2 support.
AVM 2020-09-25 (code base)
Stefan Behrens 2025-08-19 (modifications)
"""

import sys
import hashlib
import time
import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET

LOGIN_SID_ROUTE = "/login_sid.lua?version=2"

class LoginState:
    def __init__(self, challenge: str, blocktime: int):
        self.challenge = challenge
        self.blocktime = blocktime
        self.is_pbkdf2 = challenge.startswith("2$")


def check_sid_validity(sid:str|int, address:str="fritz.box")->bool:
    """Check if the given SID is valid.
    
    Args
    - sid : 16 digit integer (possibly formatted as string)
    - address : FRITZ!Box IP or address (optional, default: fritz.box)
    
    Returns
    - sid_is_valid : Boolean, True if SID is valid, False else
    """
    url = f"http://{address}/login_sid.lua?version=2&sid={sid}"
    resp = urllib.request.urlopen(url)
    root = ET.fromstring(resp.read())
    sid_value = root.find("SID").text
    sid_is_valid = (sid_value != "0000000000000000")
    return sid_is_valid


def get_sid(username: str, password: str, address:str="fritz.box") -> str:
    """ Get a sid by solving the PBKDF2 (or MD5) challenge-response
    process. """
    box_url = "http://" + address
    try:
        state = get_login_state(box_url)
    except Exception as ex:
        raise Exception("failed to get challenge") from ex
    if state.is_pbkdf2:
        # print("PBKDF2 supported")
        challenge_response = calculate_pbkdf2_response(state.challenge, password)
    else:
        # print("Falling back to MD5")
        challenge_response = calculate_md5_response(state.challenge, password)
    if state.blocktime > 0:
        # print(f"Waiting for {state.blocktime} seconds...")
        time.sleep(state.blocktime)
    try:
        sid = send_response(box_url, username, challenge_response)
    except Exception as ex:
        raise Exception("failed to login") from ex
    if sid == "0000000000000000":
        raise Exception("wrong username or password")
    return sid


def get_login_state(box_url: str) -> LoginState:
    """ Get login state from FRITZ!Box using login_sid.lua?version=2 """
    url = box_url + LOGIN_SID_ROUTE
    http_response = urllib.request.urlopen(url)
    xml = ET.fromstring(http_response.read())
    # print(f"xml: {xml}")
    challenge = xml.find("Challenge").text
    blocktime = int(xml.find("BlockTime").text)
    return LoginState(challenge, blocktime)


def calculate_pbkdf2_response(challenge: str, password: str) -> str:
    """ Calculate the response for a given challenge via PBKDF2 """
    challenge_parts = challenge.split("$")
    # Extract all necessary values encoded into the challenge
    iter1 = int(challenge_parts[1])
    salt1 = bytes.fromhex(challenge_parts[2])
    iter2 = int(challenge_parts[3])
    salt2 = bytes.fromhex(challenge_parts[4])
    # Hash twice, once with static salt...
    hash1 = hashlib.pbkdf2_hmac("sha256", password.encode(), salt1, iter1)
    # Once with dynamic salt.
    hash2 = hashlib.pbkdf2_hmac("sha256", hash1, salt2, iter2)
    return f"{challenge_parts[4]}${hash2.hex()}"


def calculate_md5_response(challenge: str, password: str) -> str:
    """ Calculate the response for a challenge using legacy MD5 """
    response = challenge + "-" + password
    # the legacy response needs utf_16_le encoding
    response = response.encode("utf_16_le")
    md5_sum = hashlib.md5()
    md5_sum.update(response)
    response = challenge + "-" + md5_sum.hexdigest()
    return response


def send_response(box_url: str, username: str, challenge_response: str)->str:
    """ Send the response and return the parsed sid. raises an Exception on
    error """
    # Build response params
    post_data_dict = {"username": username, "response": challenge_response}
    post_data = urllib.parse.urlencode(post_data_dict).encode()
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    url = box_url + LOGIN_SID_ROUTE
    # Send response
    http_request = urllib.request.Request(url, post_data, headers)
    http_response = urllib.request.urlopen(http_request)
    # Parse SID from resulting XML.
    xml = ET.fromstring(http_response.read())
    return xml.find("SID").text