├── examples ├── __init__.py ├── guest_fun.py ├── wifi_fun.py └── modem_setup.py ├── pytest.ini ├── requirements.txt ├── requirements_test.txt ├── docs ├── wireless_signal_page.JPG ├── wifi_configuration_page.JPG ├── Compal_CH7465LG_Evaluation_Report_1.1.pdf ├── fcc │ ├── FCCID.io-3118373-TG2492-external_photos.pdf │ └── FCCID.io-3118374-TG2492-internal_photos.pdf └── notes.md ├── .flake8 ├── .gitignore ├── tests ├── test_function_ids_are_unique.py └── test_login_response_parsing.py ├── pyproject.toml ├── MANIFEST.in ├── .pre-commit-config.yaml ├── tox.ini ├── LICENSE.txt ├── setup.py ├── pylintrc ├── compal ├── functions.py ├── models.py └── __init__.py ├── azure-pipelines.yml └── README.md /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | junit_family=xunit2 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.23.0 2 | lxml>=4.5.0 3 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort[pyproject] 3 | responses 4 | tox 5 | -------------------------------------------------------------------------------- /docs/wireless_signal_page.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/compal_CH7465LG_py/master/docs/wireless_signal_page.JPG -------------------------------------------------------------------------------- /docs/wifi_configuration_page.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/compal_CH7465LG_py/master/docs/wifi_configuration_page.JPG -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F403, F401 3 | max-line-length = 89 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /docs/Compal_CH7465LG_Evaluation_Report_1.1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/compal_CH7465LG_py/master/docs/Compal_CH7465LG_Evaluation_Report_1.1.pdf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .python-version 3 | .tox 4 | compal.egg-info/**/* 5 | build 6 | .dist 7 | dist 8 | test-output.xml 9 | pip-wheel-metadata 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /docs/fcc/FCCID.io-3118373-TG2492-external_photos.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/compal_CH7465LG_py/master/docs/fcc/FCCID.io-3118373-TG2492-external_photos.pdf -------------------------------------------------------------------------------- /docs/fcc/FCCID.io-3118374-TG2492-internal_photos.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/compal_CH7465LG_py/master/docs/fcc/FCCID.io-3118374-TG2492-internal_photos.pdf -------------------------------------------------------------------------------- /tests/test_function_ids_are_unique.py: -------------------------------------------------------------------------------- 1 | from compal.functions import GetFunction, SetFunction 2 | 3 | 4 | def validate_key_value_object(key_value_object): 5 | known_keys = set() 6 | known_values = set() 7 | 8 | for attr, value in vars(GetFunction).items(): 9 | assert attr not in known_keys 10 | assert value not in known_values 11 | 12 | known_keys.add(attr) 13 | known_values.add(value) 14 | 15 | 16 | def test_get_functions_are_unique(): 17 | validate_key_value_object(GetFunction) 18 | 19 | 20 | def test_set_functions_are_unique(): 21 | validate_key_value_object(SetFunction) 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py37"] 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.hg 8 | | \.mypy_cache 9 | | \.tox 10 | | \.venv 11 | | _build 12 | | buck-out 13 | | build 14 | | dist 15 | 16 | # The following are specific to Black, you probably don't want those. 17 | | blib2to3 18 | | tests/data 19 | )/ 20 | ''' 21 | 22 | [tool.isort] 23 | multi_line_output = 3 24 | include_trailing_comma = true 25 | force_grid_wrap = 0 26 | use_parentheses = true 27 | line_length = 88 28 | 29 | [settings] 30 | known_third_party = ["lxml", "requests", "responses", "setuptools"] 31 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft compal 2 | graft tests 3 | graft docs 4 | graft examples 5 | 6 | include README.md 7 | include LICENSE.txt 8 | 9 | include .coveragerc .flake8 setup.cfg pyproject.toml 10 | include .pre-commit-config.yaml pylintrc 11 | include tox.ini appveyor.yml .travis.yml rtd.txt 12 | include contributing.md RELEASING.txt HACKING.txt TODO.txt 13 | include azure-pipelines.yml pytest.ini 14 | graft .github 15 | 16 | exclude requirements.txt requirements_test.txt 17 | exclude pyproject.toml 18 | exclude docs/**/*.pdf docs/**/*.jpg docs/**/*.JPG 19 | 20 | exclude venv 21 | 22 | global-exclude __pycache__ *.py[cod] 23 | global-exclude .DS_Store 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/ambv/black 5 | rev: stable 6 | hooks: 7 | - id: black 8 | language_version: python3 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v2.0.0 11 | hooks: 12 | - id: flake8 13 | - repo: https://github.com/asottile/seed-isort-config 14 | rev: v2.1.1 15 | hooks: 16 | - id: seed-isort-config 17 | - repo: https://github.com/timothycrosley/isort 18 | rev: '' # Use the revision sha / tag you want to point at 19 | hooks: 20 | - id: isort 21 | additional_dependencies: [toml] 22 | -------------------------------------------------------------------------------- /tests/test_login_response_parsing.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from compal import Compal 4 | 5 | 6 | def load_login_responses(ip): 7 | # Response required by constructor 8 | responses.add( 9 | responses.Response( 10 | method="GET", 11 | url=f"http://{ip}/", 12 | status=302, 13 | headers={"Location": "../common_page/login.html"}, 14 | ) 15 | ) 16 | responses.add(responses.GET, f"http://{ip}/common_page/login.html", body="dummy") 17 | 18 | # Successful login response 19 | responses.add( 20 | responses.POST, f"http://{ip}/xml/setter.xml", body="successful;SID=1025573888" 21 | ) 22 | 23 | 24 | @responses.activate 25 | def test_successful_response(): 26 | load_login_responses("router") 27 | router = Compal("router", "1234") 28 | router.login() 29 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # from 2 | [tox] 3 | envlist = lint 4 | skip_missing_interpreters = True 5 | 6 | [testenv] 7 | setenv = 8 | ; both temper-python and XBee modules have utf8 in their README files 9 | ; which get read in from setup.py. If we don't force our locale to a 10 | ; utf8 one, tox's env is reset. And the install of these 2 packages 11 | ; fail. 12 | LANG=C.UTF-8 13 | PYTHONPATH = {toxinidir}:{toxinidir}/compal 14 | commands = 15 | py.test 16 | deps = 17 | -r{toxinidir}/requirements.txt 18 | -r{toxinidir}/requirements_test.txt 19 | 20 | [testenv:lint] 21 | skip_install = true 22 | commands = 23 | flake8 compal setup.py 24 | black --check --diff compal setup.py 25 | python setup.py sdist 26 | twine check dist/* 27 | check-manifest 28 | deps = 29 | flake8 30 | black 31 | isort[pyproject] 32 | readme_renderer 33 | twine 34 | check-manifest 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2016 Ties de Kock 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | 9 | 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ Set up script """ 2 | 3 | import os 4 | 5 | from setuptools import find_packages, setup 6 | 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | 10 | with open(os.path.join(here, "README.md"), "rb") as f: 11 | long_descr = f.read().decode("utf-8") 12 | 13 | 14 | setup( 15 | name="compal", 16 | version="0.6.0", 17 | author="Ties de Kock", 18 | author_email="ties@tiesdekock.nl", 19 | description="Compal CH7465LG/Ziggo Connect Box client", 20 | long_description_content_type="text/markdown", 21 | long_description=long_descr, 22 | url="https://github.com/ties/compal_CH7465LG_py", 23 | entry_points={}, 24 | install_requires=["requests", "lxml"], 25 | include_package_data=True, 26 | python_requires=">=3.7", 27 | license="MIT", 28 | keywords="compal CH7465LG connect box cablemodem", 29 | packages=find_packages(exclude=["examples", "tests", "tests.*"]), 30 | classifiers=[ 31 | "Development Status :: 5 - Production/Stable", 32 | "Topic :: Software Development :: Libraries", 33 | "License :: OSI Approved :: MIT License", 34 | ], 35 | ) 36 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | reports=no 3 | 4 | # Reasons disabled: 5 | # locally-disabled - it spams too much 6 | # duplicate-code - unavoidable 7 | # cyclic-import - doesn't test if both import on load 8 | # abstract-class-little-used - prevents from setting right foundation 9 | # abstract-class-not-used - is flaky, should not show up but does 10 | # unused-argument - generic callbacks and setup methods create a lot of warnings 11 | # global-statement - used for the on-demand requirement installation 12 | # redefined-variable-type - this is Python, we're duck typing! 13 | # too-many-* - are not enforced for the sake of readability 14 | # too-few-* - same as too-many-* 15 | # abstract-method - with intro of async there are always methods missing 16 | ignore=examples 17 | 18 | disable= 19 | locally-disabled, 20 | duplicate-code, 21 | cyclic-import, 22 | abstract-class-little-used, 23 | abstract-class-not-used, 24 | unused-argument, 25 | global-statement, 26 | redefined-variable-type, 27 | too-many-arguments, 28 | too-many-branches, 29 | too-many-instance-attributes, 30 | too-many-locals, 31 | too-many-public-methods, 32 | too-many-return-statements, 33 | too-many-statements, 34 | too-few-public-methods, 35 | abstract-method 36 | 37 | extension-pkg-whitelist=lxml 38 | 39 | 40 | [EXCEPTIONS] 41 | overgeneral-exceptions=Exception 42 | -------------------------------------------------------------------------------- /examples/guest_fun.py: -------------------------------------------------------------------------------- 1 | """ 2 | Toggles the enabling state of the 3rd 2g-interface guest network (the one also editable via the UI). 3 | """ 4 | import argparse 5 | import os 6 | import pprint 7 | import sys 8 | 9 | from compal import (Compal, DHCPSettings, PortForwards, Proto, # noqa 10 | WifiGuestNetworkSettings, WifiSettings) 11 | 12 | # Push the parent directory onto PYTHONPATH before compal module is imported 13 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 14 | 15 | 16 | def modem_setup(host, passwd): 17 | modem = Compal(host, passwd) 18 | modem.login() 19 | 20 | guest = WifiGuestNetworkSettings(modem) 21 | settings = guest.wifi_guest_network_settings 22 | 23 | old_enabling_state = settings.enabling_2g.enabled 24 | pprint.pprint('Current GUEST-NETWORK state: ' + ('ON' if old_enabling_state else 'OFF')) 25 | 26 | guest.update_wifi_guest_network_settings(settings.properties, not old_enabling_state) 27 | 28 | settings = guest.wifi_guest_network_settings 29 | new_enabling_state = settings.enabling_2g.enabled 30 | pprint.pprint('New GUEST-NETWORK state: ' + ('ON' if new_enabling_state else 'OFF')) 31 | pprint.pprint(settings) 32 | 33 | modem.logout() 34 | 35 | 36 | if __name__ == "__main__": 37 | parser = argparse.ArgumentParser(description="Connect Box configuration") 38 | parser.add_argument("--host", type=str, default=os.environ.get("CB_HOST", None)) 39 | parser.add_argument( 40 | "--password", type=str, default=os.environ.get("CB_PASSWD", None) 41 | ) 42 | 43 | args = parser.parse_args() 44 | 45 | modem_setup(args.host, args.password) 46 | -------------------------------------------------------------------------------- /examples/wifi_fun.py: -------------------------------------------------------------------------------- 1 | """ 2 | Set 'creative' WiFi SSID's using the calls. 3 | 4 | The interface is quite permissive with what it accepts. When you use special 5 | (unicode) characters, smileys, or other inputs, strange things happen. 6 | 7 | """ 8 | import argparse 9 | import os 10 | import pprint 11 | import sys 12 | 13 | from compal import (Compal, DHCPSettings, PortForwards, Proto, # noqa 14 | WifiSettings) 15 | 16 | # Push the parent directory onto PYTHONPATH before compal module is imported 17 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 18 | 19 | 20 | def modem_setup(host, passwd, wifi_passwd): 21 | modem = Compal(host, passwd) 22 | modem.login() 23 | 24 | # And/or change wifi settings 25 | wifi = WifiSettings(modem) 26 | settings = wifi.wifi_settings 27 | 28 | settings.radio_2g.ssid = "🏚 Open" # \u1F3DA' 29 | settings.radio_2g.mode = 1 30 | settings.radio_2g.security = 0 31 | # 8 = WPA2/PSK 32 | # 20/40MHz 33 | settings.radio_5g.ssid = "\U0001F916\u2028\u2028\u0085🏚\u00A0 Open" 34 | settings.radio_5g.mode = 1 35 | settings.radio_5g.security = 0 36 | 37 | if settings.radio_5g.security == 0 or settings.radio_2g.security == 0: 38 | print("[warning]: WiFi security is disabled") 39 | 40 | settings.radio_2g.pre_shared_key = wifi_passwd 41 | settings.radio_5g.pre_shared_key = wifi_passwd 42 | 43 | wifi.update_wifi_settings(settings) 44 | 45 | wifi = WifiSettings(modem) 46 | settings = wifi.wifi_settings 47 | 48 | pprint.pprint(settings) 49 | 50 | modem.logout() 51 | 52 | 53 | if __name__ == "__main__": 54 | parser = argparse.ArgumentParser(description="Connect Box configuration") 55 | parser.add_argument("--host", type=str, default=os.environ.get("CB_HOST", None)) 56 | parser.add_argument( 57 | "--password", type=str, default=os.environ.get("CB_PASSWD", None) 58 | ) 59 | 60 | parser.add_argument( 61 | "--wifi_pw", type=str, default=os.environ.get("CB_WIFI_PASSWD", None) 62 | ) 63 | 64 | args = parser.parse_args() 65 | 66 | modem_setup(args.host, args.password, args.wifi_pw) 67 | -------------------------------------------------------------------------------- /compal/functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants that define the functions of the modem 3 | """ 4 | 5 | 6 | class SetFunction: 7 | """ 8 | Constants for the setters the modem in the modem's internal API 9 | """ 10 | 11 | LANGUAGE = 4 12 | FACTORY_RESET = 7 13 | LOGIN = 15 14 | LOGOUT = 16 15 | CHANGE_PASSWORD = 18 16 | INSTALL_DONE = 20 17 | UPNP_STATUS = 101 18 | DHCP_V6 = 104 19 | DHCP_V4 = 106 20 | NAT_MODE = 108 21 | FILTER_RULE = 110 22 | IPV6_FILTER_RULE = 112 23 | FIREWALL = 116 24 | MACFILTER = 120 25 | PORT_FORWARDING = 122 26 | REBOOT = 133 27 | PING_TEST = 126 28 | TRACEROUTE = 127 29 | STOP_DIAGNOSTIC = 130 30 | REMOTE_ACCESS = 132 31 | MTU_SIZE = 135 32 | SET_EMAIL = 138 33 | SEND_EMAIL = 139 34 | PARENTAL_CONTROL = 141 35 | STATIC_DHCP_LEASE = 148 36 | WIFI_CONFIGURATION = 301 37 | WIFI_GUEST_NETWORK_CONFIGURATION = 308 38 | WIFI_SIGNAL = 319 39 | 40 | 41 | class GetFunction: 42 | """ 43 | Constants for the getters in the modem's internal API 44 | """ 45 | 46 | GLOBALSETTINGS = 1 47 | CM_SYSTEM_INFO = 2 48 | MULTILANG = 3 49 | STATUS = 5 50 | CONFIGURATION = 6 51 | DOWNSTREAM_TABLE = 10 52 | UPSTREAM_TABLE = 11 53 | SIGNAL_TABLE = 12 54 | EVENTLOG_TABLE = 13 55 | FIREWALLLOG_TABLE = 19 56 | LANGSETLIST = 21 57 | FAIL = 22 58 | LOGIN_TIMER = 24 59 | LANSETTING = 100 60 | DHCPV6INFO = 103 61 | BASICDHCP = 105 62 | WANSETTING = 107 63 | IPFILTERING = 109 64 | IPV6FILTERING = 111 65 | PORTTRIGGER = 113 66 | WEBFILTER = 115 67 | IPV6WEBFILTER = 117 68 | MACFILTERING = 119 69 | FORWARDING = 121 70 | LANUSERTABLE = 123 71 | DDNS = 124 72 | PING_RESULT = 128 73 | TRACEROUTE_RESULT = 129 74 | REMOTEACCESS = 131 75 | MTUSIZE = 134 76 | CMSTATE = 136 77 | WIREDSTATE = 137 78 | PARENTALCTL = 140 79 | WIREDSTATE_2 = 143 80 | CMSTATUS = 144 81 | ETHFLAPLIST = 147 82 | WIRELESSBASIC = 300 83 | WIRELESSWMM = 302 84 | WIRELESSSITESURVEY = 305 85 | WIRELESSGUESTNETWORK = 307 86 | CM_WIRELESSWPS = 309 87 | CM_WIRELESSACCESSCONTROL = 311 88 | CHANNELMAP = 313 89 | WIRELESSBASIC_2 = 315 90 | WIRELESSGUESTNETWORK_2 = 317 91 | WIRELESSCLIENT = 322 92 | CM_WIRELESSWPS_2 = 323 93 | DEFAULTVALUE = 324 94 | GSTRANDOMPASSWORD = 325 95 | WIFISTATE = 326 96 | WIRELESSRESETTING = 328 97 | STATUS_2 = 500 98 | QOSLIST = 502 99 | MTAEVENTLOGS = 503 100 | PROVIVSIONING = 504 101 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Python package 2 | # Create and test a Python package on multiple Python versions. 3 | # Add steps that analyze code, save the dist with the build record, publish to a PyPI-compatible index, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/python 5 | 6 | trigger: 7 | batch: true 8 | branches: 9 | include: 10 | - rc 11 | - dev 12 | - master 13 | pr: 14 | - rc 15 | - dev 16 | - master 17 | 18 | resources: 19 | containers: 20 | - container: py37 21 | image: python:3.7 22 | 23 | - container: py38 24 | image: python:3.8 25 | 26 | - container: py39 27 | image: python:3.9 28 | 29 | stages: 30 | - stage: 'Test' 31 | jobs: 32 | - job: 'Test' 33 | pool: 34 | vmImage: 'ubuntu-latest' 35 | strategy: 36 | maxParallel: 3 37 | matrix: 38 | Python37: 39 | containerResource: py37 40 | Python38: 41 | containerResource: py38 42 | Python39: 43 | containerResource: py39 44 | 45 | container: $[ variables['containerResource']] 46 | 47 | steps: 48 | - script: | 49 | python -m venv venv 50 | . venv/bin/activate 51 | 52 | pip install -r requirements.txt 53 | displayName: 'Install dependencies' 54 | - script: | 55 | . venv/bin/activate 56 | 57 | pip install -e . 58 | displayName: 'Install' 59 | - script: | 60 | . venv/bin/activate 61 | 62 | pip install -r requirements_test.txt 63 | pip install pytest pytest-azurepipelines pytest-cov pytest-xdist 64 | pytest --cov compal --cov-report html -qq -o console_output_style=count -p no:sugar tests 65 | displayName: 'pytest' 66 | # Dirty but low-effort way to run the same steps that tox does: 67 | # This could be a lint and a test stage 68 | - script: | 69 | . venv/bin/activate 70 | 71 | pip install tox 72 | tox 73 | 74 | - stage: 'Dist' 75 | dependsOn: 'Test' 76 | jobs: 77 | - job: 'BuildDist' 78 | pool: 79 | vmImage: 'ubuntu-latest' 80 | strategy: 81 | matrix: 82 | Python39: 83 | containerResource: py39 84 | container: $[ variables['containerResource']] 85 | 86 | steps: 87 | - script: | 88 | python -m venv venv 89 | . venv/bin/activate 90 | 91 | pip install -r requirements.txt 92 | pip install readme_renderer twine check-manifest 93 | displayName: 'Install dependencies' 94 | - script: | 95 | . venv/bin/activate 96 | twine check dist/* 97 | check-manifest 98 | - script: | 99 | . venv/bin/activate 100 | 101 | python setup.py sdist 102 | displayName: 'Build dist' 103 | - task: PublishPipelineArtifact@1 104 | inputs: 105 | targetPath: 'dist' 106 | 107 | -------------------------------------------------------------------------------- /examples/modem_setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provioning my Ziggo modem using the Compal/Ziggo Connect Box [web interface 3 | wrapper](https://github.com/ties/compal_CH7465LG_py). 4 | """ 5 | import argparse 6 | import os 7 | import sys 8 | import time 9 | 10 | from compal import (Compal, DHCPSettings, PortForwards, Proto, # noqa 11 | WifiSettings) 12 | 13 | # Push the parent directory onto PYTHONPATH before compal module is imported 14 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 15 | 16 | 17 | def modem_setup(host, passwd, wifi_passwd, factory_reset=False): 18 | print("Attempting connection to %s with password: %s" % (host, passwd)) 19 | try: 20 | modem = Compal(host, passwd) 21 | modem.login() 22 | except Exception as err: 23 | print("Login to modem failed! Error: %s" % err) 24 | return 25 | 26 | if factory_reset: 27 | # Factory reset 28 | print("Performing factory reset...") 29 | modem.factory_reset() 30 | print("Sleeping for 5 minutes") 31 | time.sleep(300) 32 | 33 | # New connection + login again 34 | print("Logging in again...") 35 | modem = Compal(host, passwd) 36 | modem.login() 37 | 38 | # And/or change wifi settings 39 | wifi = WifiSettings(modem) 40 | settings = wifi.wifi_settings 41 | 42 | if wifi_passwd: 43 | settings.radio_2g.ssid = "modem_setup-2.4" 44 | settings.radio_2g.mode = False 45 | settings.radio_2g.security = 8 46 | # 20/40MHz 47 | settings.radio_2g.bandwidth = 2 48 | settings.radio_5g.ssid = "modem_setup-5" 49 | settings.radio_5g.mode = False 50 | settings.radio_5g.security = 8 51 | 52 | settings.radio_2g.pre_shared_key = wifi_passwd 53 | settings.radio_5g.pre_shared_key = wifi_passwd 54 | 55 | wifi.update_wifi_settings(settings) 56 | 57 | dhcp = DHCPSettings(modem) 58 | dhcp.add_static_lease("192.168.178.17", "d0:50:99:0a:65:52") 59 | dhcp.add_static_lease("192.168.178.16", "BC:5F:F4:FE:05:15") 60 | dhcp.set_upnp_status(False) 61 | 62 | fw = PortForwards(modem) 63 | # Disable the firewall 64 | fw.update_firewall(enabled=True) 65 | 66 | # Delete all old rules 67 | rules = list(fw.rules) 68 | for rule in rules: 69 | rule.delete = True 70 | 71 | fw.update_rules(rules) 72 | 73 | # Create the new forwards 74 | fw.add_forward("192.168.178.17", 80, 80, Proto.tcp) 75 | fw.add_forward("192.168.178.17", 1022, 22, Proto.tcp) 76 | fw.add_forward("192.168.178.17", 443, 443, Proto.tcp) 77 | fw.add_forward("192.168.178.17", 32400, 32400, Proto.tcp) 78 | 79 | modem.logout() 80 | 81 | 82 | if __name__ == "__main__": 83 | parser = argparse.ArgumentParser(description="Connect Box configuration") 84 | parser.add_argument("--factory_reset", action="store_true", default=False) 85 | parser.add_argument("--host", type=str, default=os.environ.get("CB_HOST", None)) 86 | parser.add_argument( 87 | "--password", type=str, default=os.environ.get("CB_PASSWD", None) 88 | ) 89 | 90 | parser.add_argument( 91 | "--wifi_pw", type=str, default=os.environ.get("CB_WIFI_PASSWD", None) 92 | ) 93 | 94 | args = parser.parse_args() 95 | 96 | modem_setup(args.host, args.password, args.wifi_pw, args.factory_reset) 97 | -------------------------------------------------------------------------------- /compal/models.py: -------------------------------------------------------------------------------- 1 | """Objects used by CH7465LG""" 2 | 3 | from dataclasses import dataclass 4 | from enum import IntEnum 5 | from typing import List, Optional 6 | 7 | 8 | @dataclass 9 | class SystemInfo: 10 | docsis_mode: Optional[str] = None 11 | hardware_version: Optional[str] = None 12 | mac_address: Optional[str] = None 13 | serial_number: Optional[str] = None 14 | uptime: Optional[int] = None 15 | network_access: Optional[str] = None 16 | 17 | 18 | @dataclass 19 | class BandSetting: 20 | radio: Optional[int] = None 21 | bss_enable: Optional[int] = None 22 | ssid: Optional[str] = None 23 | hidden: Optional[str] = None 24 | bandwidth: Optional[int] = None 25 | tx_rate: Optional[int] = None 26 | tx_mode: Optional[int] = None 27 | security: Optional[int] = None 28 | multicast_rate: Optional[int] = None 29 | channel: Optional[int] = None 30 | pre_shared_key: Optional[str] = None 31 | re_key: Optional[str] = None 32 | wpa_algorithm: Optional[int] = None 33 | 34 | 35 | @dataclass 36 | class RadioSettings: 37 | nv_country: Optional[int] = None 38 | band_mode: Optional[int] = None 39 | channel_range: Optional[int] = None 40 | bss_coexistence: Optional[int] = None 41 | son_admin_status: Optional[int] = None 42 | smart_wifi: Optional[int] = None 43 | radio_2g: Optional[BandSetting] = None 44 | radio_5g: Optional[BandSetting] = None 45 | 46 | 47 | @dataclass 48 | class GuestNetworkEnabling: 49 | enabled: Optional[bool] = None 50 | guest_mac: Optional[str] = None 51 | 52 | 53 | @dataclass 54 | class GuestNetworkProperties: 55 | ssid: Optional[str] = None 56 | hidden: Optional[int] = None 57 | re_key: Optional[int] = None 58 | security: Optional[int] = None 59 | pre_shared_key: Optional[str] = None 60 | wpa_algorithm: Optional[int] = None 61 | 62 | 63 | @dataclass 64 | class GuestNetworkSettings: 65 | enabling_2g: GuestNetworkEnabling 66 | enabling_5g: GuestNetworkEnabling 67 | properties: GuestNetworkProperties 68 | 69 | 70 | class FilterAction(IntEnum): 71 | """ 72 | Filter action, used by internet access filters 73 | """ 74 | 75 | add = 1 76 | delete = 2 77 | enable = 3 78 | 79 | 80 | class NatMode(IntEnum): 81 | """ 82 | Values for NAT-Mode 83 | """ 84 | 85 | enabled = 1 86 | disabled = 2 87 | 88 | 89 | class FilterIpRange(IntEnum): 90 | """ 91 | Filter rule ip range enum 92 | """ 93 | 94 | all = 0 95 | single = 1 96 | range = 2 97 | 98 | 99 | class RuleDir(IntEnum): 100 | """ 101 | Filter rule direction 102 | """ 103 | 104 | incoming = 0 105 | outgoing = 1 106 | 107 | 108 | class IPv6FilterRuleProto(IntEnum): 109 | """ 110 | protocol (from form): 111 | """ 112 | 113 | all = 0 114 | udp = 1 115 | tcp = 2 116 | udp_tcp = 3 117 | icmpv6 = 4 118 | esp = 5 119 | ah = 6 120 | gre = 7 121 | ipv6encap = 8 122 | ipv4encap = 9 123 | ipv6fragment = 10 124 | l2tp = 11 125 | 126 | 127 | @dataclass 128 | class IPv6FilterRule: 129 | dir: Optional[RuleDir] = None 130 | idd: Optional[int] = None 131 | src_addr: Optional[str] = None 132 | src_prefix: Optional[int] = None 133 | dst_addr: Optional[str] = None 134 | dst_prefix: Optional[int] = None 135 | src_sport: Optional[int] = None # start port 136 | src_eport: Optional[int] = None # end port 137 | dst_sport: Optional[int] = None # start port 138 | dst_eport: Optional[int] = None # end port 139 | protocol: Optional[IPv6FilterRuleProto] = None 140 | allow: Optional[bool] = None 141 | enabled: Optional[bool] = None 142 | 143 | 144 | @dataclass 145 | class PortForward: 146 | local_ip: Optional[str] = None 147 | ext_port: Optional[int] = None 148 | int_port: Optional[int] = None 149 | proto: Optional[str] = None 150 | enabled: Optional[bool] = None 151 | delete: Optional[bool] = None 152 | idd: Optional[str] = None 153 | id: Optional[str] = None 154 | lan_ip: Optional[str] = None 155 | 156 | 157 | class Proto(IntEnum): 158 | """ 159 | protocol (from form): 1 = tcp, 2 = udp, 3 = both 160 | """ 161 | 162 | tcp = 1 163 | udp = 2 164 | both = 3 165 | 166 | 167 | class TimerMode(IntEnum): 168 | """ 169 | Timermodes used for internet access filtering 170 | """ 171 | 172 | generaltime = 1 173 | dailytime = 2 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Compal CH7465LG (Ziggo Connect Box) tools 2 | ============================================= 3 | 4 | This repository contains a simple api to wrap the web interface of the Ziggo Connect Box (i.e. the 5 | Compal CH7465LG). It is implemented in **Python >= 3.7**. 6 | 7 | At the moment it *only* contains the functionality that I needed while I was investigating my 8 | device, but pull requests that improve the documentation or add features are welcome. 9 | 10 | About the hardware 11 | ------------------ 12 | Compal does not provide information about the hardware. The modem has no FCC registration. 13 | However, the related Arris TG2492 modem was submitted to the FCC. The FCC documents for this 14 | modem are [available][0]. Some interesting documents (internal photos) have been mirrored to 15 | `docs/fcc`. 16 | danman [performed][2] an (excellent) analysis of the modem where the procedure for extracting 17 | the content of the firmware and modifying it is discussed. This writeup also examines the 18 | DOCSIS certificates used. 19 | 20 | The modem seems to be based on the Intel Puma 6 chipset. There is a long thead on (perceived) 21 | performance problems caused by jitter on DSLReports. See [[ALL] SB6190 is a terrible modem - Intel Puma 6 / MaxLinear mistake][1] 22 | 23 | 24 | The modem *most likely* contains open source components. Requests to Compal requesting source 25 | code of these components, to an e-mail address on the Compal site, have not been answered yet. 26 | 27 | [0]: https://fccid.io/UIDTG2492 28 | [1]: https://www.dslreports.com/forum/r31079834-ALL-SB6190-is-a-terrible-modem-Intel-Puma-6-MaxLinear-mistake 29 | [2]: https://blog.danman.eu/about-adding-a-static-route-to-my-docsis-modem/ 30 | 31 | Changelog 32 | --------- 33 | 34 | ### 0.6.0 35 | * Support for static DHCP leases was added by @do3cc 36 | 37 | ### 0.5.1 38 | * Support for hashed (single-sha256) passwords was added by @7FM 39 | 40 | ### 0.5.0 41 | * Added support for get/create/disable/delete IPv6 filter rules by @7FM 42 | 43 | ### 0.4.0: 44 | * Updated guest network settings for firmware [6.15.30-1p3-1-NOSH](https://github.com/ties/compal_CH7465LG_py/pull/32) by @frimtec. 45 | 46 | ### 0.3.2: 47 | * Add [system information methods](https://github.com/ties/compal_CH7465LG_py/pull/28) 48 | by @reitermarkus. 49 | 50 | ### 0.3.1: 51 | * [Fix](https://github.com/ties/compal_CH7465LG_py/pull/26) for [python url parsing change](https://bugs.python.org/issue42967) by @Kiskae. 52 | 53 | ### 0.3.0: 54 | * Guest network settings added by @frimtec. 55 | 56 | Security 57 | -------- 58 | A security evaluation of the Connect Box was [posted](https://packetstormsecurity.com/files/137996/compalch7465lglc-bypassexec.txt) 59 | on-line. This report is included in the `docs` folder. 60 | 61 | How to use it? 62 | -------------- 63 | The `examples` directory contains some example scripts. My main use case is re-provisioning the 64 | modem. An example script for this task is included. 65 | 66 | Want to get started really quickly? 67 | ```python 68 | import os 69 | import time 70 | from compal import * 71 | 72 | modem = Compal('192.168.178.1', os.environ['ROUTER_CODE']) 73 | modem.login() 74 | 75 | fw = PortForwards(modem) 76 | 77 | def toggle_all_rules(fw, goal): 78 | rules = list(fw.rules) 79 | for idx, r in enumerate(rules): 80 | rules[idx] = r._replace(enabled=goal) 81 | 82 | fw.update_rules(rules) 83 | print(list(fw.rules)) 84 | 85 | # Disable all rules 86 | toggle_all_rules(fw, False) 87 | time.sleep(5) 88 | # And re-enable 89 | toggle_all_rules(fw, True) 90 | 91 | # Or find all possible functions of the modem: 92 | scan = FuncScanner(modem, 0, os.environ['ROUTER_CODE']) 93 | while scan.current_pos < 101: 94 | print(scan.scan().text) 95 | 96 | # And/or change wifi settings 97 | wifi = WifiSettings(modem) 98 | settings = wifi.wifi_settings 99 | print(settings) 100 | 101 | new_settings = settings._replace(radio_2g=settings.radio_2g._replace(ssid='api_works')) 102 | wifi.update_wifi_settings(new_settings) 103 | 104 | print(wifi.wifi_settings) 105 | 106 | # And/or Make all dhcp adresses static: 107 | 108 | dhcp = DHCPSettings(modem) 109 | lan_table = LanTable(modem) 110 | for client in (*lan_table.get_lan(), *lan_table.get_wifi()): 111 | dhcp.add_static_lease( 112 | lease_ip=client["IPv4Addr"].split("/")[0], lease_mac=client["MACAddr"] 113 | ) 114 | 115 | 116 | # If you want to go back to 'normal': 117 | # modem.reboot() # or 118 | # modem.factory_reset() 119 | 120 | # And logout 121 | modem.logout() 122 | ``` 123 | -------------------------------------------------------------------------------- /docs/notes.md: -------------------------------------------------------------------------------- 1 | POST /xml/getter.xml HTTP/1.1 2 | Host: 192.168.178.1 3 | Connection: keep-alive 4 | Content-Length: 7 5 | Accept: application/xml, text/xml, */*; q=0.01 6 | Origin: http://192.168.178.1 7 | X-Requested-With: XMLHttpRequest 8 | User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2566.0 Safari/537.36 9 | Content-Type: application/x-www-form-urlencoded; charset=UTF-8 10 | DNT: 1 11 | Referer: http://192.168.178.1/ 12 | Accept-Encoding: gzip, deflate 13 | Accept-Language: en-US,en;q=0.8,nl;q=0.6 14 | Cookie: SID=1519658240 15 | 16 | fun=121 17 | 18 | 19 | fun=3 ==> login page 20 | fun=16 ==> logout 21 | 22 | UPNP/DHCP settings 23 | /setter.xml, fun=101 24 | LanIP: 25 | UPnP:2 26 | DHCP_addr_s: 27 | DHCP_addr_e: 28 | subnet_Mask: 29 | DMZ: 30 | DMZenable: 31 | 2 => disabled. 32 | 33 | Firewall settings: 34 | /setter.xml, fun=116 35 | firewallProtection:2 36 | blockIpFragments:2 37 | portScanDetection:2 38 | synFloodDetection:2 39 | IcmpFloodDetection:2 40 | IcmpFloodDetectRate:15 41 | action: 42 | IPv6firewallProtection: 43 | IPv6blockIpFragments: 44 | IPv6portScanDetection: 45 | IPv6synFloodDetection: 46 | IPv6IcmpFloodDetection: 47 | IPv6IcmpFloodDetectRate: 48 | => disabled=2 49 | 50 | 51 | fun = 300+ => wifi settings 52 | 324: default wifi pwd 53 | 54 | 503: MTA/Docsis errors? 55 | 504: MTA Provisioning? 56 | 57 | /setter.xml fun=126: ping 58 | Type: 0 59 | Target_IP: 60 | Ping_size: 64 61 | Num_Ping: 3 62 | Ping_Interval: 1 63 | => 64 | /getter.xml fun=128 65 | Many posts (only fun/token as params) for results. 66 | 67 | /setter.xml fun=127: traceroute 68 | type: 0 69 | Tracert_IP: "hostname" 70 | MaxHops: "30" 71 | DatSize: "32" 72 | BasePort: "33424" 73 | ResolveHost: "0" 74 | => 75 | /getter.xml fun=129 76 | Many posts (only fun/token as params) for results. 77 | 78 | Port forward: 79 | /setter.xml fun=122 80 | action:add 81 | instance: 82 | local_IP:192.168.178.17 83 | start_port:443 84 | end_port:443 85 | start_portIn:443 86 | end_portIn:443 87 | protocol:1 88 | enable:1 89 | delete:0 90 | idd: 91 | 92 | Disable/Enable port forward: 93 | /setter.xml fun=122 94 | action:apply 95 | instance:1*2*3 96 | local_IP: 97 | start_port: 98 | end_port: 99 | start_portIn:** 100 | end_portIn: 101 | protocol:1*1*1 102 | enable:1*1*1 103 | delete:0*0*0 104 | idd:** 105 | 106 | /getter.xml fun=121 107 | Firewall rules (XML) 108 | 109 | Get IP leases 110 | /getter.xml, fun=123 111 | method: 2 => static lease 112 | 113 | Static DHCP leases: 114 | /setter.xml fun=148 115 | token:1246383104 116 | fun:148 117 | data:ADD,,; 118 | 119 | /getter.xml fun=300 120 | Wifi settings 121 | 122 | --- 123 | #### CHANGE WIFI SETTINGS WITH '/setter.xml' `fun= 319` or `fun=301` 124 | 125 | * VARIABLES THAT WILL BE SENT OVER `fun:301` (Wifi Configuration Page): 126 | 127 | `OrderedDict([('wlBandMode2g', var), ('wlBandMode5g', var), ('wlSsid2g', var), ('wlSsid5g', var), ('wlBandwidth2g', var), ('wlBandwidth5g', var), ('wlTxMode2g', var), ('wlTxMode5g', var), ('wlMCastRate2g', var), ('wlMCastRate5g', var), ('wlHiden2g', var), ('wlHiden5g', var), ('wlCoexistence', var), ('wlPSkey2g', var), ('wlPSkey5g', var), ('wlTxrate2g', var), ('wlTxrate5g', var), ('wlRekey2g', var), ('wlRekey5g', var), ('wlChannel2g', var), ('wlChannel5g', var), ('wlSecurity2g', var), ('wlSecurity5g', var), ('wlWpaalg2g', var), ('wlWpaalg5g', var)])` 128 | 129 | * VARIABLES THAT WILL BE SENT OVER `fun:319` (Wireless Signal Page): 130 | 131 | `OrderedDict([('wlBandMode', var), ('wlSsid2g', var), ('wlSsid5g', var),('wlBandwidth2g', var), ('wlBandwidth5g', var), ('wlTxMode2g', var), ('wlTxMode5g', var), ('wlMCastRate2g', var), ('wlMCastRate5g', var), ('wlHiden2g', var), ('wlHiden5g', var), ('wlCoexistence', var), ('wlPSkey2g', var), ('wlPSkey5g', var), ('wlTxrate2g', var), ('wlTxrate5g', var), ('wlRekey2g', var), ('wlRekey5g', var), ('wlChannel2g', var), ('wlChannel5g', var), ('wlSecurity2g', var), ('wlSecurity5g', var), ('wlWpaalg2g', var), ('wlWpaalg5g', var), ('wlSmartWiFi', var)])` 132 | --- 133 | * VARIABLES THAT CAN NOT BE SET/CHANGED OR DON'T GET SENT OVER `fun=301` or `fun= 319` (after tests) : 134 | * **`nv_country (=doesn't get sent over fun:301 or fun:319)`**: So it always stays `1` 135 | * **`band_mode (=wlBandMode)`**: 136 | > This is a special variable that can be set, but not changed (see below `fun:319`) 137 | * **`channel_range (=doesn't get sent over fun:301 or fun:319)`**: So it always stays `1` 138 | * **`bss_coexistence (=wlCoexistence)`**: Stays always `1` 139 | * **`son_admin_status (=doesn't get sent over fun:301 or fun:319)`**: So it always stays `1` 140 | * **`radio_2g.multicast_rate (=wlMCastRate2g)` or `radio_5g.multicast_rate (=wlMCastRate5g)`**: Stays always `1` 141 | * **`radio_2g.tx_rate (=wlTxrate2g)` or `radio_5g.tx_rate (=wlTxrate5g)`**: Stays always `0` 142 | --- 143 | * VARIABLES THAT CAN BE SET OVER `fun:301` (Wifi Configuration Page): 144 | * **`radio_2g.bss_enable (=wlBandMode2g)`**: Possible integer input values -> `1`,`2` 145 | > (`wlBandMode2g=1`) means 2g is on
146 | (`wlBandMode2g=2`) means 2g is off 147 | 148 | _What changes in the router-xml-file is the following:
_ 149 | > FROM: /getter.xml fun=326
150 | `var` // var -> (1=on / 0=off) 151 | * **`radio_5g.bss_enable (=wlBandMode5g)`**: Possible integer input values -> `1`,`2` 152 | > (`wlBandMode5g=1`) means 5g is on
153 | (`wlBandMode5g=2`) means 5g is off 154 | 155 | _What changes in the router-xml-file is the following:
_ 156 | > FROM: /getter.xml fun=326
157 | `var` // var -> (1=on / 0=off) 158 | * **`radio_2g.hidden (=wlHiden2g)`**: 159 | > (`wlHiden2g=1`) means 2g broadcast is on
160 | (`wlHiden2g=2`) means 2g broadcast is off 161 | 162 | _What changes in the router-xml-file is the following:
_ 163 | > FROM: /getter.xml fun=300
164 | `var` // var -> (1=on / 0=off) 165 | * **`radio_5g.hidden (=wlHiden5g)`**: 166 | > (`wlHiden5g=1`) means 5g broadcast is on
167 | (`wlHiden5g=2`) means 5g broadcast is off 168 | 169 | _What changes in the router-xml-file is the following:
_ 170 | > FROM: /getter.xml fun=300
171 | `var` // var -> (1=on / 0=off) 172 | * **`radio_2g.pre_shared_key (=wlPSkey2g)`**: 173 | > (`wlPSkey2g=var`) means setting the password for 2g 174 | 175 | _What changes in the router-xml-file is the following:
_ 176 | > FROM: /getter.xml fun=300
177 | `var` // var -> password 178 | * **`radio_5g.pre_shared_key (=wlPSkey5g)`**: 179 | > (`wlPSkey5g=var`) means setting the password for 5g 180 | 181 | _What changes in the router-xml-file is the following:
_ 182 | > FROM: /getter.xml fun=300
183 | `var` // var -> password 184 | * **`radio_2g.re_key (=wlRekey2g)`**: standard = `0` 185 | 186 | _What changes in the router-xml-file is the following:
_ 187 | > FROM: /getter.xml fun=300
188 | `var` 189 | * **`radio_5g.re_key (=wlRekey5g)`**: standard = `0` 190 | 191 | _What changes in the router-xml-file is the following:
_ 192 | > FROM: /getter.xml fun=300
193 | `var` 194 | * **`radio_2g.security (=wlSecurity2g)`**: Possible integer input values -> `0`,`4`,`8` 195 | > (`wlSecurity2g=0`) means 'Disabled'
196 | (`wlSecurity2g=4`) means 'WPA2-PSK' (router software sets with `radio_2g.security=4` also `radio_2g.wpa_algorithm` to `2`)
197 | (`wlSecurity2g=8`) means 'WPA-PSK/WPA2-PSK' (router software sets with `radio_2g.security=8` also `radio_2g.wpa_algorithm` to `3`)
198 | 199 | _What changes in the router-xml-file is the following:
_ 200 | > FROM: /getter.xml fun=300
201 | `var` 202 | * **`radio_5g.security (=wlSecurity5g)`**: Possible integer input values -> `0`,`4`,`8` 203 | > (`wlSecurity5g=0`) means 'Disabled'
204 | (`wlSecurity5g=4`) means 'WPA2-PSK' (router software sets with `radio_5g.security=4` also `radio_5g.wpa_algorithm` to `2`)
205 | (`wlSecurity5g=8`) means 'WPA-PSK/WPA2-PSK' (router software sets with `radio_5g.security=8` also `radio_5g.wpa_algorithm` to `3`)
206 | 207 | _What changes in the router-xml-file is the following:
_ 208 | > FROM: /getter.xml fun=300
209 | `var` 210 | * **`radio_2g.wpa_algorithm (=wlWpaalg2g)`**: Should be `2` or `3` after router software, depending on security value. See under radio_2g.security. 211 | 212 | _What changes in the router-xml-file is the following:
_ 213 | > FROM: /getter.xml fun=300
214 | `var` 215 | * **`radio_5g.wpa_algorithm (=wlWpaalg5g)`**: Should be `2` or `3` after router software, depending on security value. See under radio_5g.security. 216 | 217 | _What changes in the router-xml-file is the following:
_ 218 | > FROM: /getter.xml fun=300
219 | `var` 220 | --- 221 | * VARIABLES THAT CAN BE SET OVER `fun:319` (Wireless Signal Page): 222 | * **`band_mode (=wlBandMode)`**: Possible integer input values -> `1`,`2`,`3`,`4` 223 | > (`wlBandMode=1`) means 2g is on an 5g is off
224 | (`wlBandMode=2`) means 2g is off an 5g is on
225 | (`wlBandMode=3`) means 2g is on an 5g is on
226 | (`wlBandMode=4`) means 2g is off an 5g is off
227 | 228 | `3` in the router-xml-file stays always at value=`3`. But when you set `band_mode` `radio_2g.bss_enable` and `radio_5g.bss_enable` changes. 229 | 230 | _What changes in the router-xml-file is the following:
_ 231 | > FROM: /getter.xml fun=300
232 | `var` // var -> (1=on / 2=off)
233 | `var` // var -> (1=on / 2=off)
234 | FROM: /getter.xml fun=315
235 | `var` // var -> (1=on / 2=off)
236 | `var` // var -> (1=on / 2=off)
237 | FROM: /getter.xml fun=326
238 | `var` // var -> (1=on / 0=off)
239 | `var` // var -> (1=on / 0=off) 240 | 241 | * **`radio_2g.bandwidth (=wlBandwidth2g)`**: Possible integer input values -> `1`,`2` 242 | > (`wlBandwidth2g=1`) means '20 MHz'
243 | (`wlBandwidth2g=2`) means '20/40 MHz'
244 | 245 | _What changes in the router-xml-file is the following xml-content:
_ 246 | > FROM: /getter.xml fun=300
247 | `var` // var -> (1, 2) 248 | * **`radio_5g.bandwidth (=wlBandwidth5g)`**: Possible integer input values -> `1`,`2`,`3` 249 | > (`wlBandwidth5g=1`) means '20 MHz'
250 | (`wlBandwidth5g=2`) means '20/40 MHz'
251 | (`wlBandwidth5g=3`) means '20/40/80 MHz'
252 | 253 | _What changes in the router-xml-file is the following xml-content:
_ 254 | > FROM: /getter.xml fun=300
255 | `var` // var -> (1, 2, 3) 256 | 257 | * **`radio_2g.tx_mode (=wlTxMode2g)`**: Possible integer input values -> `1`,`5`,`6` 258 | > (`wlTxMode2g=1`) means '802.11b/g/n mixed'
259 | (`wlTxMode2g=5`) means '802.11n'
260 | (`wlTxMode2g=6`) means '802.11g/n mixed'
261 | 262 | _What changes in the router-xml-file is the following xml-content:
_ 263 | > FROM: /getter.xml fun=300
264 | `var` // var -> (1, 5, 6) 265 | * **`radio_5g.tx_mode (=wlTxMode5g)`**: Possible integer input values -> `14`,`15`,`16` 266 | > (`wlTxMode5g=14`) means '802.11a/n/ac mixed'
267 | (`wlTxMode5g=15`) means '802.11n/ac mixed'
268 | (`wlTxMode5g=16`) means '802.11ac'
269 | 270 | _What changes in the router-xml-file is the following xml-content:
_ 271 | > FROM: /getter.xml fun=300
272 | `var` // var -> (14, 15, 16) 273 | * **`radio_2g.channel (=wlChannel2g)`**: Possible integer input values -> `0`,`1`-`11`, `step 1` 274 | > Set var=0 for automatic channel selection
275 | 276 | _What changes in the router-xml-file is the following xml-content:
_ 277 | > FROM: /getter.xml fun=300
278 | `var` // var -> (0, 1-11) 279 | * **`radio_5g.channel (=wlChannel5g)`**: Possible integer input values -> `0`,`36`-`128`, `step 4` 280 | > Set var=0 for automatic channel selection
281 | Some Channel are DFS channels. 282 | 283 | _What changes in the router-xml-file is the following xml-content:
_ 284 | > FROM: /getter.xml fun=300
285 | `var` // var -> (0, 36-128, step 4) 286 | * **`smart_wifi (=wlSmartWiFi)`**: Possible integer input values -> `1 or 2` 287 | > (`wlSmartWiFi=1`) means 'Enable Channel Optimization'
288 | (`wlSmartWiFi=2`) means 'Disable Channel Optimization'
289 | 290 | _What changes in the router-xml-file is the following xml-content:
_ 291 | > FROM: /getter.xml fun=300
292 | `var` // var -> (1=on / 2=off) 293 | --- 294 |
295 | /setter.xml fun=301 296 | Change wifi settings 297 | 298 | fun:301 299 | wlBandMode2g:1, 300 | wlBandMode5g:1, 301 | wlSsid2g:ssid24, 302 | wlSsid5g:ssid5g, 303 | wlBandwidth2g:2, 304 | wlBandwidth5g:3 305 | wlTxMode2g:6 306 | wlTxMode5g:14 307 | wlMCastRate2g:1 308 | wlMCastRate5g:1 309 | wlHiden2g:2 310 | wlHiden5g:2 311 | wlCoexistence:1 312 | wlPSkey2g: keykeykey 313 | wlPSkey5g: key5gkey5g 314 | wlTxrate2g:0 315 | wlTxrate5g:0 316 | wlRekey2g:0 317 | wlRekey5g:0 318 | wlChannel2g:13 319 | wlChannel5g:0 320 | wlSecurity2g:8 321 | wlSecurity5g:8 322 | wlWpaalg2g:3 323 | wlWpaalg5g:3 324 | 325 | /setter.xml fun=319 326 | Wifi enable/disable radio's 327 | 328 | Similar to 301 except for bandmode (single value) 329 | 330 | fun:319 331 | wlBandMode:4 332 | wlSsid2g:ssid24 333 | wlSsid5g:ssid5g 334 | wlBandwidth2g:1 335 | wlBandwidth5g:3 336 | wlTxMode2g:6 337 | wlTxMode5g:14 338 | wlMCastRate2g:1 339 | wlMCastRate5g:1 340 | wlHiden2g:2 341 | wlHiden5g:2 342 | wlCoexistence:1 343 | wlPSkey2g:keykeykey 344 | wlPSkey5g:key5gkey5g 345 | wlTxrate2g:0 346 | wlTxrate5g:0 347 | wlRekey2g:0 348 | wlRekey5g:0 349 | wlChannel2g:0 350 | wlChannel5g:0 351 | wlSecurity2g:4 352 | wlSecurity5g:4 353 | wlWpaalg2g:2 354 | wlWpaalg5g:2 355 | 356 | /setter.xml fun=133 357 | -> modem reboot 358 | 359 | ## Factory reset: 360 | * /getter.xml fun=324 361 | * Response that contains the default ssid and password 362 | * /setter.xml fun=7 363 | * Factory reset starts 364 | -------------------------------------------------------------------------------- /compal/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client for the Compal CH7465LG/Ziggo Connect box cable modem 3 | """ 4 | 5 | import inspect 6 | import io 7 | import itertools 8 | import logging 9 | import re 10 | import time 11 | import urllib 12 | from collections import OrderedDict 13 | from datetime import timedelta 14 | from enum import Enum 15 | from hashlib import sha256 16 | from xml.dom import minidom 17 | 18 | import requests 19 | from lxml import etree 20 | 21 | from .functions import GetFunction, SetFunction 22 | from .models import ( 23 | BandSetting, 24 | FilterAction, 25 | FilterIpRange, 26 | GuestNetworkEnabling, 27 | GuestNetworkProperties, 28 | GuestNetworkSettings, 29 | IPv6FilterRule, 30 | IPv6FilterRuleProto, 31 | NatMode, 32 | PortForward, 33 | Proto, 34 | RadioSettings, 35 | RuleDir, 36 | SystemInfo, 37 | TimerMode, 38 | ) 39 | 40 | LOGGER = logging.getLogger(__name__) 41 | logging.basicConfig() 42 | 43 | LOGGER.setLevel(logging.INFO) 44 | 45 | 46 | class Compal: 47 | """ 48 | Basic functionality for the router's API 49 | """ 50 | 51 | def __init__( 52 | self, 53 | router_ip, 54 | key=None, 55 | send_token=True, 56 | send_hash=False, 57 | username="admin", 58 | timeout=10, 59 | ): 60 | self.router_ip = router_ip 61 | self.send_token = send_token 62 | self.send_hash = send_hash 63 | self.username = username 64 | self.timeout = timeout 65 | self.key = self.sanitize_key(key) 66 | 67 | self.session = requests.Session() 68 | # limit the number of redirects 69 | self.session.max_redirects = 3 70 | 71 | # after a response is received, process the token field of the response 72 | self.session.hooks["response"].append(self.token_handler) 73 | # session token is initially empty 74 | self.session_token = None 75 | 76 | LOGGER.debug("Getting initial token") 77 | # check the initial URL. If it is redirected, perform the initial 78 | # installation 79 | self.initial_res = self.get("/") 80 | 81 | if self.initial_res.url.endswith("common_page/FirstInstallation.html"): 82 | self.initial_setup() 83 | elif not self.initial_res.url.endswith("common_page/login.html"): 84 | LOGGER.error("Was not redirected to login page:" " concurrent session?") 85 | 86 | def sanitize_key(self, key): 87 | if key: 88 | if self.send_hash: 89 | key = sha256(key.encode("utf-8")).hexdigest() 90 | else: 91 | key = key[:31] 92 | return key 93 | 94 | def initial_setup(self, new_key=None): 95 | """ 96 | Replay the settings made during initial setup 97 | """ 98 | LOGGER.info("Initial setup: english.") 99 | 100 | self.key = self.sanitize_key(new_key) 101 | 102 | if not self.key: 103 | raise ValueError("No key/password availalbe") 104 | 105 | self.xml_getter(GetFunction.MULTILANG, {}) 106 | self.xml_getter(GetFunction.LANGSETLIST, {}) 107 | self.xml_getter(GetFunction.MULTILANG, {}) 108 | 109 | self.xml_setter(SetFunction.LANGUAGE, {"lang": "en"}) 110 | # Login or change password? Not sure. 111 | self.xml_setter( 112 | SetFunction.LOGIN, 113 | OrderedDict([("Username", self.username), ("Password", self.key)]), 114 | ) 115 | # Get current wifi settings (?) 116 | self.xml_getter(GetFunction.WIRELESSBASIC, {}) 117 | 118 | # Some sheets with hints, no request 119 | # installation is done: 120 | self.xml_setter(SetFunction.INSTALL_DONE, {"install": 0, "iv": 1, "en": 0}) 121 | 122 | def url(self, path): 123 | """ 124 | Calculate the absolute URL for the request 125 | """ 126 | while path.startswith("/"): 127 | path = path[1:] 128 | 129 | return "http://{ip}/{path}".format(ip=self.router_ip, path=path) 130 | 131 | def token_handler(self, res, *args, **kwargs): 132 | """ 133 | Handle the anti-replace token system 134 | """ 135 | self.session_token = res.cookies.get("sessionToken") 136 | 137 | if res.status_code == 302: 138 | LOGGER.info( 139 | "302 [%s] => '%s' [token: %s]", 140 | res.url, 141 | res.headers["Location"], 142 | self.session_token, 143 | ) 144 | else: 145 | LOGGER.debug( 146 | "%s [%s] [token: %s]", 147 | res.status_code, 148 | res.url, 149 | self.session_token, 150 | ) 151 | 152 | def post(self, path, _data, **kwargs): 153 | """ 154 | Prepare and send a POST request to the router 155 | 156 | Wraps `requests.get` and sets the 'token' and 'fun' fields at the 157 | correct position in the post data. 158 | 159 | **The router is sensitive to the ordering of the fields** 160 | (Which is a code smell) 161 | """ 162 | data = OrderedDict() 163 | if self.send_token: 164 | data["token"] = self.session_token 165 | 166 | if "fun" in _data: 167 | data["fun"] = _data.pop("fun") 168 | 169 | data.update(_data) 170 | 171 | LOGGER.debug("POST [%s]: %s", path, data) 172 | 173 | res = self.session.post( 174 | self.url(path), 175 | data=data, 176 | allow_redirects=False, 177 | timeout=self.timeout, 178 | **kwargs, 179 | ) 180 | 181 | return res 182 | 183 | def post_binary(self, path, binary_data, filename, **kwargs): 184 | """ 185 | Perform a post request with a file as form-data in it's body. 186 | """ 187 | 188 | headers = { 189 | "Content-Disposition": f'form-data; name="file"; filename="{filename}"', 190 | "Content-Type": "application/octet-stream", 191 | } 192 | self.session.post(self.url(path), data=binary_data, headers=headers, **kwargs) 193 | 194 | def get(self, path, **kwargs): 195 | """ 196 | Perform a GET request to the router 197 | 198 | Wraps `requests.get` and sets the required referer. 199 | """ 200 | res = self.session.get(self.url(path), timeout=self.timeout, **kwargs) 201 | 202 | self.session.headers.update({"Referer": res.url}) 203 | return res 204 | 205 | def xml_getter(self, fun, params): 206 | """ 207 | Call `/xml/getter.xml` for the given function and parameters 208 | """ 209 | params["fun"] = fun 210 | 211 | return self.post("/xml/getter.xml", params) 212 | 213 | def xml_setter(self, fun, params=None): 214 | """ 215 | Call `/xml/setter.xml` for the given function and parameters. 216 | The params are optional 217 | """ 218 | params["fun"] = fun 219 | 220 | return self.post("/xml/setter.xml", params) 221 | 222 | def login(self, key=None): 223 | """ 224 | Login. Allow this function to override the key. 225 | """ 226 | 227 | key = self.sanitize_key(key) if key else self.key 228 | res = self.xml_setter( 229 | SetFunction.LOGIN, 230 | OrderedDict( 231 | [ 232 | ("Username", self.username), 233 | ("Password", key), 234 | ] 235 | ), 236 | ) 237 | 238 | if res.status_code != 200: 239 | if res.headers["Location"].endswith("common_page/Access-denied.html"): 240 | raise ValueError("Access denied. " "Still logged in somewhere else?") 241 | else: 242 | raise ValueError("Login failed for unknown reason!") 243 | 244 | def parse_response(text): 245 | # As per python 3.9.2 parse_qs does not split on ';' by default 246 | if "separator" in inspect.signature(urllib.parse.parse_qs).parameters: 247 | return urllib.parse.parse_qs(text, separator=";") 248 | else: 249 | return urllib.parse.parse_qs(text) 250 | 251 | tokens = parse_response(res.text) 252 | 253 | token_sids = tokens.get("SID") 254 | if not token_sids: 255 | raise ValueError("No valid session-Id received! Wrong password?") 256 | 257 | token_sid = token_sids[0] 258 | LOGGER.info("[login] SID %s", token_sid) 259 | 260 | self.session.cookies.update({"SID": token_sid}) 261 | 262 | return res 263 | 264 | def system_info(self): 265 | """ 266 | Get system information 267 | """ 268 | system_info = SystemInfo() 269 | 270 | parser = etree.XMLParser(recover=True) 271 | res = self.xml_getter(GetFunction.CM_SYSTEM_INFO, {}) 272 | xml = etree.fromstring(res.content, parser=parser) 273 | 274 | cm_docsis_mode = xml.find("cm_docsis_mode") 275 | if cm_docsis_mode is not None: 276 | system_info.docsis_mode = cm_docsis_mode.text 277 | 278 | cm_hardware_version = xml.find("cm_hardware_version") 279 | if cm_hardware_version is not None: 280 | system_info.hardware_version = cm_hardware_version.text 281 | 282 | cm_mac_addr = xml.find("cm_mac_addr") 283 | if cm_mac_addr is not None: 284 | system_info.mac_address = cm_mac_addr.text 285 | 286 | cm_serial_number = xml.find("cm_serial_number") 287 | if cm_serial_number is not None: 288 | system_info.serial_number = cm_serial_number.text 289 | 290 | cm_system_uptime = xml.find("cm_system_uptime") 291 | if cm_system_uptime is not None: 292 | match = re.match( 293 | r"^(\d+)day(?:\(s\))?(\d+)h?\:(\d+)m?\:(\d+)s?$", cm_system_uptime.text 294 | ) 295 | system_info.uptime = timedelta( 296 | days=int(match[1]), 297 | hours=int(match[2]), 298 | minutes=int(match[3]), 299 | seconds=int(match[4]), 300 | ) 301 | 302 | cm_network_access = xml.find("cm_network_access") 303 | if cm_network_access is not None: 304 | system_info.network_access = cm_network_access.text 305 | 306 | return system_info 307 | 308 | def reboot(self): 309 | """ 310 | Reboot the router 311 | """ 312 | try: 313 | LOGGER.info("Performing a reboot - this will take a while") 314 | return self.xml_setter(SetFunction.REBOOT, {}) 315 | except requests.exceptions.ReadTimeout: 316 | return None 317 | 318 | def factory_reset(self): 319 | """ 320 | Perform a factory reset 321 | """ 322 | default_settings = self.xml_getter(GetFunction.DEFAULTVALUE, {}) 323 | 324 | try: 325 | LOGGER.info("Initiating factory reset - this will take a while") 326 | self.xml_setter(SetFunction.FACTORY_RESET, {}) 327 | except requests.exceptions.ReadTimeout: 328 | pass 329 | return default_settings 330 | 331 | def logout(self): 332 | """ 333 | Logout of the router. This is required since only a single session can 334 | be active at any point in time. 335 | """ 336 | return self.xml_setter(SetFunction.LOGOUT, {}) 337 | 338 | def set_modem_mode(self): 339 | """ 340 | Set router to Modem-mode 341 | After setting this, router will not be reachable by IP! 342 | It needs factory reset to function as a router again! 343 | """ 344 | return self.xml_setter(SetFunction.NAT_MODE, {"NAT": NatMode.disabled.value}) 345 | 346 | def set_router_mode(self): 347 | """ 348 | Set router to Modem-mode 349 | After setting this, router will not be reachable by IP! 350 | It needs factory reset to function as a router again! 351 | """ 352 | return self.xml_setter(SetFunction.NAT_MODE, {"NAT": NatMode.enabled.value}) 353 | 354 | def change_password(self, old_password, new_password): 355 | """ 356 | Change the admin password 357 | """ 358 | return self.xml_setter( 359 | SetFunction.CHANGE_PASSWORD, 360 | OrderedDict([("oldpassword", old_password), ("newpassword", new_password)]), 361 | ) 362 | 363 | 364 | class PortForwards(object): 365 | """ 366 | Manage the port forwards on the modem 367 | """ 368 | 369 | def __init__(self, modem): 370 | # The modem sometimes returns invalid XML when 'strange' values are 371 | # present in the settings. The recovering parser from lxml is used to 372 | # handle this. 373 | self.parser = etree.XMLParser(recover=True) 374 | 375 | self.modem = modem 376 | 377 | @property 378 | def rules(self): 379 | """ 380 | Retrieve the current port forwarding rules 381 | 382 | @returns generator of PortForward rules 383 | """ 384 | res = self.modem.xml_getter(GetFunction.FORWARDING, {}) 385 | 386 | xml = etree.fromstring(res.content, parser=self.parser) 387 | router_ip = xml.find("LanIP").text 388 | 389 | def r_int(rule, attr): 390 | """ 391 | integer value for rule's child's text 392 | """ 393 | return int(rule.find(attr).text) 394 | 395 | for rule in xml.findall("instance"): 396 | yield PortForward( 397 | local_ip=rule.find("local_IP").text, 398 | lan_ip=router_ip, 399 | id=r_int(rule, "id"), 400 | ext_port=(r_int(rule, "start_port"), r_int(rule, "end_port")), 401 | int_port=( 402 | r_int(rule, "start_portIn"), 403 | r_int(rule, "end_portIn"), 404 | ), 405 | proto=Proto(r_int(rule, "protocol")), 406 | enabled=bool(r_int(rule, "enable")), 407 | idd=bool(r_int(rule, "idd")), 408 | ) 409 | 410 | def update_firewall( 411 | self, 412 | enabled=False, 413 | fragment=False, 414 | port_scan=False, 415 | ip_flood=False, 416 | icmp_flood=False, 417 | icmp_rate=15, 418 | ): 419 | """ 420 | Update the firewall rules 421 | """ 422 | assert enabled or not (fragment or port_scan or ip_flood or icmp_flood) 423 | 424 | def b2i(_bool): 425 | """ 426 | Bool-2-int with non-standard mapping 427 | """ 428 | return 1 if _bool else 2 429 | 430 | return self.modem.xml_setter( 431 | SetFunction.FIREWALL, 432 | OrderedDict( 433 | [ 434 | ("firewallProtection", b2i(enabled)), 435 | ("blockIpFragments", ""), 436 | ("portScanDetection", ""), 437 | ("synFloodDetection", ""), 438 | ("IcmpFloodDetection", ""), 439 | ("IcmpFloodDetectRate", icmp_rate), 440 | ("action", ""), 441 | ("IPv6firewallProtection", ""), 442 | ("IPv6blockIpFragments", ""), 443 | ("IPv6portScanDetection", ""), 444 | ("IPv6synFloodDetection", ""), 445 | ("IPv6IcmpFloodDetection", ""), 446 | ("IPv6IcmpFloodDetectRate", ""), 447 | ] 448 | ), 449 | ) 450 | 451 | def add_forward(self, local_ip, ext_port, int_port, proto: Proto, enabled=True): 452 | """ 453 | Add a port forward. int_port and ext_port can be ranges. Deletion 454 | param is ignored for now. 455 | """ 456 | start_int, end_int = itertools.islice(itertools.repeat(int_port), 0, 2) 457 | start_ext, end_ext = itertools.islice(itertools.repeat(ext_port), 0, 2) 458 | 459 | return self.modem.xml_setter( 460 | SetFunction.PORT_FORWARDING, 461 | OrderedDict( 462 | [ 463 | ("action", "add"), 464 | ("instance", ""), 465 | ("local_IP", local_ip), 466 | ("start_port", start_ext), 467 | ("end_port", end_ext), 468 | ("start_portIn", start_int), 469 | ("end_portIn", end_int), 470 | ("protocol", proto.value), 471 | ("enable", int(enabled)), 472 | ("delete", int(False)), 473 | ("idd", ""), 474 | ] 475 | ), 476 | ) 477 | 478 | def update_rules(self, rules): 479 | """ 480 | Update the port forwarding rules 481 | """ 482 | # Will iterate multiple times, ensure it is a list. 483 | rules = list(rules) 484 | 485 | empty_asterisk = "*" * (len(rules) - 1) 486 | 487 | # Order of parameters matters (code smell: YES) 488 | params = OrderedDict( 489 | [ 490 | ("action", "apply"), 491 | ("instance", "*".join([str(r.id) for r in rules])), 492 | ("local_IP", ""), 493 | ("start_port", ""), 494 | ("end_port", ""), 495 | ("start_portIn", empty_asterisk), 496 | ("end_portIn", ""), 497 | ("protocol", "*".join([str(r.proto.value) for r in rules])), 498 | ("enable", "*".join([str(int(r.enabled)) for r in rules])), 499 | ("delete", "*".join([str(int(r.delete)) for r in rules])), 500 | ("idd", empty_asterisk), 501 | ] 502 | ) 503 | 504 | LOGGER.info("Updating port forwards") 505 | LOGGER.debug(params) 506 | 507 | return self.modem.xml_setter(SetFunction.PORT_FORWARDING, params) 508 | 509 | 510 | class Filters(object): 511 | """ 512 | Provide filters for accessing the internet. 513 | 514 | Supports access-restriction via parental control (Keywords, url-lists, 515 | timetable), client's MAC address and by specific ports. 516 | """ 517 | 518 | def __init__(self, modem): 519 | self.modem = modem 520 | self.parser = etree.XMLParser(recover=True) 521 | 522 | def set_parental_control( 523 | self, 524 | safe_search, 525 | keyword_list, 526 | allow_list, 527 | deny_list, 528 | timer_mode, 529 | enable, 530 | ): 531 | """ 532 | Filter internet access by keywords or block/allow whole urls 533 | Allowed times can be set too 534 | """ 535 | data = "EN=%s;" % ("1" if enable else "2") 536 | data += "SAFE=%s;" % ("1" if safe_search else "2") 537 | 538 | data += "KEY=%s;" % ("1" if len(keyword_list) else "0") 539 | data += "KEYLIST=" 540 | if len(keyword_list): 541 | data += ",".join(keyword_list) + ";" 542 | else: 543 | data += "empty" + ";" 544 | 545 | data += "ALLOW=%s;" % ("1" if len(allow_list) else "0") 546 | data += "ALLOWLIST=" 547 | if len(keyword_list): 548 | data += ",".join(keyword_list) + ";" 549 | else: 550 | data += "empty" + ";" 551 | 552 | data += "DENY=%s;" % ("1" if len(deny_list) else "0") 553 | data += "DENYLIST=" 554 | if len(keyword_list): 555 | data += ",".join(keyword_list) + ";" 556 | else: 557 | data += "empty" + ";" 558 | 559 | if TimerMode.generaltime == timer_mode: 560 | timer_rule = "0,0" 561 | elif TimerMode.dailytime == timer_mode: 562 | timer_rule = "0,0" 563 | else: 564 | timer_rule = "empty" 565 | 566 | data += "TMODE=%i;" % timer_mode.value 567 | data += "TIMERULE=%s;" % timer_rule 568 | 569 | self.modem.xml_setter(SetFunction.PARENTAL_CONTROL, {"data": data}) 570 | 571 | def set_mac_filter(self, action, device_name, mac_addr, timer_mode, enable): 572 | """ 573 | Restrict access to the internet via client MAC address 574 | """ 575 | if FilterAction.add == action: 576 | data = "ADD," 577 | elif FilterAction.delete == action: 578 | data = "DEL," 579 | elif FilterAction.enable == action: 580 | data = "EN," 581 | else: 582 | LOGGER.error("No action supplied for MAC filter rule") 583 | return 584 | 585 | data += device_name + "," 586 | data += mac_addr + "," 587 | data += "%i" % (1 if enable else 2) + ";" 588 | 589 | if TimerMode.generaltime == timer_mode: 590 | timerule = "0,0" 591 | elif TimerMode.dailytime == timer_mode: 592 | timerule = "0,0" 593 | else: 594 | timerule = "0" 595 | 596 | data += "MODE=%i," % timer_mode.value 597 | data += "TIME=%s;" % timerule 598 | 599 | return self.modem.xml_setter(SetFunction.MACFILTER, {"data": data}) 600 | 601 | def get_ipv6_filter_rules(self): 602 | """ 603 | Get the list of IPv6 filter rules for incoming and outgoing traffic 604 | """ 605 | 606 | def r_int(rule, attr): 607 | """ 608 | integer value for rule's child's text 609 | """ 610 | return int(rule.find(attr).text) 611 | 612 | def getFilterRules(direction): 613 | """ 614 | Get the list of IPv6 filter rules for traffic in specified direction 615 | """ 616 | res = self.modem.xml_getter( 617 | GetFunction.IPV6FILTERING, OrderedDict([("rule", int(direction))]) 618 | ) 619 | xml = etree.fromstring(res.content, parser=self.parser) 620 | for rule in xml.findall("instance"): 621 | yield IPv6FilterRule( 622 | dir=direction, 623 | idd=r_int(rule, "idd"), 624 | src_addr=rule.find("src_addr").text, 625 | src_prefix=r_int(rule, "src_prefix"), 626 | dst_addr=rule.find("dst_addr").text, 627 | dst_prefix=r_int(rule, "dst_prefix"), 628 | src_sport=r_int(rule, "src_sport"), 629 | src_eport=r_int(rule, "src_eport"), 630 | dst_sport=r_int(rule, "dst_sport"), 631 | dst_eport=r_int(rule, "dst_eport"), 632 | protocol=IPv6FilterRuleProto(r_int(rule, "protocol")), 633 | allow=bool(r_int(rule, "allow")), 634 | enabled=bool(r_int(rule, "enabled")), 635 | ) 636 | 637 | inRules = getFilterRules(RuleDir.incoming) 638 | outRules = getFilterRules(RuleDir.outgoing) 639 | return list(inRules), list(outRules) 640 | 641 | def set_ipv6_filter_rule(self, rule): 642 | """ 643 | Add a new IPv6 traffic filter rule 644 | """ 645 | 646 | def getOrDefault(val, defaultValue): 647 | return condGetOrDefault(RuleDir.incoming, val, defaultValue, defaultValue) 648 | 649 | def condGetOrDefault(direction, val, defaultValueIn, defaultValueOut): 650 | if direction == RuleDir.incoming: 651 | return val if val is not None else defaultValueIn 652 | else: 653 | return val if val is not None else defaultValueOut 654 | 655 | direction = getOrDefault(rule.dir, RuleDir.incoming) 656 | src_prefix = getOrDefault(rule.src_prefix, 128) 657 | src_addr = condGetOrDefault(direction, rule.src_addr, "::", None) 658 | dst_prefix = getOrDefault(rule.dst_prefix, 128) 659 | dst_addr = condGetOrDefault(direction, rule.dst_addr, None, "::") 660 | params = OrderedDict( 661 | [ 662 | ("act", "2"), 663 | ("dir", int(direction)), 664 | ("enabled", int(getOrDefault(rule.enabled, True))), 665 | ( 666 | "allow_traffic", 667 | 1 - int(condGetOrDefault(direction, rule.allow, True, False)), 668 | ), # 0 actually stands for allowing the traffic.. TODO introduce enum? 669 | ("protocol", int(rule.protocol)), 670 | ("src_addr", src_addr), 671 | ("src_prefix", src_prefix), 672 | ("dst_addr", dst_addr), 673 | ("dst_prefix", dst_prefix), 674 | ("ssport", condGetOrDefault(direction, rule.src_sport, "1", None)), 675 | ("seport", condGetOrDefault(direction, rule.src_eport, "65535", None)), 676 | ("dsport", condGetOrDefault(direction, rule.dst_sport, None, "1")), 677 | ("deport", condGetOrDefault(direction, rule.dst_eport, None, "65535")), 678 | ("del", ""), 679 | ("idd", ""), 680 | ( 681 | "sIpRange", 682 | int( 683 | FilterIpRange.all 684 | if src_addr == "::" 685 | else ( 686 | FilterIpRange.range 687 | if src_prefix != 128 688 | else FilterIpRange.single 689 | ) 690 | ), 691 | ), 692 | ( 693 | "dsIpRange", 694 | int( 695 | FilterIpRange.all 696 | if dst_addr == "::" 697 | else ( 698 | FilterIpRange.range 699 | if dst_prefix != 128 700 | else FilterIpRange.single 701 | ) 702 | ), 703 | ), 704 | ("PortRange", "2"), # manual port selection 705 | ( 706 | "TMode", 707 | "0", 708 | ), # No timed rule 709 | ("TRule", "0"), # No timed rule 710 | ] 711 | ) 712 | return self.modem.xml_setter(SetFunction.IPV6_FILTER_RULE, params) 713 | 714 | def update_ipv6_filter_rules( 715 | self, ruleCount, disableIds={}, deleteIds={}, direction=RuleDir.incoming 716 | ): 717 | """ 718 | Update the existing filter set. I.e. disable or delete them. 719 | """ 720 | if ruleCount == 0 or (len(disableIds) == 0 and len(deleteIds)): 721 | pass 722 | 723 | def genIddString(): 724 | return "*".join([str(i) for i in range(1, ruleCount + 1)]) 725 | 726 | def genBitfield(ids, matchStr, noMatchStr): 727 | return "*".join( 728 | [matchStr if i in ids else noMatchStr for i in range(1, ruleCount + 1)] 729 | ) 730 | 731 | enableBitfield = genBitfield(disableIds, "0", "1") 732 | deleteBitfield = genBitfield(deleteIds, "1", "0") 733 | 734 | params = OrderedDict( 735 | [ 736 | ("act", "1"), 737 | ("dir", int(direction)), 738 | ("enabled", enableBitfield), 739 | ("allow_traffic", ""), 740 | ("protocol", ""), 741 | ("src_addr", ""), 742 | ("src_prefix", ""), 743 | ("dst_addr", ""), 744 | ("dst_prefix", ""), 745 | ("ssport", ""), 746 | ("seport", ""), 747 | ("dsport", ""), 748 | ("deport", ""), 749 | ("del", deleteBitfield), 750 | ("idd", genIddString()), 751 | ("sIpRange", ""), 752 | ("dsIpRange", ""), 753 | ("PortRange", ""), 754 | ("TMode", "0"), # No timed rule 755 | ("TRule", "0"), # No timed rule 756 | ] 757 | ) 758 | return self.modem.xml_setter(SetFunction.IPV6_FILTER_RULE, params) 759 | 760 | def delete_all_ipv6_filter_rules(self): 761 | """ 762 | Delete all ipv6 filter rules 763 | """ 764 | inRules, outRules = self.get_ipv6_filter_rules() 765 | countInRules = len(inRules) 766 | self.update_ipv6_filter_rules( 767 | countInRules, {}, set(range(1, countInRules + 1)), RuleDir.incoming 768 | ) 769 | countOutRules = len(outRules) 770 | self.update_ipv6_filter_rules( 771 | countOutRules, {}, set(range(1, countOutRules + 1)), RuleDir.outgoing 772 | ) 773 | 774 | return inRules, outRules 775 | 776 | def set_filter_rule(self): 777 | """ 778 | To be integrated... 779 | """ 780 | params = OrderedDict( 781 | [ 782 | ("act", ""), 783 | ("enabled", ""), 784 | ("protocol", ""), 785 | ("src_addr_s", ""), 786 | ("src_addr_e", ""), 787 | ("dst_addr_s", ""), 788 | ("dst_addr_e", ""), 789 | ("ssport", ""), 790 | ("seport", ""), 791 | ("dsport", ""), 792 | ("deport", ""), 793 | ("del", ""), 794 | ("idd", ""), 795 | ("sIpRange", ""), 796 | ("dsIpRange", ""), 797 | ("PortRange", ""), 798 | ("TMode", ""), 799 | ("TRule", ""), 800 | ] 801 | ) 802 | return self.modem.xml_setter(SetFunction.FILTER_RULE, params) 803 | 804 | 805 | class WifiSettings(object): 806 | """ 807 | Configures the WiFi settings 808 | """ 809 | 810 | def __init__(self, modem): 811 | # The modem sometimes returns invalid XML when 'strange' values are 812 | # present in the settings. The recovering parser from lxml is used to 813 | # handle this. 814 | self.parser = etree.XMLParser(recover=True) 815 | 816 | self.modem = modem 817 | 818 | @property 819 | def wifi_settings_xml(self): 820 | """ 821 | Get the current wifi settings as XML 822 | """ 823 | xml_content = self.modem.xml_getter(GetFunction.WIRELESSBASIC, {}).content 824 | return etree.fromstring(xml_content, parser=self.parser) 825 | 826 | @staticmethod 827 | def band_setting(xml, band): 828 | """ 829 | Get the wifi settings for the given band (2g, 5g) 830 | """ 831 | assert band in ( 832 | "2g", 833 | "5g", 834 | ) 835 | 836 | def xml_value(attr, coherce=True): 837 | """ 838 | 'XmlValue' 839 | 840 | Coherce the value if requested. First the value is parsed as an 841 | integer, if this fails it is returned as a string. 842 | """ 843 | val = xml.find(attr).text 844 | try: # Try to coherce to int. If it fails, return string 845 | if not coherce: 846 | return val 847 | return int(val) 848 | except (TypeError, ValueError): 849 | return val 850 | 851 | def band_xv(attr, coherce=True): 852 | """ 853 | xml value for the given band 854 | """ 855 | try: 856 | return xml_value(f"{attr}{band.upper()}", coherce) 857 | except AttributeError: 858 | return xml_value(f"{attr}{band}", coherce) 859 | 860 | return BandSetting( 861 | radio=band, 862 | bss_enable=band_xv( 863 | "BssEnable" 864 | ), # bss_enable is the on/off mode, 'mode' was removed 865 | ssid=band_xv("SSID", False), 866 | hidden=band_xv("HideNetwork"), 867 | bandwidth=band_xv("BandWidth"), 868 | tx_rate=band_xv("TransmissionRate"), 869 | tx_mode=band_xv("TransmissionMode"), 870 | security=band_xv("SecurityMode"), 871 | multicast_rate=band_xv("MulticastRate"), 872 | channel=band_xv("ChannelSetting"), 873 | pre_shared_key=band_xv("PreSharedKey"), 874 | re_key=band_xv("GroupRekeyInterval"), 875 | wpa_algorithm=band_xv("WpaAlgorithm"), 876 | ) 877 | 878 | @property 879 | def wifi_settings(self): 880 | """ 881 | Read the wifi settings 882 | """ 883 | xml = self.wifi_settings_xml 884 | 885 | radio_2g = WifiSettings.band_setting(xml, "2g") 886 | radio_5g = WifiSettings.band_setting(xml, "5g") 887 | 888 | """ 889 | Correct the - in the router xml-File - not changing BandMode, 890 | so that all settings are up to date in the settings object 891 | """ 892 | 893 | def get_band_mode(): 894 | if radio_2g.bss_enable == 1: 895 | band_mode = 1 if radio_5g.bss_enable == 2 else 3 896 | elif radio_2g.bss_enable == 2: 897 | band_mode = 2 if radio_5g.bss_enable == 1 else 4 898 | else: 899 | band_mode = None 900 | return band_mode 901 | 902 | return RadioSettings( 903 | nv_country=int(xml.find("NvCountry").text), 904 | band_mode=get_band_mode(), 905 | channel_range=int(xml.find("ChannelRange").text), 906 | bss_coexistence=int(xml.find("BssCoexistence").text), 907 | son_admin_status=int(xml.find("SONAdminStatus").text), 908 | smart_wifi=int(xml.find("SONOperationalStatus").text), 909 | radio_2g=radio_2g, 910 | radio_5g=radio_5g, 911 | ) 912 | 913 | def __set_wifi_settings(self, settings, setter_code, debug=True): 914 | """ 915 | Set the wifi settings either for fun:301 or fun:319 depending 916 | on the the setter_code parameter 917 | """ 918 | 919 | # Create the object. 920 | def transform_radio(radio_settings): # rs = radio_settings 921 | """ 922 | Prepare radio settings object for the request. 923 | Returns a OrderedDict with the correct keys for this band 924 | """ 925 | # Create the dict 926 | out = [] 927 | if setter_code == SetFunction.WIFI_CONFIGURATION: 928 | out = [("BandMode", radio_settings.bss_enable)] 929 | 930 | out.extend( 931 | [ 932 | ("Ssid", radio_settings.ssid), 933 | ("Bandwidth", radio_settings.bandwidth), 934 | ("TxMode", radio_settings.tx_mode), 935 | ("MCastRate", radio_settings.multicast_rate), 936 | ("Hiden", radio_settings.hidden), 937 | ("PSkey", radio_settings.pre_shared_key), 938 | ("Txrate", radio_settings.tx_rate), 939 | ("Rekey", radio_settings.re_key), 940 | ("Channel", radio_settings.channel), 941 | ("Security", radio_settings.security), 942 | ("Wpaalg", radio_settings.wpa_algorithm), 943 | ] 944 | ) 945 | 946 | # Prefix 'wl', Postfix the band 947 | return [(f"wl{k}{radio_settings.radio}", v) for (k, v) in out] 948 | 949 | # Alternate the two setting lists 950 | out_s = [] # change 951 | if setter_code == SetFunction.WIFI_SIGNAL: 952 | out_s.append(("wlBandMode", settings.band_mode)) # change 953 | 954 | for item_2g, item_5g in zip( 955 | transform_radio(settings.radio_2g), 956 | transform_radio(settings.radio_5g), 957 | ): 958 | out_s.append(item_2g) 959 | out_s.append(item_5g) 960 | 961 | if item_5g[0] == "wlHiden5g": 962 | out_s.append(("wlCoexistence", settings.bss_coexistence)) 963 | 964 | if setter_code == SetFunction.WIFI_SIGNAL: 965 | out_s.append(("wlSmartWiFi", settings.smart_wifi)) # change 966 | # Join the settings 967 | out_settings = OrderedDict(out_s) 968 | 969 | if debug: 970 | print( 971 | f"\nThe following variables will be sent over 'fun:{setter_code}'" 972 | f" to the router for settting it:\n" + str(out_settings) 973 | ) 974 | 975 | return self.modem.xml_setter(setter_code, out_settings) # change 976 | 977 | def print_xml_content(self, point=""): 978 | """ 979 | Print out xml-entries in the router that contain wifi-settings 980 | """ 981 | print(f"\n--- SETTINGS {point} ---:") 982 | # WIFI State 983 | xml_content = self.modem.xml_getter(315, {}).content 984 | print( 985 | "\n ------------------------- WIRELESSBASIC_2 (315) IN ROUTER: : -------------------------\n" 986 | + xml_content.decode("utf8") 987 | ) 988 | xml_content = self.modem.xml_getter(326, {}).content 989 | print( 990 | "\n ------------------------- WIFISTATE (326) IN ROUTER: : -------------------------\n" 991 | + xml_content.decode("utf8") 992 | ) 993 | xml_content = self.modem.xml_getter(300, {}).content 994 | print( 995 | "\n ------------------------- WIRELESSBASIC (300) IN ROUTER: : -------------------------\n" 996 | + xml_content.decode("utf8") 997 | ) 998 | print(str(self.wifi_settings)) 999 | time.sleep(1) 1000 | 1001 | @staticmethod 1002 | def __compare_wifi_settings(old_settings, new_settings): 1003 | """ 1004 | Compare two settings objects for changes and 1005 | return: 1006 | bln_changes: True if there were changes 1007 | changes: Python dict that contains the changes 1008 | """ 1009 | 1010 | def iterate_changes(old_settings, new_settings, changes): 1011 | for attr, old_value in old_settings.__dict__.items(): 1012 | if isinstance(old_value, BandSetting): 1013 | changes.update({attr: {}}) 1014 | iterate_changes( 1015 | getattr(old_settings, attr), 1016 | getattr(new_settings, attr), 1017 | changes[attr], 1018 | ) 1019 | else: 1020 | new_value = getattr(new_settings, attr) 1021 | if old_value != new_value: 1022 | changes.update({attr: f"{old_value} -> {new_value}"}) 1023 | bln_changes[0] = True 1024 | 1025 | changes = {} 1026 | bln_changes = [False] 1027 | iterate_changes(old_settings, new_settings, changes) 1028 | return bln_changes[0], changes 1029 | 1030 | def __check_router_status(self, new_settings, debug=True): 1031 | """ 1032 | Checks if (1) the new user settings were changed in the router (via checking 1033 | the getter fun:300 [WIRELESSBASIC] in the router-xml-file), (2) prints out a 1034 | progress bar and (3) prints out if the process was successful. 1035 | """ 1036 | router_settings = None 1037 | 1038 | def print_progress_bar(progress, total, postfix=""): 1039 | progress = total if progress > total else progress 1040 | per_progress = int(progress / total * 100) 1041 | postfix = f"[{postfix}]" if postfix != "" else "" 1042 | print( 1043 | f"\r|{'█' * progress}{'-' * (total - progress)}| {per_progress}%, {progress}sec\t{postfix}", 1044 | end="", 1045 | ) 1046 | 1047 | progress = 0 1048 | total = 24 1049 | bln_changes = True 1050 | start_time = time.time() 1051 | changes = "" 1052 | if debug: 1053 | print("\n--- WAITING FOR ROUTER TO UPDATE ---") 1054 | while progress < total and bln_changes is True: 1055 | progress = int(time.time() - start_time) 1056 | if debug: 1057 | print_progress_bar(progress, total, changes) 1058 | time.sleep(3) 1059 | try: 1060 | router_settings = self.wifi_settings 1061 | bln_changes, changes = self.__compare_wifi_settings( 1062 | router_settings, new_settings 1063 | ) 1064 | except Exception as e: 1065 | changes = str(e) 1066 | if debug: 1067 | if not bln_changes: 1068 | print("\n\n--- ROUTER SUCESSFULLY UPDATED ALL NEW WIFI SETTINGS! ---") 1069 | else: 1070 | print("\n\n--- CHANGES THAT DID NOT GET SET ---") 1071 | _, changes = self.__compare_wifi_settings(router_settings, new_settings) 1072 | print(changes) 1073 | return bln_changes 1074 | 1075 | @staticmethod 1076 | def __update_new_settings(old_settings, new_settings): 1077 | """ 1078 | Needed for 'self.update_wifi_settings', if the user only changes 1079 | band_mode or only changes radio_2g.bss_enable or radio_5g.bss_enable. 1080 | """ 1081 | new = new_settings 1082 | if old_settings.band_mode != new.band_mode: 1083 | new.radio_2g.bss_enable = 1 if (new_settings.band_mode & 1) else 2 1084 | new.radio_5g.bss_enable = ( 1085 | 1 if int(f"{new_settings.band_mode:03b}"[1]) else 2 1086 | ) 1087 | elif ( 1088 | old_settings.radio_2g.bss_enable != new.radio_2g.bss_enable 1089 | or old_settings.radio_5g.bss_enable != new.radio_5g.bss_enable 1090 | ): 1091 | if new.radio_2g.bss_enable == 1: 1092 | new.band_mode = 1 if new.radio_5g.bss_enable == 2 else 3 1093 | elif new.radio_2g.bss_enable == 2: 1094 | new.band_mode = 2 if new.radio_5g.bss_enable == 1 else 4 1095 | else: 1096 | new.band_mode = None 1097 | return new 1098 | 1099 | def update_wifi_settings(self, new_settings, debug=True): 1100 | """ 1101 | New method for updating the wifi settings. Uses either fun:301 or fun:319 1102 | or both, depending on what the user changed. 1103 | """ 1104 | old_settings = self.wifi_settings 1105 | if debug: 1106 | print("\n--- SETTINGS BEFORE UPDATING ---:") 1107 | print(str(old_settings)) 1108 | 1109 | if debug: 1110 | print("\n--- CHANGES THAT SHOULD BE SET ---") 1111 | _, changes = self.__compare_wifi_settings(old_settings, new_settings) 1112 | if debug: 1113 | print(changes) 1114 | 1115 | configuration_page = [ 1116 | "bss_enable", 1117 | "ssid", 1118 | "hidden", 1119 | "pre_shared_key", 1120 | "re_key", 1121 | "wpa_algorithm", 1122 | ] 1123 | signal_page = ["band_mode", "bandwidth", "tx_mode", "channel", "smart_wifi"] 1124 | config_page_update = any(e in str(changes) for e in configuration_page) 1125 | signal_page_update = any(e in str(changes) for e in signal_page) 1126 | 1127 | new_settings = self.__update_new_settings(old_settings, new_settings) 1128 | 1129 | # both_pages also checks if both wifi pages (signal and configuration) are not 1130 | # changed, so that it request fun:301 and fun:319 for settings changes that 1131 | # cannot be set 1132 | both_pages = (config_page_update and signal_page_update) or ( 1133 | not config_page_update and not signal_page_update 1134 | ) 1135 | if both_pages: 1136 | self.__set_wifi_settings(new_settings, SetFunction.WIFI_SIGNAL, debug) 1137 | elif config_page_update and not signal_page_update: 1138 | self.__set_wifi_settings( 1139 | new_settings, SetFunction.WIFI_CONFIGURATION, debug 1140 | ) 1141 | elif not config_page_update and signal_page_update: 1142 | self.__set_wifi_settings(new_settings, SetFunction.WIFI_SIGNAL, debug) 1143 | not_updated = self.__check_router_status(new_settings, debug) 1144 | 1145 | if both_pages and not_updated: 1146 | self.__set_wifi_settings( 1147 | new_settings, SetFunction.WIFI_CONFIGURATION, debug 1148 | ) 1149 | self.__check_router_status(new_settings, debug) 1150 | 1151 | def turn_on_2g(self, debug=False): 1152 | settings = self.wifi_settings 1153 | settings.radio_2g.bss_enable = 1 1154 | self.update_wifi_settings(settings, debug) 1155 | 1156 | def turn_off_2g(self, debug=False): 1157 | settings = self.wifi_settings 1158 | settings.radio_2g.bss_enable = 2 1159 | self.update_wifi_settings(settings, debug) 1160 | 1161 | def turn_on_5g(self, debug=False): 1162 | settings = self.wifi_settings 1163 | settings.radio_5g.bss_enable = 1 1164 | self.update_wifi_settings(settings, debug) 1165 | 1166 | def turn_off_5g(self, debug=False): 1167 | settings = self.wifi_settings 1168 | settings.radio_5g.bss_enable = 2 1169 | self.update_wifi_settings(settings, debug) 1170 | 1171 | def turn_off(self, debug=False): 1172 | settings = self.wifi_settings 1173 | settings.band_mode = 4 1174 | self.update_wifi_settings(settings, debug) 1175 | 1176 | 1177 | class WifiGuestNetworkSettings(object): 1178 | """ 1179 | Configures the WiFi guest network settings 1180 | """ 1181 | 1182 | def __init__(self, modem): 1183 | # The modem sometimes returns invalid XML when 'strange' values are 1184 | # present in the settings. The recovering parser from lxml is used to 1185 | # handle this. 1186 | self.parser = etree.XMLParser(recover=True) 1187 | 1188 | self.modem = modem 1189 | 1190 | @property 1191 | def wifi_guest_network_settings_xml(self): 1192 | """ 1193 | Get the current wifi guest network settings as XML 1194 | """ 1195 | xml_content = self.modem.xml_getter( 1196 | GetFunction.WIRELESSGUESTNETWORK, {} 1197 | ).content 1198 | return etree.fromstring(xml_content, parser=self.parser) 1199 | 1200 | @staticmethod 1201 | def __xml_value(interface, attr, coherce=True): 1202 | """ 1203 | 'XmlValue' of an interface 1204 | 1205 | Coherce the value if requested. First the value is parsed as an 1206 | integer, if this fails it is returned as a string. 1207 | """ 1208 | val = interface.find(attr).text 1209 | try: # Try to coherce to int. If it fails, return string 1210 | if not coherce: 1211 | return val 1212 | return int(val) 1213 | except (TypeError, ValueError): 1214 | return val 1215 | 1216 | @property 1217 | def wifi_guest_network_settings(self): 1218 | """ 1219 | Read the current wifi guest network settings for all wifi bands (2g and 5g). 1220 | """ 1221 | xml = self.wifi_guest_network_settings_xml 1222 | 1223 | # the compal modem returns 7 entries per band, the only used element is index 2 1224 | one_and_only_relevant_index = 2 1225 | 1226 | interfaces = { 1227 | "2g": list(xml.iter("Interface"))[one_and_only_relevant_index], 1228 | "5g": list(xml.iter("Interface5G"))[one_and_only_relevant_index], 1229 | } 1230 | 1231 | def guest_xv(band, attr, coherce=True): 1232 | """ 1233 | xml value for the given band 1234 | """ 1235 | try: 1236 | return WifiGuestNetworkSettings.__xml_value( 1237 | interfaces[band], f"{attr}{band.upper()}", coherce 1238 | ) 1239 | except AttributeError: 1240 | return WifiGuestNetworkSettings.__xml_value( 1241 | interfaces[band], f"{attr}{band}", coherce 1242 | ) 1243 | 1244 | return GuestNetworkSettings( 1245 | GuestNetworkEnabling( 1246 | guest_xv("2g", "Enable") == 1, guest_xv("2g", "GuestMac") 1247 | ), 1248 | GuestNetworkEnabling( 1249 | guest_xv("5g", "Enable") == 1, guest_xv("5g", "GuestMac") 1250 | ), 1251 | GuestNetworkProperties( 1252 | guest_xv("2g", "BSSID"), 1253 | guest_xv("2g", "HideNetwork"), 1254 | guest_xv("2g", "GroupRekeyInterval"), 1255 | guest_xv("2g", "SecurityMode"), 1256 | guest_xv("2g", "PreSharedKey"), 1257 | guest_xv("2g", "WpaAlgorithm"), 1258 | ), 1259 | ) 1260 | 1261 | def update_wifi_guest_network_settings(self, properties, enable): 1262 | """ 1263 | Method for updating the wifi guest network settings. Uses fun:308. 1264 | The given properties are applied to all wifi bands (2g and 5g). 1265 | The enabling has only effect on wifi bands that are currently switched on. 1266 | Requires at least firmware CH7465LG-NCIP-6.15.30-1p3-1-NOSH. 1267 | """ 1268 | out_settings = OrderedDict( 1269 | [ 1270 | ("wlEnable", 1 if enable else 2), 1271 | ("wlSsid", properties.ssid), 1272 | ("wlHiden", properties.hidden), 1273 | ("wlRekey", properties.re_key), 1274 | ("wlSecurity", properties.security), 1275 | ("wlPSkey", properties.pre_shared_key), 1276 | ("wlWpaalg", properties.wpa_algorithm), 1277 | ] 1278 | ) 1279 | self.modem.xml_setter( 1280 | SetFunction.WIFI_GUEST_NETWORK_CONFIGURATION, out_settings 1281 | ) 1282 | 1283 | 1284 | class DHCPSettings: 1285 | """ 1286 | Confgure the DHCP settings 1287 | """ 1288 | 1289 | def __init__(self, modem): 1290 | self.modem = modem 1291 | self.parser = etree.XMLParser(recover=True) 1292 | 1293 | def add_static_lease(self, lease_ip, lease_mac): 1294 | """ 1295 | Add a static DHCP lease 1296 | """ 1297 | return self.modem.xml_setter( 1298 | SetFunction.STATIC_DHCP_LEASE, 1299 | {"data": "ADD,{ip},{mac};".format(ip=lease_ip, mac=lease_mac)}, 1300 | ) 1301 | 1302 | def del_static_lease(self, lease_ip, lease_mac): 1303 | """ 1304 | Delete a static DHCP lease 1305 | """ 1306 | return self.modem.xml_setter( 1307 | SetFunction.STATIC_DHCP_LEASE, 1308 | {"data": "DEL,{ip},{mac};".format(ip=lease_ip, mac=lease_mac)}, 1309 | ) 1310 | 1311 | def get_static_leases(self): 1312 | """ 1313 | Get all static leases 1314 | """ 1315 | xml_content = self.modem.xml_getter(GetFunction.BASICDHCP, {}).content 1316 | tree = etree.fromstring(xml_content, parser=self.parser) 1317 | for host in tree.findall("ReserveIpadrr"): 1318 | yield { 1319 | "lease_ip": host.find("LeasedIP").text, 1320 | "lease_mac": host.find("MacAddress").text, 1321 | } 1322 | 1323 | def set_upnp_status(self, enabled): 1324 | """ 1325 | Ensure that UPnP is set to the given value 1326 | """ 1327 | return self.modem.xml_setter( 1328 | SetFunction.UPNP_STATUS, 1329 | OrderedDict( 1330 | [ 1331 | ("LanIP", ""), 1332 | ("UPnP", 1 if enabled else 2), 1333 | ("DHCP_addr_s", ""), 1334 | ("DHCP_addr_e", ""), 1335 | ("subnet_Mask", ""), 1336 | ("DMZ", ""), 1337 | ("DMZenable", ""), 1338 | ] 1339 | ), 1340 | ) 1341 | 1342 | # Changes Router IP too, according to given range 1343 | def set_ipv4_dhcp(self, addr_start, addr_end, num_devices, lease_time, enabled): 1344 | """ 1345 | Change the DHCP range. This implies a change to the router IP 1346 | **check**: The router takes the first IP in the given range 1347 | """ 1348 | return self.modem.xml_setter( 1349 | SetFunction.DHCP_V4, 1350 | OrderedDict( 1351 | [ 1352 | ("action", 1), 1353 | ("addr_start_s", addr_start), 1354 | ("addr_end_s", addr_end), 1355 | ("numberOfCpes_s", num_devices), 1356 | ("leaseTime_s", lease_time), 1357 | ("mac_addr", ""), 1358 | ("reserved_addr", ""), 1359 | ("_del", ""), 1360 | ("enable", 1 if enabled else 2), 1361 | ] 1362 | ), 1363 | ) 1364 | 1365 | def set_ipv6_dhcp( 1366 | self, 1367 | autoconf_type, 1368 | addr_start, 1369 | addr_end, 1370 | num_addrs, 1371 | vlifetime, 1372 | ra_lifetime, 1373 | ra_interval, 1374 | radvd, 1375 | dhcpv6, 1376 | ): 1377 | """ 1378 | Configure IPv6 DHCP settings 1379 | """ 1380 | return self.modem.xml_setter( 1381 | SetFunction.DHCP_V6, 1382 | OrderedDict( 1383 | [ 1384 | ("v6type", autoconf_type), 1385 | ("Addr_start", addr_start), 1386 | ("NumberOfAddrs", num_addrs), 1387 | ("vliftime", vlifetime), 1388 | ("ra_lifetime", ra_lifetime), 1389 | ("ra_interval", ra_interval), 1390 | ("radvd", radvd), 1391 | ("dhcpv6", dhcpv6), 1392 | ("Addr_end", addr_end), 1393 | ] 1394 | ), 1395 | ) 1396 | 1397 | 1398 | class MiscSettings(object): 1399 | """ 1400 | Miscellanious settings 1401 | """ 1402 | 1403 | def __init__(self, modem): 1404 | self.modem = modem 1405 | 1406 | def set_mtu(self, mtu_size): 1407 | """ 1408 | Sets the MTU 1409 | """ 1410 | return self.modem.xml_setter(SetFunction.MTU_SIZE, {"MTUSize": mtu_size}) 1411 | 1412 | def set_remoteaccess(self, enabled, port=8443): 1413 | """ 1414 | Ensure that remote access is enabled/disabled on the given port 1415 | """ 1416 | return self.modem.xml_setter( 1417 | SetFunction.REMOTE_ACCESS, 1418 | OrderedDict([("RemoteAccess", 1 if enabled else 2), ("Port", port)]), 1419 | ) 1420 | 1421 | def set_forgot_pw_email(self, email_addr): 1422 | """ 1423 | Set email address for Forgot Password function 1424 | """ 1425 | return self.modem.xml_setter( 1426 | SetFunction.SET_EMAIL, 1427 | OrderedDict( 1428 | [("email", email_addr), ("emailLen", len(email_addr)), ("opt", 0)] 1429 | ), 1430 | ) 1431 | 1432 | def send_forgot_pw_email(self, email_addr): 1433 | """ 1434 | Send an email to receive new or forgotten password 1435 | """ 1436 | return self.modem.xml_setter( 1437 | SetFunction.SEND_EMAIL, 1438 | OrderedDict( 1439 | [("email", email_addr), ("emailLen", len(email_addr)), ("opt", 0)] 1440 | ), 1441 | ) 1442 | 1443 | 1444 | class DiagToolName(Enum): 1445 | """ 1446 | Enumeration of diagnostic tool names 1447 | """ 1448 | 1449 | ping = "ping" 1450 | traceroute = "traceroute" 1451 | 1452 | 1453 | class Diagnostics(object): 1454 | """ 1455 | Diagnostic functions 1456 | """ 1457 | 1458 | def __init__(self, modem): 1459 | self.modem = modem 1460 | 1461 | def start_pingtest(self, target_addr, ping_size=64, num_ping=3, interval=10): 1462 | """ 1463 | Start Ping-Test 1464 | """ 1465 | return self.modem.xml_setter( 1466 | SetFunction.PING_TEST, 1467 | OrderedDict( 1468 | [ 1469 | ("Type", 1), 1470 | ("Target_IP", target_addr), 1471 | ("Ping_Size", ping_size), 1472 | ("Num_Ping", num_ping), 1473 | ("Ping_Interval", interval), 1474 | ] 1475 | ), 1476 | ) 1477 | 1478 | def stop_pingtest(self): 1479 | """ 1480 | Stop Ping-Test 1481 | """ 1482 | return self.modem.xml_setter( 1483 | SetFunction.STOP_DIAGNOSTIC, {"Ping": DiagToolName.ping} 1484 | ) 1485 | 1486 | def get_pingtest_result(self): 1487 | """ 1488 | Get Ping-Test results 1489 | """ 1490 | return self.modem.xml_getter(GetFunction.PING_RESULT, {}) 1491 | 1492 | def start_traceroute( 1493 | self, target_addr, max_hops, data_size, base_port, resolve_host 1494 | ): 1495 | """ 1496 | Start Traceroute 1497 | """ 1498 | return self.modem.xml_setter( 1499 | SetFunction.TRACEROUTE, 1500 | OrderedDict( 1501 | [ 1502 | ("type", 1), 1503 | ("Tracert_IP", target_addr), 1504 | ("MaxHops", max_hops), 1505 | ("DataSize", data_size), 1506 | ("BasePort", base_port), 1507 | ("ResolveHost", 1 if resolve_host else 0), 1508 | ] 1509 | ), 1510 | ) 1511 | 1512 | def stop_traceroute(self): 1513 | """ 1514 | Stop Traceroute 1515 | """ 1516 | return self.modem.xml_setter( 1517 | SetFunction.STOP_DIAGNOSTIC, 1518 | {"Traceroute": DiagToolName.traceroute}, 1519 | ) 1520 | 1521 | def get_traceroute_result(self): 1522 | """ 1523 | Get Traceroute results 1524 | """ 1525 | return self.modem.xml_getter(GetFunction.TRACEROUTE_RESULT, {}) 1526 | 1527 | 1528 | class BackupRestore(object): 1529 | """ 1530 | Configuration backup and restore 1531 | """ 1532 | 1533 | def __init__(self, modem): 1534 | # The modem sometimes returns invalid XML when 'strange' values are 1535 | # present in the settings. The recovering parser from lxml is used to 1536 | # handle this. 1537 | self.parser = etree.XMLParser(recover=True) 1538 | 1539 | self.modem = modem 1540 | 1541 | def backup(self, filename=None): 1542 | """ 1543 | Backup the configuration and return it's content 1544 | """ 1545 | res = self.modem.xml_getter(GetFunction.GLOBALSETTINGS, {}) 1546 | xml = etree.fromstring(res.content, parser=self.parser) 1547 | 1548 | if not filename: 1549 | fname = xml.find("ConfigVenderModel").text + "-Cfg.bin" 1550 | else: 1551 | fname = filename 1552 | 1553 | res = self.modem.get( 1554 | "/xml/getter.xml", 1555 | params={"filename": fname}, 1556 | allow_redirects=False, 1557 | ) 1558 | if res.status_code != 200: 1559 | LOGGER.error("Did not get configfile response!" " Wrong config file name?") 1560 | return None 1561 | 1562 | return res.content 1563 | 1564 | def restore(self, data): 1565 | """ 1566 | Restore the configuration from the binary string in `data` 1567 | """ 1568 | LOGGER.info("Restoring config. Modem will reboot after that") 1569 | return self.modem.post_binary( 1570 | "/xml/getter.xml", 1571 | data, 1572 | "Cfg_Restore.bin", 1573 | params={"Restore": len(data)}, 1574 | ) 1575 | 1576 | 1577 | class FuncScanner(object): 1578 | """ 1579 | Scan the modem for existing function calls 1580 | """ 1581 | 1582 | def __init__(self, modem, pos, key): 1583 | self.modem = modem 1584 | self.current_pos = pos 1585 | self.key = modem.sanitize_key(key) 1586 | self.last_login = -1 1587 | 1588 | @property 1589 | def is_valid_session(self): 1590 | """ 1591 | Is the current sesion valid? 1592 | """ 1593 | LOGGER.debug("Last login %d", self.last_login) 1594 | res = self.modem.xml_getter(GetFunction.CM_SYSTEM_INFO, {}) 1595 | return res.status_code == 200 1596 | 1597 | def scan(self, quiet=False): 1598 | """ 1599 | Scan the modem for functions. This iterates of the function calls 1600 | """ 1601 | res = None 1602 | while not res or res.text == "": 1603 | if not quiet: 1604 | LOGGER.info("func=%s", self.current_pos) 1605 | 1606 | res = self.modem.xml_getter(self.current_pos, {}) 1607 | if res.text == "": 1608 | if not self.is_valid_session: 1609 | self.last_login = self.current_pos 1610 | self.modem.login(self.key) 1611 | if not quiet: 1612 | LOGGER.info("Had to login at index %s", self.current_pos) 1613 | continue 1614 | 1615 | if res.status_code == 200: 1616 | self.current_pos += 1 1617 | else: 1618 | raise ValueError("HTTP {}".format(res.status_code)) 1619 | 1620 | return res 1621 | 1622 | def scan_to_file(self): 1623 | """ 1624 | Scan and write results to `func_i.xml` for all indices 1625 | """ 1626 | while True: 1627 | res = self.scan() 1628 | xmlstr = minidom.parseString(res.content).toprettyxml(indent=" ") 1629 | with io.open( 1630 | "func_%i.xml" % (self.current_pos - 1), "wt" 1631 | ) as f: # noqa pylint: disable=invalid-name 1632 | f.write("===== HEADERS =====\n") 1633 | f.write(str(res.headers)) 1634 | f.write("\n===== DATA ======\n") 1635 | f.write(xmlstr) 1636 | 1637 | def enumerate(self): 1638 | """ 1639 | Enumerate the function calls, outputting id <=> response tag name pairs 1640 | """ 1641 | while True: 1642 | res = self.scan(quiet=True) 1643 | xml = minidom.parseString(res.content) 1644 | LOGGER.info( 1645 | "%s = %d", 1646 | xml.documentElement.tagName.upper(), 1647 | self.current_pos - 1, 1648 | ) 1649 | 1650 | 1651 | class LanTable: 1652 | """Table of known devices.""" 1653 | 1654 | ETHERNET = "Ethernet" 1655 | WIFI = "WIFI" 1656 | TOTAL = "totalClient" 1657 | 1658 | def __init__(self, modem): 1659 | self.modem = modem 1660 | self.parser = etree.XMLParser(recover=True) 1661 | self.table = None 1662 | self.refresh() 1663 | 1664 | def _parse_lan_table_xml(self, xml): 1665 | table = {LanTable.ETHERNET: [], LanTable.WIFI: []} 1666 | for con_type in table.keys(): 1667 | for client in xml.find(con_type).findall("clientinfo"): 1668 | client_info = {} 1669 | for prop in client: 1670 | client_info[prop.tag] = prop.text 1671 | table[con_type].append(client_info) 1672 | table[LanTable.TOTAL] = xml.find(LanTable.TOTAL).text 1673 | self.table = table 1674 | 1675 | def _check_data(self): 1676 | if self.table is None: 1677 | self.refresh() 1678 | 1679 | def refresh(self): 1680 | resp = self.modem.xml_getter(GetFunction.LANUSERTABLE, {}) 1681 | if resp.status_code != 200: 1682 | LOGGER.error( 1683 | "Didn't receive correct response, try to call " "LanTable.refresh()" 1684 | ) 1685 | return 1686 | xml = etree.fromstring(resp.content, parser=self.parser) 1687 | self._parse_lan_table_xml(xml) 1688 | 1689 | def get_lan(self): 1690 | self._check_data() 1691 | return self.table.get(LanTable.ETHERNET) 1692 | 1693 | def get_wifi(self): 1694 | self._check_data() 1695 | return self.table.get(LanTable.WIFI) 1696 | 1697 | def get_client_count(self): 1698 | self._check_data() 1699 | return self.table.get(LanTable.TOTAL) 1700 | --------------------------------------------------------------------------------