Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/linting.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Linting

on:
push:
branches: [main]
pull_request:

jobs:
ruff:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install ruff

- name: Run ruff
run: ruff check src
39 changes: 39 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Tests

on:
push:
branches: [main]
pull_request:

jobs:
pytest:
runs-on: ubuntu-latest

strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-asyncio pytest-cov aioresponses

- name: Run tests with coverage
run: |
pytest

- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.python-version }}
path: coverage.xml
28 changes: 28 additions & 0 deletions .github/workflows/typing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Typing

on:
push:
branches: [main]
pull_request:

jobs:
mypy:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install mypy

- name: Run mypy
run: mypy src/hdfury
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
# General files
*~
*.DS_STORE

# pytest
.coverage
coverage.xml
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ print(TX0_INPUT_PORTS["1"]) # Output: Input 1
| `"1"` | Input 1 |
| `"2"` | Input 2 |
| `"3"` | Input 3 |
| `"4"` | Copy TX0 |
| `"4"` | Copy TX1 |

### `TX1_INPUT_PORTS`

Expand All @@ -161,7 +161,7 @@ print(TX1_INPUT_PORTS["3"]) # Output: Input 3
| `"1"` | Input 1 |
| `"2"` | Input 2 |
| `"3"` | Input 3 |
| `"4"` | Copy TX1 |
| `"4"` | Copy TX0 |

## License

Expand Down
39 changes: 38 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ authors = [
]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.0.0",
]
Expand All @@ -19,3 +19,40 @@ Issues = "https://github.com/glenndehaan/python-hdfury/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/hdfury"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = [
"--strict-markers",
"--cov=hdfury",
"--cov-report=term-missing",
"--cov-report=xml",
]

[tool.ruff]
line-length = 120
exclude = ["build", ".venv"]

[tool.ruff.lint]
select = ["E", "F", "W", "I"]
extend-select = ["B", "D"]
ignore = [
"D203", # incompatible with D211
"D213", # incompatible with D212
]
extend-ignore = []

[tool.mypy]
python_version = 3.11
files = "src,hdfury"
ignore_missing_imports = true
strict = true
disallow_untyped_defs = true
warn_unused_ignores = true
warn_return_any = true
pretty = true
show_error_codes = true
12 changes: 10 additions & 2 deletions src/hdfury/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

from .api import HDFuryAPI
from .const import OPERATION_MODES, TX0_INPUT_PORTS, TX1_INPUT_PORTS
from .exceptions import HDFuryError
from .exceptions import HDFuryConnectionError, HDFuryError, HDFuryParseError

__all__ = ["OPERATION_MODES", "TX0_INPUT_PORTS", "TX1_INPUT_PORTS", "HDFuryAPI", "HDFuryError"]
__all__ = [
"OPERATION_MODES",
"TX0_INPUT_PORTS",
"TX1_INPUT_PORTS",
"HDFuryAPI",
"HDFuryConnectionError",
"HDFuryError",
"HDFuryParseError",
]
41 changes: 20 additions & 21 deletions src/hdfury/api.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
"""HDFury Client API."""

import asyncio
from asyncio import TimeoutError
import json
import time
from typing import Literal
from asyncio import TimeoutError
from typing import Literal, cast

import aiohttp
from aiohttp import ClientError, ClientResponseError
from aiohttp import ClientError, ClientResponseError, ClientTimeout

from .exceptions import HDFuryConnectionError, HDFuryParseError

StateStr = Literal["0", "1", "off", "on"]


class HDFuryAPI:
"""Asynchronous API client for HDFury devices."""

def __init__(self, host: str, session: aiohttp.ClientSession | None = None) -> None:
"""HDFury API Client."""

self.host = host
self._session = session or aiohttp.ClientSession()
self._last_command_time = 0
self._debounce_delay = 2 # seconds

def _normalize_state(self, state: StateStr, *, output: Literal["text", "number"] = "text") -> str:
self.host: str = host
self._session: aiohttp.ClientSession = session or aiohttp.ClientSession()
self._last_command_time: float = 0
self._debounce_delay: int = 2 # seconds

@staticmethod
def _normalize_state(state: str, output: Literal["text", "number"] = "text") -> str:
"""Normalize state and optionally convert return type."""
state = state.lower()

if state in ("1", "on"):
Expand All @@ -36,7 +35,7 @@ def _normalize_state(self, state: StateStr, *, output: Literal["text", "number"]
raise HDFuryParseError(f"Invalid state: {state}")

async def _wait_for_debounce(self) -> None:
"""Helper to ensure at least `_debounce_delay` seconds have passed since last command."""
"""Wait until at least `_debounce_delay` seconds have passed since the last command."""
elapsed = time.time() - self._last_command_time
if elapsed < self._debounce_delay:
wait_time = self._debounce_delay - elapsed
Expand All @@ -47,41 +46,41 @@ async def _request(self, endpoint: str) -> str:
url = f"http://{self.host}{endpoint}"

try:
async with self._session.get(url, timeout=10) as response:
async with self._session.get(url, timeout=ClientTimeout(total=10)) as response:
if response.status != 200:
raise HDFuryConnectionError(
f"Unexpected response from: {url} (Status: {response.status})"
)

return await response.text()
except TimeoutError:
raise HDFuryConnectionError(f"Timeout while fetching: {url}")
except TimeoutError as err:
raise HDFuryConnectionError(f"Timeout while fetching: {url}") from err
except (ClientError, ClientResponseError) as err:
raise HDFuryConnectionError(f"Request failed ({url}): {err}") from err
except Exception as err:
raise HDFuryConnectionError(f"Unexpected error ({url}): {err}") from err

async def _request_json(self, path: str) -> dict:
async def _request_json(self, path: str) -> dict[str, str]:
"""Handle a request to the HDFury device and parse JSON."""
response = await self._request(path)
try:
return json.loads(response)
return cast(dict[str, str], json.loads(response))
except json.JSONDecodeError as err:
raise HDFuryParseError(f"Unable to decode JSON: {err}") from err

async def get_board(self) -> dict:
async def get_board(self) -> dict[str, str]:
"""Fetch board info."""
await self._wait_for_debounce()
response = await self._request_json("/ssi/brdinfo.ssi")
return response

async def get_info(self) -> dict:
async def get_info(self) -> dict[str, str]:
"""Fetch device info."""
await self._wait_for_debounce()
response = await self._request_json("/ssi/infopage.ssi")
return response

async def get_config(self) -> dict:
async def get_config(self) -> dict[str, str]:
"""Fetch device configuration."""
await self._wait_for_debounce()
config_response = await self._request_json("/ssi/confpage.ssi")
Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the HDFury library."""
Loading