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
= "https://" + ip + ":49443/upnp/control/x_homeauto"
UPNP_URL = "urn:dslforum-org:service:X_AVM-DE_Homeauto:1"
TR064_SERVICE = "GetSpecificDeviceInfos"
SOAP_ACTION # header for POST request
= {
request_headers 'Content-Type': 'text/xml; charset="utf-8"',
'SoapAction': TR064_SERVICE + "#" + SOAP_ACTION
}# data for POST request
= f"""
request_data <?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)
'ignore')
warnings.simplefilter(# send POST request
= requests.post(
request_result =UPNP_URL,
url=HTTPDigestAuth(user, pwd),
auth=request_headers,
headers=request_data,
data=False
verify
)# allow warning again
warnings.resetwarnings()return request_result
Talking to AMV FRITZ!Box Routers in Python
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:
- The TR-064 interface is based on SOAP and the synonymous TR-064 protocol.
- The AHA-HTTP interface where AHA stands for “AVM Home Automation”.
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:
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:
= get_specific_device_info("user", "pwd", "ip", "12345 6789012")
results
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
= 'http://fritz.box/webservices/homeautoswitch.lua'
URL_BASE
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)
= requests.get(url=URL_BASE, params=params, verify=False)
response 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
= get_sid("user", "pwd")
sid # call the implementation of 'getbasicdevicestats'
= getbasicdevicestats("12345 6789012", sid)
results 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,235stats>
</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,230001stats>
</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,1137stats>
</power>
</
energy>
<stats count="12" grid="2678400" datatime="1757462405">
<
3876,11670,10987,12726,12952,11345,10704,13675,12599,15803,8513,7917stats>
</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,489stats>
</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.lua?version=2"
LOGIN_SID_ROUTE
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
"""
= f"http://{address}/login_sid.lua?version=2&sid={sid}"
url = urllib.request.urlopen(url)
resp = ET.fromstring(resp.read())
root = root.find("SID").text
sid_value = (sid_value != "0000000000000000")
sid_is_valid 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. """
= "http://" + address
box_url try:
= get_login_state(box_url)
state except Exception as ex:
raise Exception("failed to get challenge") from ex
if state.is_pbkdf2:
# print("PBKDF2 supported")
= calculate_pbkdf2_response(state.challenge, password)
challenge_response else:
# print("Falling back to MD5")
= calculate_md5_response(state.challenge, password)
challenge_response if state.blocktime > 0:
# print(f"Waiting for {state.blocktime} seconds...")
time.sleep(state.blocktime)try:
= send_response(box_url, username, challenge_response)
sid 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 """
= box_url + LOGIN_SID_ROUTE
url = urllib.request.urlopen(url)
http_response = ET.fromstring(http_response.read())
xml # print(f"xml: {xml}")
= xml.find("Challenge").text
challenge = int(xml.find("BlockTime").text)
blocktime return LoginState(challenge, blocktime)
def calculate_pbkdf2_response(challenge: str, password: str) -> str:
""" Calculate the response for a given challenge via PBKDF2 """
= challenge.split("$")
challenge_parts # Extract all necessary values encoded into the challenge
= int(challenge_parts[1])
iter1 = bytes.fromhex(challenge_parts[2])
salt1 = int(challenge_parts[3])
iter2 = bytes.fromhex(challenge_parts[4])
salt2 # Hash twice, once with static salt...
= hashlib.pbkdf2_hmac("sha256", password.encode(), salt1, iter1)
hash1 # Once with dynamic salt.
= hashlib.pbkdf2_hmac("sha256", hash1, salt2, iter2)
hash2 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 """
= challenge + "-" + password
response # the legacy response needs utf_16_le encoding
= response.encode("utf_16_le")
response = hashlib.md5()
md5_sum
md5_sum.update(response)= challenge + "-" + md5_sum.hexdigest()
response 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
= {"username": username, "response": challenge_response}
post_data_dict = urllib.parse.urlencode(post_data_dict).encode()
post_data = {"Content-Type": "application/x-www-form-urlencoded"}
headers = box_url + LOGIN_SID_ROUTE
url # Send response
= urllib.request.Request(url, post_data, headers)
http_request = urllib.request.urlopen(http_request)
http_response # Parse SID from resulting XML.
= ET.fromstring(http_response.read())
xml return xml.find("SID").text