diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..76e0337 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,50 @@ +name: Publish to PyPI + +# Publishes smartthings-local to PyPI on a version tag (v1.2.3). +# Uses PyPI Trusted Publishing (OIDC) — no API token is stored in the repo. +# One-time setup on PyPI: add a trusted publisher for this repo pointing at +# workflow `publish.yml` and environment `pypi`. + +on: + push: + tags: + - "v*" + +jobs: + build: + name: Build sdist + wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade build + - run: python -m build + - name: Verify the built version matches the tag + run: | + ls -l dist/ + version="${GITHUB_REF_NAME#v}" + if ! ls dist/ | grep -q "smartthings_local-${version}"; then + echo "Built artifacts do not match tag version ${version}"; exit 1 + fi + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # required for Trusted Publishing (OIDC) + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index e43687b..621bfb5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ __pycache__/ .venv/ venv/ +# Packaging / build artifacts +/smartthings_local/_version.py +/dist/ +*.egg-info/ + # Bridge runtime bridge.log course_mapper.log diff --git a/mqtt_demo/Dockerfile b/mqtt_demo/Dockerfile index 8510c75..da12450 100644 --- a/mqtt_demo/Dockerfile +++ b/mqtt_demo/Dockerfile @@ -8,9 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt # Application code — build context is the repo root (see # docker-compose.yml's `context: ..`) since mqtt_demo/ depends on the -# protocol/ and ocf/ library packages that live alongside it. -COPY protocol/ ./protocol/ -COPY ocf/ ./ocf/ +# smartthings_local library package that lives alongside it. +COPY smartthings_local/ ./smartthings_local/ COPY mqtt_demo/ ./mqtt_demo/ # /config holds the ab0b0ac4 client cert + key. Mount from the host so diff --git a/mqtt_demo/bridge.py b/mqtt_demo/bridge.py index 36b8747..b5f6039 100644 --- a/mqtt_demo/bridge.py +++ b/mqtt_demo/bridge.py @@ -23,12 +23,12 @@ import time import cbor2 -from protocol.dtls_session import DtlsCoapSession, fmt_code +from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code -from ocf.keepalive import KeepaliveTask -from ocf.observe_refresh import ObserveRefreshTask -from ocf.poll_scheduler import PollScheduler -from ocf.state_cache import StateCache +from smartthings_local.ocf.keepalive import KeepaliveTask +from smartthings_local.ocf.observe_refresh import ObserveRefreshTask +from smartthings_local.ocf.poll_scheduler import PollScheduler +from smartthings_local.ocf.state_cache import StateCache from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery from .config import ApplianceConfig, SharedConfig diff --git a/mqtt_demo/deploy.sh b/mqtt_demo/deploy.sh index c160a49..9748e9a 100755 --- a/mqtt_demo/deploy.sh +++ b/mqtt_demo/deploy.sh @@ -22,7 +22,7 @@ set -e # This script lives in mqtt_demo/ but the build context is the repo # root (mqtt_demo/docker-compose.yml uses `context: ..`, since the -# image needs the protocol/ and ocf/ library packages alongside +# image needs the smartthings_local library package alongside # mqtt_demo/). Run everything from the repo root so the tar allowlist # and remote layout line up with that context. cd "$(dirname "$0")/.." @@ -51,12 +51,11 @@ ssh "${SSH_HOST}" mkdir -p "${REMOTE_DIR}" "${APPDATA_DIR}" # Source code — explicit allowlist instead of an excludelist. Anything # else in the repo (research files, certs, logs, the .git dir) stays -# local. protocol/ and ocf/ are the library packages mqtt_demo/ imports -# from; they need to land as REMOTE_DIR's siblings of mqtt_demo/ so the +# local. smartthings_local/ is the library package mqtt_demo/ imports +# from; it needs to land as REMOTE_DIR's sibling of mqtt_demo/ so the # compose file's `context: ..` resolves the same way it does locally. COPYFILE_DISABLE=1 tar cz \ - protocol/ \ - ocf/ \ + smartthings_local/ \ mqtt_demo/ \ README.md \ .gitignore \ diff --git a/mqtt_demo/descriptor.py b/mqtt_demo/descriptor.py index ed335d2..34984d3 100644 --- a/mqtt_demo/descriptor.py +++ b/mqtt_demo/descriptor.py @@ -27,7 +27,7 @@ from dataclasses import dataclass, field from typing import Callable, Optional, TYPE_CHECKING if TYPE_CHECKING: - from ocf.poll_scheduler import PollTier + from smartthings_local.ocf.poll_scheduler import PollTier @dataclass diff --git a/mqtt_demo/samples/dryer.py b/mqtt_demo/samples/dryer.py index 533b97d..aded197 100644 --- a/mqtt_demo/samples/dryer.py +++ b/mqtt_demo/samples/dryer.py @@ -13,7 +13,7 @@ from ..descriptor import ( device_block, encode, ) -from ocf.poll_scheduler import PollTier +from smartthings_local.ocf.poll_scheduler import PollTier # --- OBSERVE paths ----------------------------------------------------- diff --git a/mqtt_demo/samples/fridge.py b/mqtt_demo/samples/fridge.py index 49b48c3..82e5567 100644 --- a/mqtt_demo/samples/fridge.py +++ b/mqtt_demo/samples/fridge.py @@ -30,7 +30,7 @@ from ..descriptor import ( device_block, encode, ) -from ocf.poll_scheduler import PollTier +from smartthings_local.ocf.poll_scheduler import PollTier MODEL = 'ARTIK051_REF_17K' diff --git a/mqtt_demo/samples/oven.py b/mqtt_demo/samples/oven.py index 48c6ce8..83b4068 100644 --- a/mqtt_demo/samples/oven.py +++ b/mqtt_demo/samples/oven.py @@ -31,7 +31,7 @@ from ..descriptor import ( device_block, encode, ) -from ocf.poll_scheduler import PollTier +from smartthings_local.ocf.poll_scheduler import PollTier # --------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ae1273 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "smartthings-local" +dynamic = ["version"] +description = "Local CoAP-over-DTLS (OCF) transport + polling layer for Samsung SmartThings appliances" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Jack Nagy" }] +keywords = ["smartthings", "samsung", "ocf", "coap", "dtls", "home-assistant", "iot"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Home Automation", + "Topic :: System :: Networking", + "Programming Language :: Python :: 3", +] +dependencies = [ + "cbor2>=5.6", + "pyOpenSSL>=23.0", +] + +[project.urls] +Homepage = "https://github.com/QuiteYellow/SmartThings-Local" +Repository = "https://github.com/QuiteYellow/SmartThings-Local" +Issues = "https://github.com/QuiteYellow/SmartThings-Local/issues" + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[tool.hatch.version] +source = "vcs" + +# Derive the version from git tags of the form v1.2.3. +[tool.hatch.version.raw-options] +version_scheme = "no-guess-dev" + +[tool.hatch.build.hooks.vcs] +version-file = "smartthings_local/_version.py" + +# ocf_root_ca.pem is tracked, but the repo .gitignore's `*.pem` rule makes +# hatchling's VCS-based file selection drop it. It's loaded at runtime by +# protocol/dtls_session.py (load_verify_locations), so force it into every +# build target (wheel + sdist). +[tool.hatch.build] +artifacts = ["smartthings_local/protocol/ocf_root_ca.pem"] + +[tool.hatch.build.targets.wheel] +# Ship only the library; the mqtt_demo/, tests/, and setup_cert.py stay +# in-repo and are not part of the distributable package. +packages = ["smartthings_local"] + +[tool.hatch.build.targets.sdist] +include = [ + "smartthings_local", + "tests", + "README.md", + "LICENSE", + "pyproject.toml", +] diff --git a/smartthings_local/__init__.py b/smartthings_local/__init__.py new file mode 100644 index 0000000..aaf55fd --- /dev/null +++ b/smartthings_local/__init__.py @@ -0,0 +1,2 @@ +"""smartthings-local: local CoAP-over-DTLS (OCF) transport + polling layer +for Samsung SmartThings appliances.""" diff --git a/ocf/__init__.py b/smartthings_local/ocf/__init__.py similarity index 100% rename from ocf/__init__.py rename to smartthings_local/ocf/__init__.py diff --git a/ocf/keepalive.py b/smartthings_local/ocf/keepalive.py similarity index 98% rename from ocf/keepalive.py rename to smartthings_local/ocf/keepalive.py index 9543770..0101866 100644 --- a/ocf/keepalive.py +++ b/smartthings_local/ocf/keepalive.py @@ -20,7 +20,7 @@ from __future__ import annotations import threading from typing import Callable, Optional -from protocol.dtls_session import DtlsCoapSession +from smartthings_local.protocol.dtls_session import DtlsCoapSession class KeepaliveTask: diff --git a/ocf/observe_refresh.py b/smartthings_local/ocf/observe_refresh.py similarity index 96% rename from ocf/observe_refresh.py rename to smartthings_local/ocf/observe_refresh.py index 15a9ca0..47cd591 100644 --- a/ocf/observe_refresh.py +++ b/smartthings_local/ocf/observe_refresh.py @@ -17,7 +17,7 @@ from __future__ import annotations import threading from typing import Optional -from protocol.dtls_session import DtlsCoapSession +from smartthings_local.protocol.dtls_session import DtlsCoapSession class ObserveRefreshTask: diff --git a/ocf/poll_scheduler.py b/smartthings_local/ocf/poll_scheduler.py similarity index 99% rename from ocf/poll_scheduler.py rename to smartthings_local/ocf/poll_scheduler.py index 6634957..6704cf5 100644 --- a/ocf/poll_scheduler.py +++ b/smartthings_local/ocf/poll_scheduler.py @@ -36,7 +36,7 @@ from typing import Callable, Optional, TYPE_CHECKING import cbor2 -from protocol.dtls_session import DtlsCoapSession, fmt_code +from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code if TYPE_CHECKING: from .state_cache import StateCache diff --git a/ocf/state_cache.py b/smartthings_local/ocf/state_cache.py similarity index 100% rename from ocf/state_cache.py rename to smartthings_local/ocf/state_cache.py diff --git a/protocol/__init__.py b/smartthings_local/protocol/__init__.py similarity index 100% rename from protocol/__init__.py rename to smartthings_local/protocol/__init__.py diff --git a/protocol/coap.py b/smartthings_local/protocol/coap.py similarity index 100% rename from protocol/coap.py rename to smartthings_local/protocol/coap.py diff --git a/protocol/dtls_session.py b/smartthings_local/protocol/dtls_session.py similarity index 100% rename from protocol/dtls_session.py rename to smartthings_local/protocol/dtls_session.py diff --git a/protocol/ocf_root_ca.pem b/smartthings_local/protocol/ocf_root_ca.pem similarity index 100% rename from protocol/ocf_root_ca.pem rename to smartthings_local/protocol/ocf_root_ca.pem diff --git a/tests/test_coap_wire.py b/tests/test_coap_wire.py index e76fa98..6f5f0eb 100644 --- a/tests/test_coap_wire.py +++ b/tests/test_coap_wire.py @@ -1,4 +1,4 @@ -from protocol.coap import ( +from smartthings_local.protocol.coap import ( build_coap, parse_coap, encode_options, block_value, fmt_code, TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2, ) diff --git a/tests/test_import_isolation.py b/tests/test_import_isolation.py index d2d6119..8efcdbb 100644 --- a/tests/test_import_isolation.py +++ b/tests/test_import_isolation.py @@ -1,8 +1,8 @@ -"""protocol/ and ocf/ must be vendorable on their own — no dependency -on mqtt_demo/. This copies just those two directories into an empty -temp dir and imports every module in them there, so a stray -`from mqtt_demo... import ...` fails loudly instead of silently -passing because mqtt_demo/ happens to also be on sys.path in-repo.""" +"""smartthings_local (protocol/ + ocf/) must be vendorable on its own — no +dependency on mqtt_demo/. This copies just the smartthings_local package into +an empty temp dir and imports every module in it there, so a stray +`from mqtt_demo... import ...` fails loudly instead of silently passing +because mqtt_demo/ happens to also be on sys.path in-repo.""" import os import shutil import subprocess @@ -12,17 +12,16 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent -def test_protocol_and_ocf_import_without_mqtt_demo_present(tmp_path): - for pkg in ('protocol', 'ocf'): - shutil.copytree(REPO_ROOT / pkg, tmp_path / pkg) +def test_smartthings_local_imports_without_mqtt_demo_present(tmp_path): + shutil.copytree(REPO_ROOT / "smartthings_local", tmp_path / "smartthings_local") import_lines = [ - "import protocol.coap", - "import protocol.dtls_session", - "import ocf.state_cache", - "import ocf.poll_scheduler", - "import ocf.keepalive", - "import ocf.observe_refresh", + "import smartthings_local.protocol.coap", + "import smartthings_local.protocol.dtls_session", + "import smartthings_local.ocf.state_cache", + "import smartthings_local.ocf.poll_scheduler", + "import smartthings_local.ocf.keepalive", + "import smartthings_local.ocf.observe_refresh", ] script = "\n".join(import_lines) + "\nprint('OK')\n" @@ -36,7 +35,7 @@ def test_protocol_and_ocf_import_without_mqtt_demo_present(tmp_path): capture_output=True, text=True, ) assert result.returncode == 0, ( - f"protocol/ocf failed to import without mqtt_demo/ present:\n" + f"smartthings_local failed to import without mqtt_demo/ present:\n" f"stdout: {result.stdout}\nstderr: {result.stderr}" ) assert "OK" in result.stdout diff --git a/tests/test_state_cache.py b/tests/test_state_cache.py index a505176..82c9807 100644 --- a/tests/test_state_cache.py +++ b/tests/test_state_cache.py @@ -1,5 +1,5 @@ # tests/test_state_cache.py -from ocf.state_cache import StateCache +from smartthings_local.ocf.state_cache import StateCache class _FakeDescriptor: