Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ sess.subscribe(["operational", "state", "vs", "0"], # OBSERVE
sess.close()
```

`get()` and `post()` accept repeated URI-query strings. They also accept
ordered `(number, bytes)` options for reviewed OCF extensions while retaining
ownership of path, query, content-format, Accept, Observe, and blockwise
options:

```python
code, body = sess.get(
["oic", "res"],
query=("rt=oic.r.doxm",),
extra_options=((2049, b"\x08\x00"),),
)
```

Path and query text is UTF-8 encoded, and every value is size bounded before
anything is sent. Additional options remain arbitrary bytes, must already be
ordered by option number, and may repeat a number when the option is
repeatable.

`delete()` uses the same path, query, extension-option, timeout, and response
contract without sending a request payload.

`connect()` uses a 12-second monotonic DTLS handshake deadline by default. A
caller that needs a shorter bounded attempt can pass a positive finite value
without changing later reader timeouts. OpenSSL's DTLS timer schedules flight
Expand Down
11 changes: 8 additions & 3 deletions smartthings_local/protocol/coap.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
CONTENT_FORMAT = 12
ACCEPT = 17
BLOCK2 = 23
BLOCK1 = 27
SIZE2 = 28
SIZE1 = 60

# CoAP message types
TYPE_CON = 0
Expand All @@ -29,6 +31,7 @@
# CoAP method codes
METHOD_GET = 0x01
METHOD_POST = 0x02
METHOD_DELETE = 0x04

# CoAP content-format value for application/cbor
CF_CBOR = b'\x3c'
Expand Down Expand Up @@ -249,12 +252,13 @@ def _option_bytes(value, *, name):

def build_get_request(
mtype, mid, token, path_segs, query=(), *, accept=CF_CBOR,
block_number=None, block_szx=BLOCK_SZX):
"""Build a GET with optional Uri-Query, Accept, and Block2 options.
block_number=None, block_szx=BLOCK_SZX, extra_options=()):
"""Build a GET with optional query, Block2, and extension options.

``block_number=None`` omits Block2 for the initial request. Continuation
requests pass the accumulator's ``expected_number`` and ``szx``. Path and
query values may be either text or already encoded bytes.
query values may be either text or already encoded bytes. Extension
options are expected to have been validated by the session.
"""
options = [
(URI_PATH, _option_bytes(segment, name='path segment'))
Expand All @@ -278,6 +282,7 @@ def build_get_request(
or not 0 <= block_szx <= BLOCK_SZX):
raise ValueError('block_szx must be between 0 and 6')
options.append((BLOCK2, block_value(block_number, 0, block_szx)))
options.extend(extra_options)
return build_coap(mtype, METHOD_GET, mid, token, options)


Expand Down
162 changes: 154 additions & 8 deletions smartthings_local/protocol/dtls_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@
)
from .coap import (
ACCEPT,
BLOCK1,
BLOCK2,
BLOCK2_COMPLETE,
CF_CBOR,
CONTENT_FORMAT,
ETAG,
METHOD_DELETE,
METHOD_GET,
METHOD_POST,
OBSERVE,
Expand All @@ -67,8 +69,11 @@
RESPONSE_EMPTY_ACK,
RESPONSE_MESSAGE,
RESPONSE_RESET,
SIZE1,
SIZE2,
TYPE_CON,
URI_PATH,
URI_QUERY,
Block2Accumulator,
block_fields,
build_coap,
Expand Down Expand Up @@ -178,6 +183,75 @@ def _validate_handshake_timeout(timeout, default):
return value


_MAX_REQUEST_OPTION_BYTES = 1024
_MAX_REQUEST_OPTION_COUNT = 32
_MAX_REQUEST_OPTION_NUMBER = 65535
_MANAGED_REQUEST_OPTIONS = frozenset((
URI_PATH, URI_QUERY, OBSERVE, CONTENT_FORMAT, ACCEPT,
BLOCK2, BLOCK1, SIZE2, SIZE1,
))


def _validated_text_options(values, *, name, allow_empty):
"""Return bounded UTF-8 option values without echoing caller data."""
if isinstance(values, (str, bytes, bytearray, memoryview)):
raise TypeError(f'{name} must be an iterable of strings')
try:
iterator = iter(values)
except TypeError:
raise TypeError(f'{name} must be an iterable of strings') from None
result = []
for value in iterator:
if len(result) >= _MAX_REQUEST_OPTION_COUNT:
raise ValueError(f'{name} must contain at most 32 values')
if not isinstance(value, str):
raise TypeError(f'{name} values must be strings')
try:
encoded = value.encode('utf-8')
except UnicodeEncodeError:
raise ValueError(f'{name} values must be valid UTF-8') from None
if (not allow_empty and not encoded) or \
len(encoded) > _MAX_REQUEST_OPTION_BYTES:
raise ValueError(f'{name} values must be non-empty and bounded')
result.append(value)
return tuple(result)


def _validated_extra_options(extra_options):
"""Return bounded, ordered options not owned by the request methods."""
if isinstance(extra_options, (str, bytes, bytearray, memoryview)):
raise TypeError('extra_options must contain (number, bytes) tuples')
try:
iterator = iter(extra_options)
except TypeError:
raise TypeError(
'extra_options must contain (number, bytes) tuples') from None
result = []
previous = -1
for item in iterator:
if len(result) >= _MAX_REQUEST_OPTION_COUNT:
raise ValueError('extra_options must contain at most 32 values')
if not isinstance(item, tuple) or len(item) != 2:
raise TypeError(
'extra_options must contain (number, bytes) tuples')
number, value = item
if isinstance(number, bool) or not isinstance(number, int):
raise TypeError('extra option numbers must be integers')
if not 1 <= number <= _MAX_REQUEST_OPTION_NUMBER:
raise ValueError('extra option numbers must be bounded')
if number < previous:
raise ValueError('extra options must be ordered by number')
if number in _MANAGED_REQUEST_OPTIONS:
raise ValueError('extra option is managed by the CoAP transport')
if not isinstance(value, bytes):
raise TypeError('extra option values must be bytes')
if len(value) > _MAX_REQUEST_OPTION_BYTES:
raise ValueError('extra option values must be bounded')
result.append((number, value))
previous = number
return tuple(result)


class ConnectCancellation:
"""One-way, socket-backed cancellation signal for ``connect()``.

Expand Down Expand Up @@ -958,19 +1032,25 @@ def _refetch_one(self, href, seq):

# ---- request primitives ------------------------------------------

def get(self, path_segs, query=(), timeout=10.0):
def get(self, path_segs, query=(), timeout=10.0, *, extra_options=()):
"""Token-stable Block2 GET. Returns (code, payload_bytes).

Reuses one CoAP token across every block of a multi-block
response — Samsung's server keys per-transfer state on the
token, and dropping a fresh token on block 1+ silently drops
the request."""
self._check_live()
path_segs = _validated_text_options(
path_segs, name='path_segs', allow_empty=False)
query = _validated_text_options(
query, name='query', allow_empty=False)
extra_options = _validated_extra_options(extra_options)
code, blob, _blocks, _tok = self._blockwise_get(
path_segs, query, timeout)
path_segs, query, timeout, extra_options=extra_options)
return code, blob

def _blockwise_get(self, path_segs, query=(), timeout=10.0):
def _blockwise_get(
self, path_segs, query=(), timeout=10.0, *, extra_options=()):
"""Shared token-stable Block2 reassembly (RFC 7959 §2.4).

Returns (code, payload, block_count, token). The last two are
Expand All @@ -989,19 +1069,22 @@ def _blockwise_get(self, path_segs, query=(), timeout=10.0):
when the server supplies them. None of the tested appliances
emit option 4, so on those this is inert."""
try:
return self._blockwise_get_once(path_segs, query, timeout)
return self._blockwise_get_once(
path_segs, query, timeout, extra_options)
except _EtagChanged:
logger.debug("GET %s /%s: ETag changed mid-transfer, restarting",
self.host, '/'.join(path_segs))
try:
return self._blockwise_get_once(path_segs, query, timeout)
return self._blockwise_get_once(
path_segs, query, timeout, extra_options)
except _EtagChanged:
logger.debug(
"GET %s /%s: representation kept changing mid-transfer",
self.host, '/'.join(path_segs))
raise BlockwiseError() from None

def _blockwise_get_once(self, path_segs, query, timeout):
def _blockwise_get_once(
self, path_segs, query, timeout, extra_options):
"""One attempt at a full Block2 transfer. Raises _EtagChanged if
the server's representation changed while we were reassembling."""
tok = self._next_tok()
Expand All @@ -1018,6 +1101,7 @@ def _blockwise_get_once(self, path_segs, query, timeout):
num,
accumulator.szx,
deadline,
extra_options,
)
prior_blocks = accumulator.blocks_received

Expand Down Expand Up @@ -1045,7 +1129,9 @@ def _blockwise_get_once(self, path_segs, query, timeout):

raise BlockwiseError()

def _exchange_block(self, tok, path_segs, query, num, szx, deadline):
def _exchange_block(
self, tok, path_segs, query, num, szx, deadline,
extra_options):
"""Send one block request under `tok` and return its response
message, retransmitting up to _BLOCK_MAX_ATTEMPTS times.

Expand All @@ -1070,6 +1156,7 @@ def _exchange_block(self, tok, path_segs, query, num, szx, deadline):
query,
block_number=num if num > 0 else None,
block_szx=szx,
extra_options=extra_options,
)
try:
for attempt in range(_BLOCK_MAX_ATTEMPTS):
Expand Down Expand Up @@ -1167,7 +1254,9 @@ def _block_num_matches(message, num, szx):
response_offset = response_num << (response_szx + 4)
return response_offset == requested_offset

def post(self, path_segs, body_cbor, timeout=8.0):
def post(
self, path_segs, body_cbor, timeout=8.0, *, query=(),
extra_options=()):
"""Single-frame POST with a CBOR-encoded body. Returns
(code, payload_bytes). body_cbor must already be encoded.

Expand All @@ -1181,10 +1270,20 @@ def post(self, path_segs, body_cbor, timeout=8.0):
retry cannot offer that, since it mints a fresh MID and token.
Defaults to one attempt — see _WRITE_ACK_TIMEOUT."""
self._check_live()
path_segs = _validated_text_options(
path_segs, name='path_segs', allow_empty=False)
query = _validated_text_options(
query, name='query', allow_empty=False)
extra_options = _validated_extra_options(extra_options)
if not isinstance(body_cbor, bytes):
raise TypeError('body_cbor must be bytes')
tok = self._next_tok()
opts = [(URI_PATH, s.encode()) for s in path_segs]
for q in query:
opts.append((URI_QUERY, q.encode()))
opts.append((CONTENT_FORMAT, CF_CBOR))
opts.append((ACCEPT, CF_CBOR))
opts.extend(extra_options)
ev = threading.Event()
container = {}
mid, exchange = self._register_pending_request(tok, ev, container)
Expand Down Expand Up @@ -1282,6 +1381,53 @@ def post(self, path_segs, body_cbor, timeout=8.0):
finally:
self._unregister_pending_request(tok, mid, exchange)

def delete(
self, path_segs, timeout=8.0, *, query=(), extra_options=()):
"""Single-frame DELETE. Returns (code, payload_bytes).

``timeout`` bounds the whole call, including request pacing.
"""
self._check_live()
path_segs = _validated_text_options(
path_segs, name='path_segs', allow_empty=False)
query = _validated_text_options(
query, name='query', allow_empty=False)
extra_options = _validated_extra_options(extra_options)
tok = self._next_tok()
opts = [(URI_PATH, s.encode()) for s in path_segs]
for q in query:
opts.append((URI_QUERY, q.encode()))
opts.append((ACCEPT, CF_CBOR))
opts.extend(extra_options)
ev = threading.Event()
container = {}
mid, exchange = self._register_pending_request(tok, ev, container)
datagram = build_coap(TYPE_CON, METHOD_DELETE, mid, tok, opts)
deadline = time.time() + timeout
try:
self.pace()
self._check_live()
self._send_dgram(datagram)
while True:
with self._state_lock:
error = container.get('err')
has_response = 'code' in container
response = (
(container['code'], container['payload'])
if has_response else None
)
if error is None and not has_response:
ev.clear()
if error is not None:
raise error
if has_response:
return response
remaining = deadline - time.time()
if remaining <= 0 or not self._wait_live(ev, remaining):
raise SessionTimeoutError()
finally:
self._unregister_pending_request(tok, mid, exchange)

def ping(self):
"""RFC 7252 §4.4 CoAP Ping — empty CON, no token, no payload.
Fire-and-forget: we do not wait for the matching RST because
Expand Down
1 change: 1 addition & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ def close(self):
(
lambda session: session.get(['resource']),
lambda session: session.post(['resource'], b'payload'),
lambda session: session.delete(['resource']),
lambda session: session.ping(),
lambda session: session.refresh_observes([]),
lambda session: session.subscribe(['resource']),
Expand Down
22 changes: 22 additions & 0 deletions tests/test_public_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ def test_dtls_session_keeps_current_consumer_methods():
expected = {
"close",
"connect",
"delete",
"get",
"join",
"pace",
Expand Down Expand Up @@ -206,6 +207,11 @@ def test_dtls_session_keeps_current_consumer_methods():
"timeout",
],
)
get_extra_options = inspect.signature(DtlsCoapSession.get).parameters[
"extra_options"
]
assert get_extra_options.kind is inspect.Parameter.KEYWORD_ONLY
assert get_extra_options.default == ()
_assert_compatible_signature(
DtlsCoapSession.post,
[
Expand All @@ -215,6 +221,22 @@ def test_dtls_session_keeps_current_consumer_methods():
"timeout",
],
)
post_parameters = inspect.signature(DtlsCoapSession.post).parameters
for name in ("query", "extra_options"):
assert post_parameters[name].kind is inspect.Parameter.KEYWORD_ONLY
assert post_parameters[name].default == ()
_assert_compatible_signature(
DtlsCoapSession.delete,
[
"self",
"path_segs",
"timeout",
],
)
delete_parameters = inspect.signature(DtlsCoapSession.delete).parameters
for name in ("query", "extra_options"):
assert delete_parameters[name].kind is inspect.Parameter.KEYWORD_ONLY
assert delete_parameters[name].default == ()
_assert_compatible_signature(
DtlsCoapSession.subscribe,
["self", "path_segs"],
Expand Down
Loading