first commit

This commit is contained in:
alexpolo1
2023-06-27 09:24:33 +02:00
commit 06486ba860
33 changed files with 2615 additions and 0 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ensure Docker script files uses LF to support Docker for Windows.
# Ensure "git config --global core.autocrlf input" before you clone
* text eol=lf
*.py whitespace=error

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @iMicknl

39
.github/ISSUE_TEMPLATE/bug.md vendored Normal file
View File

@@ -0,0 +1,39 @@
---
name: Bug report
about: "Create a bug report for a bug you found in the Python Sagemcom API"
---
## Versions
| Key | Value |
| ----------------- | ----- |
| Package version | |
| Python Version | |
| F@st Router Model | |
## Describe the bug
Give a clear and concise description of what the bug is.
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected behavior
Give a clear and concise description of what you expected to happen.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Additional context
Add any other context about the problem here.
[bug]

View File

@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest a feature for the Python Sagemcom API
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
[enhancement]

View File

@@ -0,0 +1,39 @@
---
name: Unsupported F@st model
about: "Is your F@st model not supported by this package? Let's have a look if we can make the required changes."
---
## Model information
| Key | Value |
| ------------------------ | ----- |
| Model name | |
| Hardware Version | |
| Software Version | |
## Describe the bug
Give a clear and concise description of what the bug is.
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected behavior
Give a clear and concise description of what you expected to happen.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Additional context
Add any other context about the problem here.
[bug]

15
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: "daily"
time: "08:00"
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: "daily"
time: "08:00"
open-pull-requests-limit: 10

5
.github/pr-labeler.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
feature: ['feature/*', 'feat/*']
enhancement: enhancement/*
bug: fix/*
breaking: breaking/*
documentation: doc/*

21
.github/release-drafter.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name-template: 'v$NEXT_PATCH_VERSION'
tag-template: 'v$NEXT_PATCH_VERSION'
exclude-labels:
- 'exclude-from-changelog'
categories:
- title: '⚠️ Breaking changes'
label: 'breaking'
- title: '🚀 Features'
label: 'feature'
- title: '✨ Enhancement'
label: 'enhancement'
- title: '📘 Documentation'
label: 'documentation'
- title: '🐛 Bug Fixes'
label: 'bug'
template: |
## What's changed
$CHANGES
## Contributors to this release
$CONTRIBUTORS

49
.github/workflows/main.yaml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Linters
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
analyse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v3
with:
python-version: 3.9
- name: Set up Poetry
uses: Gr1N/setup-poetry@v7
- name: Cache venv
uses: actions/cache@v3
with:
path: .venv
key: venv-${{ hashFiles('**/poetry.lock') }}
- name: Cache pre-commit
uses: actions/cache@v3
with:
path: ~/.cache/pre-commit/
key: ${{ runner.os }}-pre-commit-${{ hashFiles('**/poetry.lock') }}-${{ hashFiles('**/.pre-commit-config.yaml') }} # yamllint disable-line
- name: Install dependencies
run: poetry install
- name: Register problems matchers
run: |
echo "::add-matcher::.github/workflows/matchers/pylint.json"
echo "::add-matcher::.github/workflows/matchers/flake8.json"
echo "::add-matcher::.github/workflows/matchers/mypy.json"
echo "::add-matcher::.github/workflows/matchers/python.json"
- name: Apply all pre-commit
run: poetry run pre-commit run -a

30
.github/workflows/matchers/flake8.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"problemMatcher": [
{
"owner": "flake8-error",
"severity": "error",
"pattern": [
{
"regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
]
},
{
"owner": "flake8-warning",
"severity": "warning",
"pattern": [
{
"regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
]
}
]
}

16
.github/workflows/matchers/mypy.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"problemMatcher": [
{
"owner": "mypy",
"pattern": [
{
"regexp": "^(.+):(\\d+):\\s(error|warning):\\s(.+)$",
"file": 1,
"line": 2,
"severity": 3,
"message": 4
}
]
}
]
}

32
.github/workflows/matchers/pylint.json vendored Normal file
View File

@@ -0,0 +1,32 @@
{
"problemMatcher": [
{
"owner": "pylint-error",
"severity": "error",
"pattern": [
{
"regexp": "^(.+):(\\d+):(\\d+):\\s(([EF]\\d{4}):\\s.+)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4,
"code": 5
}
]
},
{
"owner": "pylint-warning",
"severity": "warning",
"pattern": [
{
"regexp": "^(.+):(\\d+):(\\d+):\\s(([CRW]\\d{4}):\\s.+)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4,
"code": 5
}
]
}
]
}

18
.github/workflows/matchers/python.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"problemMatcher": [
{
"owner": "python",
"pattern": [
{
"regexp": "^\\s*File\\s\\\"(.*)\\\",\\sline\\s(\\d+),\\sin\\s(.*)$",
"file": 1,
"line": 2
},
{
"regexp": "^\\s*raise\\s(.*)\\(\\'(.*)\\'\\)$",
"message": 2
}
]
}
]
}

12
.github/workflows/pr-labeler.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: PR Labeler
on:
pull_request:
types: [opened]
jobs:
pr-labeler:
runs-on: ubuntu-latest
steps:
- uses: TimonVS/pr-labeler-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

30
.github/workflows/publish-pypi-test.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# yamllint disable-file
# This workflows will upload a Python Package
# using Poetry when a release is created
name: Publish Python Package (test)
on:
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: '3.x'
- name: Set up Poetry
uses: Gr1N/setup-poetry@v7
- name: Build and publish to PyPi
env:
POETRY_PYPI_TOKEN_TESTPYPI: ${{ secrets.TEST_PYPI_API_TOKEN }}
run: |
poetry config http-basic.testpypi ${{ secrets.TEST_PYPI_API_TOKEN }} ""
poetry build
poetry config repositories.testpypi https://test.pypi.org/legacy/
poetry publish -r testpypi

15
.github/workflows/release-drafter.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Release Drafter
on:
push:
branches:
- master
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- name: Update release draft
uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

41
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
# This workflow will upload a Python Package using
# Poetry when a release is published
name: Publish Python Package (PyPi)
on:
release:
types: [published]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: '3.x'
- name: Set up Poetry
uses: Gr1N/setup-poetry@v7
- name: Bump Poetry version
run: |
tag=${{ github.event.release.tag_name }}
version_number=${tag#?}
poetry version $version_number
- name: Commit changes
uses: EndBug/add-and-commit@v4
with:
message: "Bump version to ${{ github.event.release.tag_name }}"
add: "pyproject.toml"
ref: "master"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish to PyPi
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
run: |
poetry config pypi-token.pypi ${{ secrets.PYPI_API_TOKEN }}
poetry build
poetry publish

129
.gitignore vendored Normal file
View File

@@ -0,0 +1,129 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

39
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,39 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v2.7.2
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/psf/black
rev: 20.8b1
hooks:
- id: black
args:
- --safe
- --quiet
files: ^((sagemcom_api|tests)/.+)?[^/]+\.py$
- repo: https://github.com/codespell-project/codespell
rev: v1.17.1
hooks:
- id: codespell
args:
- --skip="./.*,*.csv,*.json,*.md"
- --quiet-level=2
exclude_types: [csv, json]
- repo: https://gitlab.com/pycqa/flake8
rev: 3.8.4
hooks:
- id: flake8
args: ['--ignore=E501']
additional_dependencies:
- flake8-docstrings==1.5.0
- pydocstyle==5.1.1
files: ^(sagemcom_api|tests)/.+\.py$
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.24.2
hooks:
- id: yamllint
- repo: https://github.com/PyCQA/isort
rev: 5.5.3
hooks:
- id: isort

16
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true
}
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Mick Vleeshouwer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

147
README.md Normal file
View File

@@ -0,0 +1,147 @@
# Sagemcom API Client in Python
(Unofficial) async Python client to interact with Sagemcom F@st routers via internal API's. This client offers helper functions to retrieve common used functions, but also offers functionality to do custom requests via XPATH notation.
Python 3.9+ required.
## Features
- Retrieve detailed information of your Sagemcom F@st device
- Retrieve connected devices (wifi and ethernet)
- Reboot Sagemcom F@st device
- Retrieve and set all values of your Sagemcom F@st device
## Supported devices
The Sagemcom F@st series is used by multiple cable companies, where some cable companies did rebrand the router. Examples are the b-box from Proximus, Home Hub from bell and the Smart Hub from BT.
| Router Model | Provider(s) | Authentication Method | Comments |
| --------------------- | -------------------- | --------------------- | ----------------------------- |
| Sagemcom F@st 3864 | Optus | sha512 | username: guest, password: "" |
| Sagemcom F@st 3865b | Proximus (b-box3) | md5 | |
| Sagemcom F@st 3890V3 | Delta / Zeelandnet | md5 | |
| Sagemcom F@st 4360Air | KPN | md5 | |
| Sagemcom F@st 5250 | Bell (Home Hub 2000) | md5 | username: guest, password: "" |
| Sagemcom F@st 5280 | | sha512 | |
| Sagemcom F@st 5364 | BT (Smart Hub) | md5 | username: guest, password: "" |
| SagemCom F@st 5366SD | Eir F3000 | md5 | |
| Sagemcom F@st 5370e | Telia | sha512 | |
| Sagemcom F@st 5566 | Bell (Home Hub 3000) | md5 | username: guest, password: "" |
| Sagemcom F@st 5655V2 | MásMóvil | md5 | |
| Sagemcom F@st 5657IL | | md5 | |
| Speedport Pro | Telekom | md5 | username: admin |
> Contributions welcome. If you router model is supported by this package, but not in the list above, please create [an issue](https://github.com/iMicknl/python-sagemcom-api/issues/new) or pull request.
## Installation
```bash
pip install sagemcom_api
```
## Getting Started
Depending on the router model, Sagemcom is using different encryption methods for authentication, which can be found in [the table above](#supported-devices). This package supports MD5 and SHA512 encryption. If you receive a `LoginTimeoutException`, you will probably need to use another encryption type.
The following script can be used as a quickstart.
```python
import asyncio
from sagemcom_api.enums import EncryptionMethod
from sagemcom_api.client import SagemcomClient
HOST = ""
USERNAME = ""
PASSWORD = ""
ENCRYPTION_METHOD = EncryptionMethod.MD5 # or EncryptionMethod.SHA512
async def main() -> None:
async with SagemcomClient(HOST, USERNAME, PASSWORD, ENCRYPTION_METHOD) as client:
try:
await client.login()
except Exception as exception: # pylint: disable=broad-except
print(exception)
return
# Print device information of Sagemcom F@st router
device_info = await client.get_device_info()
print(f"{device_info.id} {device_info.model_name}")
# Print connected devices
devices = await client.get_hosts()
for device in devices:
if device.active:
print(f"{device.id} - {device.name}")
# Retrieve values via XPath notation, output is a dict
custom_command_output = await client.get_value_by_xpath("Device/UserInterface/AdvancedMode")
print(custom_command_output)
# Set value via XPath notation
custom_command_output = await client.set_value_by_xpath("Device/UserInterface/AdvancedMode", "true")
print(custom_command_output)
asyncio.run(main())
```
## Functions
- `login()`
- `get_device_info()`
- `get_hosts()`
- `get_port_mappings()`
- `reboot()`
- `get_value_by_xpath(xpath)`
- `set_value_by_xpath(xpath, value)`
## Advanced
### Determine the EncryptionMethod
(not supported yet)
### Handle exceptions
Some functions may cause an error when an attempt is made to execute it. These exceptions are thrown by the client and need to be [handled in your Python program](https://docs.python.org/3/tutorial/errors.html#handling-exceptions). Best practice is to catch some specific exceptions and handle them gracefully.
```python
from sagemcom_api.exceptions import *
try:
await client.set_value_by_xpath("Device/UserInterface/AdvancedMode", "true")
except NonWritableParameterException as exception:
print("You don't have rights to write to this parameter.")
except UnknownPathException as exception:
print("The xpath does not exist.")
```
### Run your custom commands
Not all values can be retrieved by helper functions in this client implementation. By using XPath, you are able to return all values via the API. The result will be a dict response, or [an exception](#handle-exceptions) when the attempt was not successful.
```python
try:
result = await client.get_value_by_xpath("Device/DeviceSummary")
except Exception as exception:
print(exception)
```
### Use your own aiohttp ClientSession
> ClientSession is the heart and the main entry point for all client API operations. The session contains a cookie storage and connection pool, thus cookies and connections are shared between HTTP requests sent by the same session.
In order to change settings like the time-out, it is possible to pass your custom [aiohttp ClientSession](https://docs.aiohttp.org/en/stable/client_advanced.html).
```python
from aiohttp import ClientSession, ClientTimeout
session = ClientSession(timeout=ClientTimeout(100))
client = SagemcomClient(session=session)
```
## Inspired by
- [wuseman/SAGEMCOM-FAST-5370e-TELIA](https://github.com/wuseman/SAGEMCOM-FAST-5370e-TELIA)
- [insou22/optus-router-tools](https://github.com/insou22/optus-router-tools)
- [onegambler/bthomehub_client](https://github.com/onegambler/bthomehub_client)

1135
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[tool.poetry]
name = "sagemcom_api"
version = "1.0.8"
description = "Python client to interact with SagemCom F@st routers via internal API's."
authors = ["Mick Vleeshouwer <mick@imick.nl>"]
license = "MIT"
readme = "README.md"
homepage = "https://github.com/iMicknl/python-sagemcom-api"
repository = "https://github.com/iMicknl/python-sagemcom-api"
keywords = ["sagemcom", "f@st"]
packages = [
{ include = "sagemcom_api" }
]
[tool.poetry.urls]
"Bug Tracker" = "https://github.com/iMicknl/python-sagemcom-api/issues"
[tool.poetry.dependencies]
python = ">=3.9,<4.0"
aiohttp = "^3.7.3"
pyhumps = "^3.0.2"
[tool.poetry.dev-dependencies]
pytest = "^7.1"
pre-commit = "^2.16.0"
black = "^22.1"
pylint = "^2.13.2"
isort = "^5.10.1"
mypy = "^0.942"
flake8 = "^4.0.1"
pyupgrade = "^2.31.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.isort]
# https://github.com/PyCQA/isort/wiki/isort-Settings
profile = "black"
force_sort_within_sections = true
combine_as_imports = true

2
sagemcom_api/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Package to communicate with Sagemcom F@st internal APIs."""
__version__ = "1.0.1"

412
sagemcom_api/client.py Normal file
View File

@@ -0,0 +1,412 @@
"""Client to communicate with Sagemcom F@st internal APIs."""
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import random
import urllib.parse
import humps
from types import TracebackType
from typing import Dict, List, Optional, Type
from aiohttp import ClientSession, ClientTimeout
from aiohttp.connector import TCPConnector
from . import __version__
from .const import (
API_ENDPOINT,
DEFAULT_TIMEOUT,
DEFAULT_USER_AGENT,
XMO_ACCESS_RESTRICTION_ERR,
XMO_AUTHENTICATION_ERR,
XMO_MAX_SESSION_COUNT_ERR,
XMO_NO_ERR,
XMO_NON_WRITABLE_PARAMETER_ERR,
XMO_REQUEST_ACTION_ERR,
XMO_REQUEST_NO_ERR,
XMO_UNKNOWN_PATH_ERR,
)
from .enums import EncryptionMethod
from .exceptions import (
AccessRestrictionException,
AuthenticationException,
BadRequestException,
LoginTimeoutException,
MaximumSessionCountException,
NonWritableParameterException,
UnauthorizedException,
UnknownException,
UnknownPathException,
)
from .models import Device, DeviceInfo, PortMapping
class SagemcomClient:
"""Client to communicate with the Sagemcom API."""
def __init__(
self,
host,
username,
password,
authentication_method,
session: ClientSession = None,
ssl=False,
verify_ssl=True,
):
"""
Create a SagemCom client.
:param host: the host of your Sagemcom router
:param username: the username for your Sagemcom router
:param password: the password for your Sagemcom router
:param authentication_method: the auth method of your Sagemcom router
:param session: use a custom session, for example to configure the timeout
"""
self.host = host
self.username = username
self.authentication_method = authentication_method
self._password_hash = self.__generate_hash(password)
self.protocol = "https" if ssl else "http"
self._current_nonce = None
self._server_nonce = ""
self._session_id = 0
self._request_id = -1
self.session = (
session
if session
else ClientSession(
headers={"User-Agent": f"{DEFAULT_USER_AGENT}/{__version__}"},
timeout=ClientTimeout(DEFAULT_TIMEOUT),
connector=TCPConnector(ssl=verify_ssl),
)
)
async def __aenter__(self) -> SagemcomClient:
"""TODO."""
return self
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
"""Close session on exit."""
await self.close()
async def close(self) -> None:
"""Close the websession."""
await self.session.close()
def __generate_nonce(self):
"""Generate pseudo random number (nonce) to avoid replay attacks."""
self._current_nonce = math.floor(random.randrange(0, 1) * 500000)
def __generate_request_id(self):
"""Generate sequential request ID."""
self._request_id += 1
def __generate_hash(self, value, authentication_method=None):
"""Hash value with selected encryption method and return HEX value."""
auth_method = authentication_method or self.authentication_method
bytes_object = bytes(value, encoding="utf-8")
if auth_method == EncryptionMethod.MD5:
return hashlib.md5(bytes_object).hexdigest()
if auth_method == EncryptionMethod.SHA512:
return hashlib.sha512(bytes_object).hexdigest()
return value
def __get_credential_hash(self):
"""Build credential hash."""
return self.__generate_hash(
self.username + ":" + self._server_nonce + ":" + self._password_hash
)
def __generate_auth_key(self):
"""Build auth key."""
credential_hash = self.__get_credential_hash()
auth_string = f"{credential_hash}:{self._request_id}:{self._current_nonce}:JSON:{API_ENDPOINT}"
self._auth_key = self.__generate_hash(auth_string)
def __get_response_error(self, response):
"""Retrieve response error from result."""
try:
value = response["reply"]["error"]
except KeyError:
value = None
return value
def __get_response(self, response, index=0):
"""Retrieve response from result."""
try:
value = response["reply"]["actions"][index]["callbacks"][0]["parameters"]
except KeyError:
value = None
return value
def __get_response_value(self, response, index=0):
"""Retrieve response value from value."""
try:
value = self.__get_response(response, index)["value"]
except KeyError:
value = None
# Rewrite result to snake_case
value = humps.decamelize(value)
return value
async def __api_request_async(self, actions, priority=False):
"""Build request to the internal JSON-req API."""
self.__generate_request_id()
self.__generate_nonce()
self.__generate_auth_key()
api_host = f"{self.protocol}://{self.host}{API_ENDPOINT}"
payload = {
"request": {
"id": self._request_id,
"session-id": str(self._session_id),
"priority": priority,
"actions": actions,
"cnonce": self._current_nonce,
"auth-key": self._auth_key,
}
}
async with self.session.post(
api_host, data="req=" + json.dumps(payload, separators=(",", ":"))
) as response:
if response.status == 400:
result = await response.text()
raise BadRequestException(result)
if response.status != 200:
result = await response.text()
raise UnknownException(result)
if response.status == 200:
result = await response.json()
error = self.__get_response_error(result)
# No errors
if (
error["description"] == XMO_REQUEST_NO_ERR
or error["description"] == "Ok" # NOQA: W503
):
return result
# Error in one of the actions
if error["description"] == XMO_REQUEST_ACTION_ERR:
# TODO How to support multiple actions + error handling?
actions = result["reply"]["actions"]
for action in actions:
action_error = action["error"]
action_error_description = action_error["description"]
if action_error_description == XMO_NO_ERR:
continue
if action_error_description == XMO_AUTHENTICATION_ERR:
raise AuthenticationException(action_error)
if action_error_description == XMO_ACCESS_RESTRICTION_ERR:
raise AccessRestrictionException(action_error)
if action_error_description == XMO_NON_WRITABLE_PARAMETER_ERR:
raise NonWritableParameterException(action_error)
if action_error_description == XMO_UNKNOWN_PATH_ERR:
raise UnknownPathException(action_error)
if action_error_description == XMO_MAX_SESSION_COUNT_ERR:
raise MaximumSessionCountException(action_error)
raise UnknownException(action_error)
return result
async def login(self):
"""TODO."""
actions = {
"method": "logIn",
"parameters": {
"user": self.username,
"persistent": True,
"session-options": {
"nss": [{"name": "gtw", "uri": "http://sagemcom.com/gateway-data"}],
"language": "ident",
"context-flags": {"get-content-name": True, "local-time": True},
"capability-depth": 2,
"capability-flags": {
"name": True,
"default-value": False,
"restriction": True,
"description": False,
},
"time-format": "ISO_8601",
"write-only-string": "_XMO_WRITE_ONLY_",
"undefined-write-only-string": "_XMO_UNDEFINED_WRITE_ONLY_",
},
},
}
try:
response = await self.__api_request_async([actions], True)
except asyncio.TimeoutError as exception:
raise LoginTimeoutException(
"Request timed-out. This is mainly due to using the wrong encryption method."
) from exception
data = self.__get_response(response)
if data["id"] is not None and data["nonce"] is not None:
self._session_id = data["id"]
self._server_nonce = data["nonce"]
return True
else:
raise UnauthorizedException(data)
async def logout(self):
"""Log out of the Sagemcom F@st device."""
actions = {"id": 0, "method": "logOut"}
await self.__api_request_async([actions], False)
self._session_id = -1
self._server_nonce = ""
self._request_id = -1
async def get_value_by_xpath(
self, xpath: str, options: Optional[Dict] = {}
) -> Dict:
"""
Retrieve raw value from router using XPath.
:param xpath: path expression
:param options: optional options
"""
actions = {
"id": 0,
"method": "getValue",
"xpath": urllib.parse.quote(xpath),
"options": options,
}
response = await self.__api_request_async([actions], False)
data = self.__get_response_value(response)
return data
async def get_values_by_xpaths(self, xpaths, options: Optional[Dict] = {}) -> Dict:
"""
Retrieve raw values from router using XPath.
:param xpaths: Dict of key to xpath expression
:param options: optional options
"""
actions = [
{
"id": i,
"method": "getValue",
"xpath": urllib.parse.quote(xpath),
"options": options,
}
for i, xpath in enumerate(xpaths.values())
]
response = await self.__api_request_async(actions, False)
values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
data = dict(zip(xpaths.keys(), values))
return data
async def set_value_by_xpath(
self, xpath: str, value: str, options: Optional[Dict] = {}
) -> Dict:
"""
Retrieve raw value from router using XPath.
:param xpath: path expression
:param value: value
:param options: optional options
"""
actions = {
"id": 0,
"method": "setValue",
"xpath": xpath,
"parameters": {"value": str(value)},
"options": options,
}
response = await self.__api_request_async([actions], False)
return response
async def get_device_info(self) -> DeviceInfo:
"""Retrieve information about Sagemcom F@st device."""
try:
data = await self.get_value_by_xpath("Device/DeviceInfo")
return DeviceInfo(**data.get("device_info"))
except UnknownPathException:
data = await self.get_values_by_xpaths(
{
"mac_address": "Device/DeviceInfo/MACAddress",
"model_name": "Device/DeviceInfo/ModelNumber",
"model_number": "Device/DeviceInfo/ProductClass",
"product_class": "Device/DeviceInfo/ProductClass",
"serial_number": "Device/DeviceInfo/SerialNumber",
"software_version": "Device/DeviceInfo/SoftwareVersion",
}
)
data["manufacturer"] = "Sagemcom"
return DeviceInfo(**data)
async def get_hosts(self, only_active: Optional[bool] = False) -> List[Device]:
"""Retrieve hosts connected to Sagemcom F@st device."""
data = await self.get_value_by_xpath("Device/Hosts/Hosts")
devices = [Device(**d) for d in data]
if only_active:
active_devices = [d for d in devices if d.active is True]
return active_devices
return devices
async def get_port_mappings(self) -> List[PortMapping]:
"""Retrieve configured Port Mappings on Sagemcom F@st device."""
data = await self.get_value_by_xpath("Device/NAT/PortMappings")
port_mappings = [PortMapping(**p) for p in data]
return port_mappings
async def reboot(self):
"""Reboot Sagemcom F@st device."""
action = {
"method": "reboot",
"xpath": "Device",
"parameters": {"source": "GUI"},
}
response = await self.__api_request_async([action], False)
data = self.__get_response_value(response)
return data

14
sagemcom_api/const.py Normal file
View File

@@ -0,0 +1,14 @@
"""Constants for the Sagemcom F@st client."""
API_ENDPOINT = "/cgi/json-req"
DEFAULT_TIMEOUT = 7
DEFAULT_USER_AGENT = "Python_Sagemcom"
XMO_ACCESS_RESTRICTION_ERR = "XMO_ACCESS_RESTRICTION_ERR"
XMO_AUTHENTICATION_ERR = "XMO_AUTHENTICATION_ERR"
XMO_NON_WRITABLE_PARAMETER_ERR = "XMO_NON_WRITABLE_PARAMETER_ERR"
XMO_NO_ERR = "XMO_NO_ERR"
XMO_REQUEST_ACTION_ERR = "XMO_REQUEST_ACTION_ERR"
XMO_REQUEST_NO_ERR = "XMO_REQUEST_NO_ERR"
XMO_UNKNOWN_PATH_ERR = "XMO_UNKNOWN_PATH_ERR"
XMO_MAX_SESSION_COUNT_ERR = "XMO_MAX_SESSION_COUNT_ERR"

10
sagemcom_api/enums.py Normal file
View File

@@ -0,0 +1,10 @@
"""Enums for the Sagemcom F@st client."""
from enum import Enum
class EncryptionMethod(Enum):
"""Encryption method defining the password hash."""
MD5 = "MD5"
SHA512 = "SHA512"

View File

@@ -0,0 +1,58 @@
"""Exceptions for the Sagemcom F@st client."""
# Exceptions provided by SagemCom API
class AccessRestrictionException(Exception):
"""Raised when current user has access restrictions."""
pass
class AuthenticationException(Exception):
"""Raised when authentication is not correct."""
pass
class LoginTimeoutException(Exception):
"""Raised when a timeout is encountered during login."""
pass
class NonWritableParameterException(Exception):
"""Raised when provided parameter is not writable."""
pass
class UnknownPathException(Exception):
"""Raised when provided path does not exist."""
pass
class MaximumSessionCountException(Exception):
"""Raised when the maximum session count is reached."""
pass
# Exceptions provided by this library
# TODO Validate our own errors
class BadRequestException(Exception):
"""TODO."""
pass
class UnauthorizedException(Exception):
"""TODO."""
pass
class UnknownException(Exception):
"""TODO."""
pass

164
sagemcom_api/models.py Normal file
View File

@@ -0,0 +1,164 @@
"""Models for the Sagemcom F@st client."""
import dataclasses
from dataclasses import dataclass
from typing import Any, List, Optional
@dataclass
class Device:
"""Device connected to a router."""
uid: Optional[int] = None
alias: Optional[str] = None
phys_address: Optional[str] = None
ip_address: Optional[str] = None
address_source: Optional[str] = None
dhcp_client: Optional[str] = None
lease_time_remaining: Optional[int] = None
associated_device: Optional[Any] = None
layer1_interface: Optional[Any] = None
layer3_interface: Optional[Any] = None
vendor_class_id: Optional[Any] = None
client_id: Optional[Any] = None
user_class_id: Optional[Any] = None
host_name: Optional[Any] = None
active: Optional[bool] = None
lease_start: Optional[int] = None
lease_duration: Optional[int] = None
interface_type: Optional[str] = None # enum!
detected_device_type: Optional[str] = None
active_last_change: Optional[Any] = None
user_friendly_name: Optional[str] = None
user_host_name: Optional[str] = None
user_device_type: Optional[Any] = None # enum!
icon: Optional[Any] = None
room: Optional[Any] = None
blacklist_enable: Optional[bool] = None
blacklisted: Optional[bool] = None
unblock_hours_count: Optional[int] = None
blacklist_status: Optional[bool] = None
blacklisted_according_to_schedule: Optional[bool] = None
blacklisted_schedule: Optional[List] = None
hidden: Optional[bool] = None
options: Optional[List] = None
vendor_class_idv6: Optional[Any] = None
ipv4_addresses: Optional[List] = None
ipv6_addresses: Optional[List] = None
device_type_association: Optional[Any] = None
# TODO Remove extra kwargs before init
def __init__(self, **kwargs):
"""Override to accept more args than specified."""
names = {f.name for f in dataclasses.fields(self)}
for k, v in kwargs.items():
if k in names:
setattr(self, k, v)
@property
def id(self):
"""Return unique ID for device."""
return self.phys_address.upper()
@property
def name(self):
"""Return name of the device."""
return self.user_host_name or self.host_name
@dataclass
class DeviceInfo:
"""Sagemcom Router representation."""
mac_address: str
serial_number: Optional[str] = None
manufacturer: Optional[Any] = None
model_name: Optional[Any] = None
model_number: Optional[Any] = None
software_version: Optional[str] = None
hardware_version: Optional[str] = None
up_time: Optional[Any] = None
reboot_count: Optional[Any] = None
router_name: Optional[Any] = None
bootloader_version: Optional[Any] = None
device_category: Optional[Any] = None
manufacturer_oui: Optional[Any] = None
product_class: Optional[str] = None
description: Optional[str] = None
additional_hardware_version: Optional[str] = None
additional_software_version: Optional[str] = None
external_firmware_version: Optional[str] = None
internal_firmware_version: Optional[str] = None
gui_firmware_version: Optional[str] = None
guiapi_version: Optional[float] = None
provisioning_code: Optional[str] = None
up_time: Optional[int] = None
first_use_date: Optional[str] = None
mac_address: Optional[str] = None
mode: Optional[str] = None
country: Optional[str] = None
reboot_count: Optional[int] = None
nodes_to_restore: Optional[str] = None
router_name: Optional[str] = None
reboot_status: Optional[float] = None
reset_status: Optional[float] = None
update_status: Optional[float] = None
SNMP: Optional[bool] = None
first_connection: Optional[bool] = None
build_date: Optional[str] = None
spec_version: Optional[str] = None
CLID: Optional[str] = None
flush_device_log: Optional[bool] = None
locations: Optional[str] = None
api_version: Optional[str] = None
# TODO Remove extra kwargs before init
def __init__(self, **kwargs):
"""Override to accept more args than specified."""
names = {f.name for f in dataclasses.fields(self)}
for k, v in kwargs.items():
if k in names:
setattr(self, k, v)
@property
def id(self):
"""Return unique ID for gateway."""
return self.mac_address
@dataclass
class PortMapping:
"""Port Mapping representation."""
uid: int
enable: bool
status: Optional[str] = None # Enum
alias: Optional[str] = None
external_interface: Optional[str] = None
all_external_interfaces: Optional[bool] = None
lease_duration: Optional[int] = None
external_port: Optional[int] = None
external_port_end_range: Optional[int] = None
internal_interface: Optional[str] = None
internal_port: Optional[int] = None
protocol: Optional[str] = None
service: Optional[str] = None
internal_client: Optional[str] = None
public_ip: Optional[str] = None
description: Optional[str] = None
creator: Optional[str] = None
target: Optional[str] = None
lease_start: Optional[str] = None # Date?
# TODO Remove extra kwargs before init
def __init__(self, **kwargs):
"""Override to accept more args than specified."""
names = {f.name for f in dataclasses.fields(self)}
for k, v in kwargs.items():
if k in names:
setattr(self, k, v)
@property
def id(self):
"""Return unique ID for port mapping."""
return self.uid

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test for Sagemcom F@st client."""

8
tests/test_sagemcom.py Normal file
View File

@@ -0,0 +1,8 @@
"""Test for Sagemcom F@st client."""
from sagemcom_api import __version__
def test_version():
"""Test if version number is 1.0.0."""
assert __version__ == "1.0.0"

31
uickstart.py Normal file
View File

@@ -0,0 +1,31 @@
import asyncio
from sagemcom_api.enums import EncryptionMethod
from sagemcom_api.client import SagemcomClient
HOST = "192.168.0.1"
USERNAME = "admin"
PASSWORD = "DJZZEZMX"
ENCRYPTION_METHOD = EncryptionMethod.MD5 # or EncryptionMethod.SHA512
async def main() -> None:
async with SagemcomClient(HOST, USERNAME, PASSWORD, ENCRYPTION_METHOD) as client:
try:
await client.login()
except Exception as exception: # pylint: disable=broad-except
print(exception)
return
# Print device information of Sagemcom F@st router
device_info = await client.get_device_info()
print(f"{device_info.id} {device_info.model_name}")
# Print connected devices
devices = await client.get_hosts()
for device in devices:
if device.active:
print(f"{device.id} - {device.name}")
output= await client.reboot()
print(output)
test = input()
asyncio.run(main())