pax_global_header00006660000000000000000000000064152426112350014513gustar00rootroot0000000000000052 comment=9bf222f8a07ee09d6b72abf9402d3036151e1a0c pykmtronic-0.4.0/000077500000000000000000000000001524261123500137135ustar00rootroot00000000000000pykmtronic-0.4.0/.github/000077500000000000000000000000001524261123500152535ustar00rootroot00000000000000pykmtronic-0.4.0/.github/workflows/000077500000000000000000000000001524261123500173105ustar00rootroot00000000000000pykmtronic-0.4.0/.github/workflows/ci.yml000066400000000000000000000014011524261123500204220ustar00rootroot00000000000000name: CI on: push: branches: - master pull_request: workflow_dispatch: permissions: contents: read jobs: package: name: Build package runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install release tools run: | python -m pip install --upgrade pip python -m pip install -e ".[release,test]" - name: Run tests run: python -m pytest - name: Build distributions run: python -m build - name: Check distributions run: python -m twine check dist/* pykmtronic-0.4.0/.github/workflows/python-publish.yml000066400000000000000000000027451524261123500230300ustar00rootroot00000000000000name: Publish Python Package on: push: tags: - "*.*.*" release: types: [published] workflow_dispatch: permissions: contents: read jobs: build: name: Build distributions runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install release tools run: | python -m pip install --upgrade pip python -m pip install -e ".[release,test]" - name: Run tests run: python -m pytest - name: Build distributions run: python -m build - name: Check distributions run: python -m twine check dist/* - name: Upload distributions uses: actions/upload-artifact@v7 with: name: release-dists path: dist/ if-no-files-found: error publish: name: Publish to PyPI runs-on: ubuntu-latest needs: build if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'release' environment: name: pypi url: https://pypi.org/p/pykmtronic permissions: id-token: write steps: - name: Download distributions uses: actions/download-artifact@v7 with: name: release-dists path: dist/ - name: Publish distributions uses: pypa/gh-action-pypi-publish@release/v1 pykmtronic-0.4.0/.gitignore000066400000000000000000000001451524261123500157030ustar00rootroot00000000000000*.code-workspace *.egg-info *.pyc /build /dist __pycache__/ /.vscode /lixo.py /teste.py /venv /.venv pykmtronic-0.4.0/CHANGES.md000066400000000000000000000003111524261123500153000ustar00rootroot00000000000000# 0.4.0 (2026-08-23) Bump dependencies # 0.3.0 (2021-04-13) Serialize HTTP requests by spacing 100ms # 0.2.0 (2021-03-15) Adds toggle() method to use relays.cgi?relay=NUMBER # 0.1.0 Initial Release pykmtronic-0.4.0/Makefile000066400000000000000000000004061524261123500153530ustar00rootroot00000000000000.PHONY: build check-dist clean release-check test PYTHON ?= .venv/bin/python test: $(PYTHON) -m pytest build: $(PYTHON) -m build check-dist: $(PYTHON) -m twine check dist/* clean: rm -rf build dist *.egg-info release-check: clean test build check-dist pykmtronic-0.4.0/README.md000066400000000000000000000005061524261123500151730ustar00rootroot00000000000000# pykmtronic [![PyPI version](https://badge.fury.io/py/pykmtronic.svg)](https://badge.fury.io/py/pykmtronic) This library provides a Python3 interface to [KM-Tronic web relays](https://info.kmtronic.com/lan-relays.html). # Tested equipments: - https://info.kmtronic.com/manuals/user_manuals/W2CR_WEB_TWO_CHANNEL_RELAY.pdf pykmtronic-0.4.0/RELEASING.md000066400000000000000000000015641524261123500155540ustar00rootroot00000000000000# Releasing This project uses standard PEP 517 builds and derives package versions from git tags with `setuptools-scm`. ## One-time setup Create a PyPI Trusted Publisher for this repository: - Project: `pykmtronic` - Owner/repository: `dgomes/pykmtronic` - Workflow: `python-publish.yml` - Environment: `pypi` ## Local checks Use the project virtualenv for release tooling: ```console python3 -m venv .venv .venv/bin/pip install -e ".[release,test]" .venv/bin/python -m pytest .venv/bin/python -m build .venv/bin/python -m twine check dist/* ``` The same checks are available through `make release-check`. ## Publish 1. Update `CHANGES.md`. 2. Commit the release changes. 3. Create and push a version tag: ```console git tag 0.4.0 git push origin 0.4.0 ``` GitHub Actions will build the source distribution and wheel, then publish them to PyPI through Trusted Publishing. pykmtronic-0.4.0/example.py000066400000000000000000000020641524261123500157220ustar00rootroot00000000000000"""Example usage of pykmtronic.""" import asyncio import aiohttp import time import logging from pykmtronic.auth import Auth from pykmtronic.hub import KMTronicHubAPI logging.basicConfig(level=logging.DEBUG) async def main(): async with aiohttp.ClientSession() as session: auth = Auth(session, "http://192.168.1.160", "admin", "admin") api = KMTronicHubAPI(auth) relays = await api.async_get_relays() r = relays[0] print("is relay1 ON?", r.is_energised) await r.energise() time.sleep(2) print("is relay1 ON?", r.is_energised) await r.de_energise() time.sleep(2) await api.async_update_relays() print("is relay1 ON?", r.is_energised) time.sleep(2) await r.toggle() time.sleep(2) await api.async_update_relays() print("is relay1 ON?", r.is_energised) time.sleep(2) await r.toggle() time.sleep(2) await api.async_update_relays() print("is relay1 ON?", r.is_energised) asyncio.run(main()) pykmtronic-0.4.0/pykmtronic/000077500000000000000000000000001524261123500161125ustar00rootroot00000000000000pykmtronic-0.4.0/pykmtronic/__init__.py000066400000000000000000000004531524261123500202250ustar00rootroot00000000000000from .consts import * try: from importlib.metadata import PackageNotFoundError, version except ImportError: # pragma: no cover from importlib_metadata import PackageNotFoundError, version try: __version__ = version("pykmtronic") except PackageNotFoundError: __version__ = "0.0.0" pykmtronic-0.4.0/pykmtronic/auth.py000066400000000000000000000014451524261123500174310ustar00rootroot00000000000000import asyncio from aiohttp import ClientSession, ClientResponse, encode_basic_auth class Auth: """Class to make authenticated requests.""" def __init__(self, websession: ClientSession, host: str, user: str, password: str): """Initialize the auth.""" self.websession = websession self.host = host self._auth_header = encode_basic_auth(user, password) self.lock = asyncio.Lock() async def request(self, path: str) -> ClientResponse: """Make a request.""" async with self.lock: response = await self.websession.request( "GET", f"{self.host}/{path}", headers={"Authorization": self._auth_header}, ) await asyncio.sleep(0.1) return response pykmtronic-0.4.0/pykmtronic/consts.py000066400000000000000000000000401524261123500177670ustar00rootroot00000000000000STATUS_ENDPOINT = "/status.xml" pykmtronic-0.4.0/pykmtronic/hub.py000066400000000000000000000032441524261123500172450ustar00rootroot00000000000000"""Representation of a KMtronic Device.""" from typing import List from lxml import etree import logging from .auth import Auth from .relay import Relay logger = logging.getLogger(__name__) class KMTronicHubAPI: """Class to communicate with the KMTronic Web based API.""" def __init__(self, auth: Auth): """Initialize the API and store the auth so we can make requests.""" self.auth = auth self.host = auth.host self.relays = [] @property def name(self) -> str: """Return the name of the kmtronic device, usually an IP.""" return self.host async def async_get_status(self) -> List[tuple]: """Return status on relays.""" resp = await self.auth.request("status.xml") resp.raise_for_status() response_xml = etree.fromstring(await resp.text()) response = response_xml.xpath("/response/*") status = {} for relay in response[1:]: relay_num = int(relay.tag[5:]) relay_status = relay.text == "1" status[relay_num] = relay_status return status async def async_get_relays(self) -> List[Relay]: """Create relay representations from status information.""" status = await self.async_get_status() self.relays = [ Relay(number, status, self.auth) for number, status in status.items() ] return self.relays async def async_update_relays(self): """Update the status of each relay.""" status = await self.async_get_status() logger.debug("Status of relays: %s", status) for relay in self.relays: relay.is_energised = status[relay.id] pykmtronic-0.4.0/pykmtronic/relay.py000066400000000000000000000033241524261123500176020ustar00rootroot00000000000000"""Representation of a single relay.""" from pykmtronic.auth import Auth import logging logger = logging.getLogger(__name__) class Relay: """Relay Object.""" def __init__(self, number: int, status: bool, auth: Auth): """Relay constructor.""" self._relay = number self.auth = auth self.is_energised = status # True means energised/ON @property def id(self) -> int: """Id of the relay, position of the relay in the device.""" return self._relay @property def is_energised(self): """Wether the relay is energised (ON/True) or not (OFF/False).""" return self._is_energised @is_energised.setter def is_energised(self, b): """Set state variable _is_energised.""" logger.debug( f"Relay{self._relay} is now {'Energised' if b else 'De-energised'}" ) self._is_energised = b async def energise(self): """Energises the relay connecting COM to NO.""" logger.debug(f"Sending ... FF{self._relay:02}01") resp = await self.auth.request(f"FF{self._relay:02}01") resp.raise_for_status() self.is_energised = True async def de_energise(self): """De-energises the relay connecting COM to NO.""" logger.debug(f"Sending ... FF{self._relay:02}00") resp = await self.auth.request(f"FF{self._relay:02}00") resp.raise_for_status() self.is_energised = False async def toggle(self): """Toggle the relay.""" logger.debug(f"Calling relays.cgi?relay={self._relay}") resp = await self.auth.request(f"relays.cgi?relay={self._relay}") resp.raise_for_status() self.is_energised = not self.is_energised pykmtronic-0.4.0/pyproject.toml000066400000000000000000000025171524261123500166340ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=80", "setuptools-scm>=8", ] build-backend = "setuptools.build_meta" [project] name = "pykmtronic" description = "Python library to interface with KM Tronic Web Relays" readme = "README.md" requires-python = ">=3.9" license = "MIT" authors = [ { name = "Diogo Gomes", email = "diogogomes@gmail.com" }, ] dependencies = [ "aiohttp>=3.14.0", "lxml>=4.5.2", ] dynamic = ["version"] classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development :: Libraries :: Python Modules", ] [project.optional-dependencies] release = [ "build>=1.2", "setuptools>=80", "setuptools-scm>=8", "twine>=6.0", ] test = [ "pytest>=8.0", "pytest-asyncio>=0.23", ] [project.urls] Homepage = "https://github.com/dgomes/pykmtronic" Source = "https://github.com/dgomes/pykmtronic" Changelog = "https://github.com/dgomes/pykmtronic/blob/master/CHANGES.md" Issues = "https://github.com/dgomes/pykmtronic/issues" [tool.setuptools] packages = ["pykmtronic"] zip-safe = true [tool.setuptools_scm] local_scheme = "no-local-version" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] pykmtronic-0.4.0/requirements.txt000066400000000000000000000000341524261123500171740ustar00rootroot00000000000000aiohttp>=3.14.0 lxml>=4.5.2 pykmtronic-0.4.0/setup.py000066400000000000000000000000471524261123500154260ustar00rootroot00000000000000from setuptools import setup setup() pykmtronic-0.4.0/tests/000077500000000000000000000000001524261123500150555ustar00rootroot00000000000000pykmtronic-0.4.0/tests/test_auth.py000066400000000000000000000016361524261123500174350ustar00rootroot00000000000000from unittest.mock import AsyncMock from aiohttp import encode_basic_auth from pykmtronic.auth import Auth class FakeSession: def __init__(self): self.calls = [] self.response = object() async def request(self, method, url, headers): self.calls.append((method, url, headers)) return self.response async def test_request_sends_basic_auth_header_and_spaces_requests(monkeypatch): sleep = AsyncMock() monkeypatch.setattr("pykmtronic.auth.asyncio.sleep", sleep) session = FakeSession() auth = Auth(session, "http://relay.local", "admin", "secret") response = await auth.request("status.xml") assert response is session.response assert session.calls == [ ( "GET", "http://relay.local/status.xml", {"Authorization": encode_basic_auth("admin", "secret")}, ) ] sleep.assert_awaited_once_with(0.1) pykmtronic-0.4.0/tests/test_hub.py000066400000000000000000000031241524261123500172440ustar00rootroot00000000000000from pykmtronic.hub import KMTronicHubAPI class FakeResponse: def __init__(self, body): self.body = body self.raise_for_status_called = False def raise_for_status(self): self.raise_for_status_called = True async def text(self): return self.body class FakeAuth: host = "http://relay.local" def __init__(self, responses): self.responses = list(responses) self.requests = [] async def request(self, path): self.requests.append(path) return self.responses.pop(0) def status_xml(relay1, relay2): return f""" KMTronic {relay1} {relay2} """ async def test_async_get_status_parses_relay_states(): response = FakeResponse(status_xml("1", "0")) hub = KMTronicHubAPI(FakeAuth([response])) status = await hub.async_get_status() assert status == {1: True, 2: False} assert hub.auth.requests == ["status.xml"] assert response.raise_for_status_called async def test_async_get_relays_and_update_relays(): auth = FakeAuth( [ FakeResponse(status_xml("1", "0")), FakeResponse(status_xml("0", "1")), ] ) hub = KMTronicHubAPI(auth) relays = await hub.async_get_relays() assert [relay.id for relay in relays] == [1, 2] assert [relay.is_energised for relay in relays] == [True, False] await hub.async_update_relays() assert [relay.is_energised for relay in relays] == [False, True] assert auth.requests == ["status.xml", "status.xml"] pykmtronic-0.4.0/tests/test_relay.py000066400000000000000000000015431524261123500176050ustar00rootroot00000000000000from pykmtronic.relay import Relay class FakeResponse: def __init__(self): self.raise_for_status_called = False def raise_for_status(self): self.raise_for_status_called = True class FakeAuth: def __init__(self): self.requests = [] self.responses = [] async def request(self, path): response = FakeResponse() self.requests.append(path) self.responses.append(response) return response async def test_relay_commands_call_expected_paths_and_update_state(): auth = FakeAuth() relay = Relay(2, False, auth) await relay.energise() await relay.de_energise() await relay.toggle() assert auth.requests == ["FF0201", "FF0200", "relays.cgi?relay=2"] assert [response.raise_for_status_called for response in auth.responses] assert relay.is_energised is True