Merge pull request #12 from mbillow/cert-pem-support
Support in-memory PEM cert/key alongside file paths in DtlsCoapSession
This commit is contained in:
@@ -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
|
||||||
@@ -17,6 +17,11 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
|
# Packaging / build artifacts
|
||||||
|
/smartthings_local/_version.py
|
||||||
|
/dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
# Bridge runtime
|
# Bridge runtime
|
||||||
bridge.log
|
bridge.log
|
||||||
course_mapper.log
|
course_mapper.log
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
# Application code — build context is the repo root (see
|
# Application code — build context is the repo root (see
|
||||||
# docker-compose.yml's `context: ..`) since mqtt_demo/ depends on the
|
# docker-compose.yml's `context: ..`) since mqtt_demo/ depends on the
|
||||||
# protocol/ and ocf/ library packages that live alongside it.
|
# smartthings_local library package that lives alongside it.
|
||||||
COPY protocol/ ./protocol/
|
COPY smartthings_local/ ./smartthings_local/
|
||||||
COPY ocf/ ./ocf/
|
|
||||||
COPY mqtt_demo/ ./mqtt_demo/
|
COPY mqtt_demo/ ./mqtt_demo/
|
||||||
|
|
||||||
# /config holds the ab0b0ac4 client cert + key. Mount from the host so
|
# /config holds the ab0b0ac4 client cert + key. Mount from the host so
|
||||||
|
|||||||
+5
-5
@@ -23,12 +23,12 @@ import time
|
|||||||
|
|
||||||
import cbor2
|
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 smartthings_local.ocf.keepalive import KeepaliveTask
|
||||||
from ocf.observe_refresh import ObserveRefreshTask
|
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||||
from ocf.poll_scheduler import PollScheduler
|
from smartthings_local.ocf.poll_scheduler import PollScheduler
|
||||||
from ocf.state_cache import StateCache
|
from smartthings_local.ocf.state_cache import StateCache
|
||||||
|
|
||||||
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
|
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
|
||||||
from .config import ApplianceConfig, SharedConfig
|
from .config import ApplianceConfig, SharedConfig
|
||||||
|
|||||||
+4
-5
@@ -22,7 +22,7 @@ set -e
|
|||||||
|
|
||||||
# This script lives in mqtt_demo/ but the build context is the repo
|
# This script lives in mqtt_demo/ but the build context is the repo
|
||||||
# root (mqtt_demo/docker-compose.yml uses `context: ..`, since the
|
# 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
|
# mqtt_demo/). Run everything from the repo root so the tar allowlist
|
||||||
# and remote layout line up with that context.
|
# and remote layout line up with that context.
|
||||||
cd "$(dirname "$0")/.."
|
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
|
# Source code — explicit allowlist instead of an excludelist. Anything
|
||||||
# else in the repo (research files, certs, logs, the .git dir) stays
|
# else in the repo (research files, certs, logs, the .git dir) stays
|
||||||
# local. protocol/ and ocf/ are the library packages mqtt_demo/ imports
|
# local. smartthings_local/ is the library package mqtt_demo/ imports
|
||||||
# from; they need to land as REMOTE_DIR's siblings of mqtt_demo/ so the
|
# 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.
|
# compose file's `context: ..` resolves the same way it does locally.
|
||||||
COPYFILE_DISABLE=1 tar cz \
|
COPYFILE_DISABLE=1 tar cz \
|
||||||
protocol/ \
|
smartthings_local/ \
|
||||||
ocf/ \
|
|
||||||
mqtt_demo/ \
|
mqtt_demo/ \
|
||||||
README.md \
|
README.md \
|
||||||
.gitignore \
|
.gitignore \
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Callable, Optional, TYPE_CHECKING
|
from typing import Callable, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ocf.poll_scheduler import PollTier
|
from smartthings_local.ocf.poll_scheduler import PollTier
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from ..descriptor import (
|
|||||||
device_block,
|
device_block,
|
||||||
encode,
|
encode,
|
||||||
)
|
)
|
||||||
from ocf.poll_scheduler import PollTier
|
from smartthings_local.ocf.poll_scheduler import PollTier
|
||||||
|
|
||||||
|
|
||||||
# --- OBSERVE paths -----------------------------------------------------
|
# --- OBSERVE paths -----------------------------------------------------
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from ..descriptor import (
|
|||||||
device_block,
|
device_block,
|
||||||
encode,
|
encode,
|
||||||
)
|
)
|
||||||
from ocf.poll_scheduler import PollTier
|
from smartthings_local.ocf.poll_scheduler import PollTier
|
||||||
|
|
||||||
|
|
||||||
MODEL = 'ARTIK051_REF_17K'
|
MODEL = 'ARTIK051_REF_17K'
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ..descriptor import (
|
|||||||
device_block,
|
device_block,
|
||||||
encode,
|
encode,
|
||||||
)
|
)
|
||||||
from ocf.poll_scheduler import PollTier
|
from smartthings_local.ocf.poll_scheduler import PollTier
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""smartthings-local: local CoAP-over-DTLS (OCF) transport + polling layer
|
||||||
|
for Samsung SmartThings appliances."""
|
||||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|||||||
import threading
|
import threading
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from protocol.dtls_session import DtlsCoapSession
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||||
|
|
||||||
|
|
||||||
class KeepaliveTask:
|
class KeepaliveTask:
|
||||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from protocol.dtls_session import DtlsCoapSession
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||||
|
|
||||||
|
|
||||||
class ObserveRefreshTask:
|
class ObserveRefreshTask:
|
||||||
@@ -36,7 +36,7 @@ from typing import Callable, Optional, TYPE_CHECKING
|
|||||||
|
|
||||||
import cbor2
|
import cbor2
|
||||||
|
|
||||||
from protocol.dtls_session import DtlsCoapSession, fmt_code
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .state_cache import StateCache
|
from .state_cache import StateCache
|
||||||
@@ -19,6 +19,7 @@ on a per-token Event the reader signals. OBSERVE notifications are
|
|||||||
delivered via the on_notification callback.
|
delivered via the on_notification callback.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import re as _re
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -61,31 +62,75 @@ _BLOCK_ACK_TIMEOUT = 4.0
|
|||||||
# once the ceiling is measured empirically.
|
# once the ceiling is measured empirically.
|
||||||
_DEFAULT_RATE_LIMIT_RPS = 5.0
|
_DEFAULT_RATE_LIMIT_RPS = 5.0
|
||||||
|
|
||||||
|
_PEM_CERT_RE = _re.compile(
|
||||||
|
rb'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
|
||||||
|
_re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pem_chain(ctx: SSL.Context, cert_pem: str, key_pem: str) -> None:
|
||||||
|
"""Load a PEM cert chain and private key into an SSL context in memory.
|
||||||
|
|
||||||
|
Parses all certificate blocks from cert_pem: the first is the leaf
|
||||||
|
(use_certificate), the rest are intermediates (add_extra_chain_cert).
|
||||||
|
No temp files are written.
|
||||||
|
"""
|
||||||
|
from OpenSSL import crypto
|
||||||
|
certs = _PEM_CERT_RE.findall(cert_pem.encode())
|
||||||
|
if not certs:
|
||||||
|
raise ValueError("No certificates found in cert_pem")
|
||||||
|
ctx.use_certificate(crypto.load_certificate(crypto.FILETYPE_PEM, certs[0]))
|
||||||
|
for extra in certs[1:]:
|
||||||
|
ctx.add_extra_chain_cert(
|
||||||
|
crypto.load_certificate(crypto.FILETYPE_PEM, extra)
|
||||||
|
)
|
||||||
|
ctx.use_privatekey(crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem.encode()))
|
||||||
|
ctx.check_privatekey()
|
||||||
|
|
||||||
|
|
||||||
class DtlsCoapSession:
|
class DtlsCoapSession:
|
||||||
"""Single sustained DTLS-CoAP session.
|
"""Single sustained DTLS-CoAP session.
|
||||||
|
|
||||||
Caller drives lifecycle:
|
Caller drives lifecycle:
|
||||||
sess = DtlsCoapSession(host, port, cert, key)
|
sess = DtlsCoapSession(host, port, cert_path=cert, key_path=key)
|
||||||
sess.connect()
|
sess.connect()
|
||||||
sess.start_reader()
|
sess.start_reader()
|
||||||
sess.subscribe([...], on_notification=cb) # OBSERVE
|
sess.subscribe([...], on_notification=cb) # OBSERVE
|
||||||
code, body = sess.get(['device', '0']) # Block2 fetch
|
code, body = sess.get(['device', '0']) # Block2 fetch
|
||||||
code, _ = sess.post(['mode','vs','0'], cbor)
|
code, _ = sess.post(['mode','vs','0'], cbor)
|
||||||
sess.close()
|
sess.close()
|
||||||
|
|
||||||
|
Cert material comes from either a file pair (cert_path, key_path) or
|
||||||
|
an in-memory PEM pair (cert_pem, key_pem) — exactly one pair required.
|
||||||
|
The in-memory path exists for callers (e.g. an HA config flow) that
|
||||||
|
mint a client cert at runtime and never write it to disk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
HANDSHAKE_TIMEOUT_S = 12.0
|
HANDSHAKE_TIMEOUT_S = 12.0
|
||||||
READER_RECV_TIMEOUT_S = 1.0 # short so stop_event propagates quickly
|
READER_RECV_TIMEOUT_S = 1.0 # short so stop_event propagates quickly
|
||||||
MAX_BLOCKS = 32 # safety bound for Block2 fetches
|
MAX_BLOCKS = 32 # safety bound for Block2 fetches
|
||||||
|
|
||||||
def __init__(self, host, port, cert_path, key_path,
|
def __init__(self, host, port, cert_path=None, key_path=None, *,
|
||||||
|
cert_pem=None, key_pem=None,
|
||||||
on_notification=None, mtu=1200,
|
on_notification=None, mtu=1200,
|
||||||
rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS):
|
rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS):
|
||||||
|
if (cert_path is not None or key_path is not None) and \
|
||||||
|
(cert_pem is not None or key_pem is not None):
|
||||||
|
raise ValueError(
|
||||||
|
"pass either cert_path/key_path or cert_pem/key_pem, not both")
|
||||||
|
if cert_pem is not None or key_pem is not None:
|
||||||
|
if cert_pem is None or key_pem is None:
|
||||||
|
raise ValueError("cert_pem and key_pem must be passed together")
|
||||||
|
elif cert_path is None or key_path is None:
|
||||||
|
raise ValueError(
|
||||||
|
"must pass either cert_path/key_path or cert_pem/key_pem")
|
||||||
|
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.cert_path = str(cert_path)
|
self.cert_path = str(cert_path) if cert_path is not None else None
|
||||||
self.key_path = str(key_path)
|
self.key_path = str(key_path) if key_path is not None else None
|
||||||
|
self.cert_pem = cert_pem
|
||||||
|
self.key_pem = key_pem
|
||||||
self.on_notification = on_notification # fn(href, payload_bytes)
|
self.on_notification = on_notification # fn(href, payload_bytes)
|
||||||
self.mtu = mtu
|
self.mtu = mtu
|
||||||
self._min_req_interval = 1.0 / rate_limit_rps
|
self._min_req_interval = 1.0 / rate_limit_rps
|
||||||
@@ -136,9 +181,12 @@ class DtlsCoapSession:
|
|||||||
# the OpenSSL instance cryptography bundles — ctypes and cffi bindings
|
# the OpenSSL instance cryptography bundles — ctypes and cffi bindings
|
||||||
# do not expose SSL_CTX_set_security_level on this build.
|
# do not expose SSL_CTX_set_security_level on this build.
|
||||||
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
|
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
|
||||||
ctx.use_certificate_chain_file(self.cert_path)
|
if self.cert_pem is not None:
|
||||||
ctx.use_privatekey_file(self.key_path)
|
_load_pem_chain(ctx, self.cert_pem, self.key_pem)
|
||||||
ctx.check_privatekey()
|
else:
|
||||||
|
ctx.use_certificate_chain_file(self.cert_path)
|
||||||
|
ctx.use_privatekey_file(self.key_path)
|
||||||
|
ctx.check_privatekey()
|
||||||
|
|
||||||
conn = SSL.Connection(ctx, None)
|
conn = SSL.Connection(ctx, None)
|
||||||
conn.set_connect_state()
|
conn.set_connect_state()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from protocol.coap import (
|
from smartthings_local.protocol.coap import (
|
||||||
build_coap, parse_coap, encode_options, block_value, fmt_code,
|
build_coap, parse_coap, encode_options, block_value, fmt_code,
|
||||||
TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2,
|
TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import pytest
|
||||||
|
from OpenSSL import SSL, crypto
|
||||||
|
|
||||||
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession, _load_pem_chain
|
||||||
|
|
||||||
|
|
||||||
|
def _make_self_signed_pem_pair():
|
||||||
|
"""A throwaway self-signed cert + key, just to exercise PEM loading —
|
||||||
|
not meant to resemble a real Samsung client cert."""
|
||||||
|
key = crypto.PKey()
|
||||||
|
key.generate_key(crypto.TYPE_RSA, 2048)
|
||||||
|
|
||||||
|
cert = crypto.X509()
|
||||||
|
cert.get_subject().CN = "test"
|
||||||
|
cert.set_serial_number(1)
|
||||||
|
cert.gmtime_adj_notBefore(0)
|
||||||
|
cert.gmtime_adj_notAfter(3600)
|
||||||
|
cert.set_issuer(cert.get_subject())
|
||||||
|
cert.set_pubkey(key)
|
||||||
|
cert.sign(key, "sha256")
|
||||||
|
|
||||||
|
cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
|
||||||
|
key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode()
|
||||||
|
return cert_pem, key_pem
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_pem_chain_loads_cert_and_key_in_memory():
|
||||||
|
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||||
|
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||||
|
_load_pem_chain(ctx, cert_pem, key_pem)
|
||||||
|
ctx.check_privatekey() # raises if cert/key don't match
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_pem_chain_rejects_cert_pem_with_no_certificates():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_load_pem_chain(SSL.Context(SSL.DTLS_METHOD), "not a cert", "not a key")
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_requires_exactly_one_cert_source():
|
||||||
|
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
DtlsCoapSession("host", 1234) # neither pair given
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
DtlsCoapSession("host", 1234, cert_path="/a", key_path="/b",
|
||||||
|
cert_pem=cert_pem, key_pem=key_pem) # both given
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
DtlsCoapSession("host", 1234, cert_pem=cert_pem) # key_pem missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_accepts_pem_pair():
|
||||||
|
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||||
|
sess = DtlsCoapSession("host", 1234, cert_pem=cert_pem, key_pem=key_pem)
|
||||||
|
assert sess.cert_path is None
|
||||||
|
assert sess.key_path is None
|
||||||
|
assert sess.cert_pem == cert_pem
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""protocol/ and ocf/ must be vendorable on their own — no dependency
|
"""smartthings_local (protocol/ + ocf/) must be vendorable on its own — no
|
||||||
on mqtt_demo/. This copies just those two directories into an empty
|
dependency on mqtt_demo/. This copies just the smartthings_local package into
|
||||||
temp dir and imports every module in them there, so a stray
|
an empty temp dir and imports every module in it there, so a stray
|
||||||
`from mqtt_demo... import ...` fails loudly instead of silently
|
`from mqtt_demo... import ...` fails loudly instead of silently passing
|
||||||
passing because mqtt_demo/ happens to also be on sys.path in-repo."""
|
because mqtt_demo/ happens to also be on sys.path in-repo."""
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -12,17 +12,16 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
def test_protocol_and_ocf_import_without_mqtt_demo_present(tmp_path):
|
def test_smartthings_local_imports_without_mqtt_demo_present(tmp_path):
|
||||||
for pkg in ('protocol', 'ocf'):
|
shutil.copytree(REPO_ROOT / "smartthings_local", tmp_path / "smartthings_local")
|
||||||
shutil.copytree(REPO_ROOT / pkg, tmp_path / pkg)
|
|
||||||
|
|
||||||
import_lines = [
|
import_lines = [
|
||||||
"import protocol.coap",
|
"import smartthings_local.protocol.coap",
|
||||||
"import protocol.dtls_session",
|
"import smartthings_local.protocol.dtls_session",
|
||||||
"import ocf.state_cache",
|
"import smartthings_local.ocf.state_cache",
|
||||||
"import ocf.poll_scheduler",
|
"import smartthings_local.ocf.poll_scheduler",
|
||||||
"import ocf.keepalive",
|
"import smartthings_local.ocf.keepalive",
|
||||||
"import ocf.observe_refresh",
|
"import smartthings_local.ocf.observe_refresh",
|
||||||
]
|
]
|
||||||
script = "\n".join(import_lines) + "\nprint('OK')\n"
|
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,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
assert result.returncode == 0, (
|
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}"
|
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||||
)
|
)
|
||||||
assert "OK" in result.stdout
|
assert "OK" in result.stdout
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# tests/test_state_cache.py
|
# tests/test_state_cache.py
|
||||||
from ocf.state_cache import StateCache
|
from smartthings_local.ocf.state_cache import StateCache
|
||||||
|
|
||||||
|
|
||||||
class _FakeDescriptor:
|
class _FakeDescriptor:
|
||||||
|
|||||||
Reference in New Issue
Block a user