docs: add project description and server setup instructions to README

This commit is contained in:
2026-03-21 18:25:35 +00:00
parent c81815a83d
commit 6115a065c7
36 changed files with 4003 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
from .microcoapy import Coap
from .coap_macros import COAP_CONTENT_FORMAT
from .coap_macros import COAP_RESPONSE_CODE
from .coap_macros import COAP_TYPE
from .coap_macros import COAP_OPTION_NUMBER
from .coap_macros import COAP_OBSERVE_REGISTER
from .coap_macros import COAP_OBSERVE_DEREGISTER

View File

@@ -0,0 +1,112 @@
# Macros - Extended for Demeter with CoAP Observe (RFC 7641)
_COAP_HEADER_SIZE = 4
_COAP_OPTION_HEADER_SIZE = 1
_COAP_PAYLOAD_MARKER = 0xFF
_MAX_OPTION_NUM = 10
_BUF_MAX_SIZE = 1024
_COAP_DEFAULT_PORT = 5683
def enum(**enums):
return type('Enum', (), enums)
class CoapResponseCode:
@staticmethod
def encode(class_, detail):
return ((class_ << 5) | (detail))
@staticmethod
def decode(value):
class_ = (0xE0 & value) >> 5
detail = 0x1F & value
return (class_, detail)
COAP_VERSION = enum(
COAP_VERSION_UNSUPPORTED=0,
COAP_VERSION_1=1
)
COAP_TYPE = enum(
COAP_CON=0,
COAP_NONCON=1,
COAP_ACK=2,
COAP_RESET=3
)
COAP_METHOD = enum(
COAP_EMPTY_MESSAGE=0,
COAP_GET=1,
COAP_POST=2,
COAP_PUT=3,
COAP_DELETE=4
)
COAP_RESPONSE_CODE = enum(
COAP_CREATED=CoapResponseCode.encode(2, 1),
COAP_DELETED=CoapResponseCode.encode(2, 2),
COAP_VALID=CoapResponseCode.encode(2, 3),
COAP_CHANGED=CoapResponseCode.encode(2, 4),
COAP_CONTENT=CoapResponseCode.encode(2, 5),
COAP_BAD_REQUEST=CoapResponseCode.encode(4, 0),
COAP_UNAUTHORIZED=CoapResponseCode.encode(4, 1),
COAP_BAD_OPTION=CoapResponseCode.encode(4, 2),
COAP_FORBIDDEN=CoapResponseCode.encode(4, 3),
COAP_NOT_FOUND=CoapResponseCode.encode(4, 4),
COAP_METHOD_NOT_ALLOWD=CoapResponseCode.encode(4, 5),
COAP_NOT_ACCEPTABLE=CoapResponseCode.encode(4, 6),
COAP_PRECONDITION_FAILED=CoapResponseCode.encode(4, 12),
COAP_REQUEST_ENTITY_TOO_LARGE=CoapResponseCode.encode(4, 13),
COAP_UNSUPPORTED_CONTENT_FORMAT=CoapResponseCode.encode(4, 15),
COAP_INTERNAL_SERVER_ERROR=CoapResponseCode.encode(5, 0),
COAP_NOT_IMPLEMENTED=CoapResponseCode.encode(5, 1),
COAP_BAD_GATEWAY=CoapResponseCode.encode(5, 2),
COAP_SERVICE_UNAVALIABLE=CoapResponseCode.encode(5, 3),
COAP_GATEWAY_TIMEOUT=CoapResponseCode.encode(5, 4),
COAP_PROXYING_NOT_SUPPORTED=CoapResponseCode.encode(5, 5)
)
COAP_OPTION_NUMBER = enum(
COAP_IF_MATCH=1,
COAP_URI_HOST=3,
COAP_E_TAG=4,
COAP_IF_NONE_MATCH=5,
COAP_OBSERVE=6, # RFC 7641 - Observe
COAP_URI_PORT=7,
COAP_LOCATION_PATH=8,
COAP_URI_PATH=11,
COAP_CONTENT_FORMAT=12,
COAP_MAX_AGE=14,
COAP_URI_QUERY=15,
COAP_ACCEPT=17,
COAP_LOCATION_QUERY=20,
COAP_PROXY_URI=35,
COAP_PROXY_SCHEME=39
)
COAP_CONTENT_FORMAT = enum(
COAP_NONE=-1,
COAP_TEXT_PLAIN=0,
COAP_APPLICATION_LINK_FORMAT=40,
COAP_APPLICATION_XML=41,
COAP_APPLICATION_OCTET_STREAM=42,
COAP_APPLICATION_EXI=47,
COAP_APPLICATION_JSON=50,
COAP_APPLICATION_CBOR=60
)
# Observe option values (RFC 7641)
COAP_OBSERVE_REGISTER = 0
COAP_OBSERVE_DEREGISTER = 1
coapTypeToStringMap = {
COAP_TYPE.COAP_CON: 'CON',
COAP_TYPE.COAP_NONCON: 'NONCON',
COAP_TYPE.COAP_ACK: 'ACK',
COAP_TYPE.COAP_RESET: 'RESET'
}
def coapTypeToString(type):
return coapTypeToStringMap.get(type, "INVALID")

View File

@@ -0,0 +1,10 @@
class CoapOption:
def __init__(self, number=-1, buffer=None):
self.number = number
byteBuf = bytearray()
if buffer is not None:
if isinstance(buffer, str):
byteBuf.extend(buffer.encode("utf-8"))
else:
byteBuf.extend(buffer)
self.buffer = byteBuf

View File

@@ -0,0 +1,83 @@
from . import coap_macros as macros
from .coap_option import CoapOption
class CoapPacket:
def __init__(self):
self.version = macros.COAP_VERSION.COAP_VERSION_UNSUPPORTED
self.type = macros.COAP_TYPE.COAP_CON
self.method = macros.COAP_METHOD.COAP_GET
self.token = bytearray()
self.payload = bytearray()
self.messageid = 0
self.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE
self.query = bytearray()
self.options = []
def addOption(self, number, opt_payload):
if len(self.options) >= macros._MAX_OPTION_NUM:
return
self.options.append(CoapOption(number, opt_payload))
def setUriHost(self, address):
self.addOption(macros.COAP_OPTION_NUMBER.COAP_URI_HOST, address)
def setUriPath(self, url):
for subPath in url.split('/'):
self.addOption(macros.COAP_OPTION_NUMBER.COAP_URI_PATH, subPath)
def setObserve(self, value):
"""Set the Observe option (RFC 7641).
For requests:
value=0: register as observer
value=1: deregister
For notifications:
value=sequence number (24-bit, 0-16777215)
"""
if value < 256:
buf = bytearray([value & 0xFF])
elif value < 65536:
buf = bytearray([(value >> 8) & 0xFF, value & 0xFF])
else:
buf = bytearray([(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF])
self.addOption(macros.COAP_OPTION_NUMBER.COAP_OBSERVE, buf)
def setMaxAge(self, seconds):
"""Set the Max-Age option (seconds until resource is considered stale)."""
if seconds < 256:
buf = bytearray([seconds & 0xFF])
elif seconds < 65536:
buf = bytearray([(seconds >> 8) & 0xFF, seconds & 0xFF])
else:
buf = bytearray([
(seconds >> 24) & 0xFF, (seconds >> 16) & 0xFF,
(seconds >> 8) & 0xFF, seconds & 0xFF
])
self.addOption(macros.COAP_OPTION_NUMBER.COAP_MAX_AGE, buf)
def getObserveValue(self):
"""Extract the Observe option value from the packet, or None if absent."""
for opt in self.options:
if opt.number == macros.COAP_OPTION_NUMBER.COAP_OBSERVE:
val = 0
for b in opt.buffer:
val = (val << 8) | b
return val
return None
def getUriPath(self):
"""Reconstruct the URI path from URI_PATH options."""
parts = []
for opt in self.options:
if opt.number == macros.COAP_OPTION_NUMBER.COAP_URI_PATH and len(opt.buffer) > 0:
parts.append(opt.buffer.decode("utf-8"))
return "/".join(parts)
def toString(self):
class_, detail = macros.CoapResponseCode.decode(self.method)
return "type: {}, method: {}.{:02d}, messageid: {}, payload: {}".format(
macros.coapTypeToString(self.type), class_, detail,
self.messageid, self.payload
)

View File

@@ -0,0 +1,88 @@
from . import coap_macros as macros
from .coap_option import CoapOption
def parseOption(packet, runningDelta, buffer, i):
option = CoapOption()
headlen = 1
errorMessage = (False, runningDelta, i)
if buffer is None:
return errorMessage
buflen = len(buffer) - i
if buflen < headlen:
return errorMessage
delta = (buffer[i] & 0xF0) >> 4
length = buffer[i] & 0x0F
if delta == 15 or length == 15:
return errorMessage
if delta == 13:
headlen += 1
if buflen < headlen:
return errorMessage
delta = buffer[i + 1] + 13
i += 1
elif delta == 14:
headlen += 2
if buflen < headlen:
return errorMessage
delta = ((buffer[i + 1] << 8) | buffer[i + 2]) + 269
i += 2
if length == 13:
headlen += 1
if buflen < headlen:
return errorMessage
length = buffer[i + 1] + 13
i += 1
elif length == 14:
headlen += 2
if buflen < headlen:
return errorMessage
length = ((buffer[i + 1] << 8) | buffer[i + 2]) + 269
i += 2
endOfOptionIndex = (i + 1 + length)
if endOfOptionIndex > len(buffer):
return errorMessage
option.number = delta + runningDelta
option.buffer = buffer[i + 1:i + 1 + length]
packet.options.append(option)
return (True, runningDelta + delta, endOfOptionIndex)
def parsePacketHeaderInfo(buffer, packet):
packet.version = (buffer[0] & 0xC0) >> 6
packet.type = (buffer[0] & 0x30) >> 4
packet.tokenLength = buffer[0] & 0x0F
packet.method = buffer[1]
packet.messageid = 0xFF00 & (buffer[2] << 8)
packet.messageid |= 0x00FF & buffer[3]
def parsePacketOptionsAndPayload(buffer, packet):
bufferLen = len(buffer)
if (macros._COAP_HEADER_SIZE + packet.tokenLength) < bufferLen:
delta = 0
bufferIndex = macros._COAP_HEADER_SIZE + packet.tokenLength
while (len(packet.options) < macros._MAX_OPTION_NUM) and \
(bufferIndex < bufferLen) and \
(buffer[bufferIndex] != 0xFF):
(status, delta, bufferIndex) = parseOption(packet, delta, buffer, bufferIndex)
if status is False:
return False
if ((bufferIndex + 1) < bufferLen) and (buffer[bufferIndex] == 0xFF):
packet.payload = buffer[bufferIndex + 1:]
else:
packet.payload = None
return True

View File

@@ -0,0 +1,67 @@
from .coap_macros import _BUF_MAX_SIZE
from .coap_macros import COAP_VERSION
def CoapOptionDelta(v):
if v < 13:
return (0xFF & v)
elif v <= 0xFF + 13:
return 13
else:
return 14
def writePacketHeaderInfo(buffer, packet):
buffer.append(COAP_VERSION.COAP_VERSION_1 << 6)
buffer[0] |= (packet.type & 0x03) << 4
tokenLength = 0
if (packet.token is not None) and (len(packet.token) <= 0x0F):
tokenLength = len(packet.token)
buffer[0] |= (tokenLength & 0x0F)
buffer.append(packet.method)
buffer.append(packet.messageid >> 8)
buffer.append(packet.messageid & 0xFF)
if tokenLength > 0:
buffer.extend(packet.token)
def writePacketOptions(buffer, packet):
runningDelta = 0
for opt in sorted(packet.options, key=lambda x: x.number):
if (opt is None) or (opt.buffer is None) or (len(opt.buffer) == 0):
continue
optBufferLen = len(opt.buffer)
if (len(buffer) + 5 + optBufferLen) >= _BUF_MAX_SIZE:
return 0
optdelta = opt.number - runningDelta
delta = CoapOptionDelta(optdelta)
length = CoapOptionDelta(optBufferLen)
buffer.append(0xFF & (delta << 4 | length))
if delta == 13:
buffer.append(optdelta - 13)
elif delta == 14:
buffer.append((optdelta - 269) >> 8)
buffer.append(0xFF & (optdelta - 269))
if length == 13:
buffer.append(optBufferLen - 13)
elif length == 14:
buffer.append(optBufferLen >> 8)
buffer.append(0xFF & (optBufferLen - 269))
buffer.extend(opt.buffer)
runningDelta = opt.number
def writePacketPayload(buffer, packet):
if (packet.payload is not None) and (len(packet.payload)):
if (len(buffer) + 1 + len(packet.payload)) >= _BUF_MAX_SIZE:
return 0
buffer.append(0xFF)
buffer.extend(packet.payload)

View File

@@ -0,0 +1,512 @@
"""
microCoAPy - Extended for Demeter with CoAP Observe (RFC 7641)
Changes from upstream microCoAPy:
- Observer registration/deregistration on incoming GET with Option 6
- notifyObservers() method for server-side Observe notifications
- observeGet() client method for subscribing to observable resources
- RST handling to remove observers
- Per-resource Max-Age support in notifications
"""
try:
import socket
except ImportError:
import usocket as socket
try:
import os
except ImportError:
import uos as os
try:
import time
except ImportError:
import utime as time
import binascii
from . import coap_macros as macros
from .coap_packet import CoapPacket
from .coap_reader import parsePacketHeaderInfo
from .coap_reader import parsePacketOptionsAndPayload
from .coap_writer import writePacketHeaderInfo
from .coap_writer import writePacketOptions
from .coap_writer import writePacketPayload
from .observe_manager import ObserveManager
class Coap:
TRANSMISSION_STATE = macros.enum(
STATE_IDLE=0,
STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA=1
)
def __init__(self):
self.debug = True
self.sock = None
self.callbacks = {}
self.responseCallback = None
self.port = 0
self.isServer = False
self.state = self.TRANSMISSION_STATE.STATE_IDLE
self.isCustomSocket = False
# Observe manager (RFC 7641)
self.observe = ObserveManager(debug=True)
# Beta flags
self.discardRetransmissions = False
self.lastPacketStr = ""
def log(self, s):
if self.debug:
print("[microcoapy]: " + s)
# ── Socket Management ──
def start(self, port=macros._COAP_DEFAULT_PORT):
"""Create and bind a UDP socket."""
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("", port))
def stop(self):
"""Close the socket."""
if self.sock is not None:
self.sock.close()
self.sock = None
def setCustomSocket(self, custom_socket):
"""Use a custom UDP socket implementation."""
self.stop()
self.isCustomSocket = True
self.sock = custom_socket
# ── Callback Registration ──
def addIncomingRequestCallback(self, requestUrl, callback):
"""Register a callback for incoming requests to a URL.
The callback signature is:
callback(packet, senderIp, senderPort)
For observable resources, the callback should return a tuple:
(payload_str, content_format)
This allows notifyObservers to retrieve the current value.
If the callback returns None, the server handles it as before
(callback is responsible for sending its own response).
"""
self.callbacks[requestUrl] = callback
self.isServer = True
# ── Packet Sending ──
def sendPacket(self, ip, port, coapPacket):
"""Serialize and send a CoAP packet."""
if coapPacket.content_format != macros.COAP_CONTENT_FORMAT.COAP_NONE:
optionBuffer = bytearray(2)
optionBuffer[0] = (coapPacket.content_format & 0xFF00) >> 8
optionBuffer[1] = coapPacket.content_format & 0x00FF
coapPacket.addOption(
macros.COAP_OPTION_NUMBER.COAP_CONTENT_FORMAT, optionBuffer
)
if (coapPacket.query is not None) and (len(coapPacket.query) > 0):
coapPacket.addOption(
macros.COAP_OPTION_NUMBER.COAP_URI_QUERY, coapPacket.query
)
buffer = bytearray()
writePacketHeaderInfo(buffer, coapPacket)
writePacketOptions(buffer, coapPacket)
writePacketPayload(buffer, coapPacket)
status = 0
try:
sockaddr = (ip, port)
try:
sockaddr = socket.getaddrinfo(ip, port)[0][-1]
except Exception:
pass
status = self.sock.sendto(buffer, sockaddr)
if status > 0:
status = coapPacket.messageid
self.log("Packet sent. messageid: " + str(status))
except Exception as e:
status = 0
print("Exception while sending packet...")
import sys
sys.print_exception(e)
return status
def send(self, ip, port, url, type, method, token, payload, content_format, query_option):
"""Build and send a CoAP request."""
packet = CoapPacket()
packet.type = type
packet.method = method
packet.token = token
packet.payload = payload
packet.content_format = content_format
packet.query = query_option
return self.sendEx(ip, port, url, packet)
def sendEx(self, ip, port, url, packet):
"""Send a packet with auto-generated message ID and URI options."""
self.state = self.TRANSMISSION_STATE.STATE_IDLE
randBytes = os.urandom(2)
packet.messageid = (randBytes[0] << 8) | randBytes[1]
packet.setUriHost(ip)
packet.setUriPath(url)
return self.sendPacket(ip, port, packet)
def sendResponse(self, ip, port, messageid, payload, method, content_format, token):
"""Send a response (ACK) packet."""
packet = CoapPacket()
packet.type = macros.COAP_TYPE.COAP_ACK
packet.method = method
packet.token = token
packet.payload = payload
packet.messageid = messageid
packet.content_format = content_format
return self.sendPacket(ip, port, packet)
# ── Client Methods (Confirmable) ──
def get(self, ip, port, url, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_GET,
token, None, macros.COAP_CONTENT_FORMAT.COAP_NONE, None
)
def put(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_PUT,
token, payload, content_format, query_option
)
def post(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_CON, macros.COAP_METHOD.COAP_POST,
token, payload, content_format, query_option
)
# ── Client Methods (Non-Confirmable) ──
def getNonConf(self, ip, port, url, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_GET,
token, None, macros.COAP_CONTENT_FORMAT.COAP_NONE, None
)
def putNonConf(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_PUT,
token, payload, content_format, query_option
)
def postNonConf(self, ip, port, url, payload=bytearray(), query_option=None,
content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE, token=bytearray()):
return self.send(
ip, port, url,
macros.COAP_TYPE.COAP_NONCON, macros.COAP_METHOD.COAP_POST,
token, payload, content_format, query_option
)
# ── Observe Client Methods (RFC 7641) ──
def observeGet(self, ip, port, url, token=None):
"""Send a GET with Observe option 0 (register) to subscribe to a resource.
Args:
ip: Server IP
port: Server port
url: Resource URI path
token: Token for matching responses (auto-generated if None)
Returns:
Message ID on success, 0 on failure.
"""
if token is None:
token = bytearray(os.urandom(4))
packet = CoapPacket()
packet.type = macros.COAP_TYPE.COAP_CON
packet.method = macros.COAP_METHOD.COAP_GET
packet.token = token
packet.payload = None
packet.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE
# Set Observe = 0 (register)
packet.setObserve(macros.COAP_OBSERVE_REGISTER)
return self.sendEx(ip, port, url, packet)
def observeCancel(self, ip, port, url, token=bytearray()):
"""Send a GET with Observe option 1 (deregister) to cancel observation.
Returns:
Message ID on success, 0 on failure.
"""
packet = CoapPacket()
packet.type = macros.COAP_TYPE.COAP_CON
packet.method = macros.COAP_METHOD.COAP_GET
packet.token = token
packet.payload = None
packet.content_format = macros.COAP_CONTENT_FORMAT.COAP_NONE
# Set Observe = 1 (deregister)
packet.setObserve(macros.COAP_OBSERVE_DEREGISTER)
return self.sendEx(ip, port, url, packet)
# ── Observe Server Methods (RFC 7641) ──
def notifyObservers(self, resource_url, payload, content_format=macros.COAP_CONTENT_FORMAT.COAP_NONE,
message_type=macros.COAP_TYPE.COAP_NONCON, max_age=None):
"""Send an Observe notification to all observers of a resource.
Args:
resource_url: The resource URI path (must match what observers subscribed to)
payload: The current resource representation (str or bytes)
content_format: Content format of the payload
message_type: COAP_NON for periodic data, COAP_CON for critical events
max_age: Optional Max-Age in seconds (freshness lifetime)
Returns:
Number of notifications sent successfully.
"""
if not self.observe.has_observers(resource_url):
return 0
observers = self.observe.get_observers(resource_url)
seq = self.observe.next_sequence(resource_url)
sent = 0
for obs in observers:
packet = CoapPacket()
packet.type = message_type
packet.method = macros.COAP_RESPONSE_CODE.COAP_CONTENT
packet.token = obs["token"]
packet.content_format = content_format
if isinstance(payload, str):
packet.payload = bytearray(payload.encode("utf-8"))
elif payload is not None:
packet.payload = bytearray(payload)
else:
packet.payload = bytearray()
# Generate message ID
randBytes = os.urandom(2)
packet.messageid = (randBytes[0] << 8) | randBytes[1]
# Add Observe sequence number
packet.setObserve(seq)
# Add Max-Age if specified
if max_age is not None:
packet.setMaxAge(max_age)
status = self.sendPacket(obs["ip"], obs["port"], packet)
if status > 0:
sent += 1
else:
self.log("Failed to notify observer {}:{}".format(obs["ip"], obs["port"]))
self.log("Notified {}/{} observers of {} (seq={})".format(
sent, len(observers), resource_url, seq
))
return sent
# ── Incoming Request Handling ──
def handleIncomingRequest(self, requestPacket, sourceIp, sourcePort):
"""Handle an incoming CoAP request, including Observe registration."""
url = requestPacket.getUriPath()
urlCallback = None
if url != "":
urlCallback = self.callbacks.get(url)
if urlCallback is None:
if self.responseCallback:
return False
print("Callback for url [", url, "] not found")
self.sendResponse(
sourceIp, sourcePort, requestPacket.messageid,
None, macros.COAP_RESPONSE_CODE.COAP_NOT_FOUND,
macros.COAP_CONTENT_FORMAT.COAP_NONE, requestPacket.token,
)
return True
# Check for Observe option in GET requests (RFC 7641)
if requestPacket.method == macros.COAP_METHOD.COAP_GET:
observeValue = requestPacket.getObserveValue()
if observeValue == macros.COAP_OBSERVE_REGISTER:
# Register observer
registered = self.observe.register(
url, sourceIp, sourcePort, requestPacket.token
)
if registered:
# Send initial response with Observe option (sequence 0)
result = urlCallback(requestPacket, sourceIp, sourcePort)
if result is not None:
payload_str, cf = result
response = CoapPacket()
response.type = macros.COAP_TYPE.COAP_ACK
response.method = macros.COAP_RESPONSE_CODE.COAP_CONTENT
response.token = requestPacket.token
response.messageid = requestPacket.messageid
response.content_format = cf
if isinstance(payload_str, str):
response.payload = bytearray(payload_str.encode("utf-8"))
elif payload_str is not None:
response.payload = bytearray(payload_str)
# Include Observe option with sequence 0 in initial response
response.setObserve(self.observe.next_sequence(url))
self.sendPacket(sourceIp, sourcePort, response)
# else: callback handled its own response
return True
else:
# Registration failed (limits exceeded) — respond without Observe
self.log("Observer registration failed for {}:{} on {}".format(
sourceIp, sourcePort, url
))
# Fall through to normal callback handling
elif observeValue == macros.COAP_OBSERVE_DEREGISTER:
# Deregister observer
self.observe.deregister(url, sourceIp, sourcePort)
# Fall through to normal GET response
# Normal (non-observe) request handling
urlCallback(requestPacket, sourceIp, sourcePort)
return True
# ── Socket Reading ──
def readBytesFromSocket(self, numOfBytes):
try:
return self.sock.recvfrom(numOfBytes)
except Exception:
return (None, None)
def parsePacketToken(self, buffer, packet):
if packet.tokenLength == 0:
packet.token = None
elif packet.tokenLength <= 8:
packet.token = buffer[4: 4 + packet.tokenLength]
else:
(tempBuffer, tempRemoteAddress) = self.readBytesFromSocket(
macros._BUF_MAX_SIZE
)
if tempBuffer is not None:
buffer.extend(tempBuffer)
return False
return True
# ── Main Loop ──
def loop(self, blocking=True):
"""Process one incoming packet.
Returns True if a packet was processed, False otherwise.
"""
if self.sock is None:
return False
self.sock.setblocking(blocking)
(buffer, remoteAddress) = self.readBytesFromSocket(macros._BUF_MAX_SIZE)
self.sock.setblocking(True)
while (buffer is not None) and (len(buffer) > 0):
bufferLen = len(buffer)
if (bufferLen < macros._COAP_HEADER_SIZE) or \
(((buffer[0] & 0xC0) >> 6) != 1):
(tempBuffer, tempRemoteAddress) = self.readBytesFromSocket(
macros._BUF_MAX_SIZE - bufferLen
)
if tempBuffer is not None:
buffer.extend(tempBuffer)
continue
packet = CoapPacket()
self.log("Incoming bytes: " + str(binascii.hexlify(bytearray(buffer))))
parsePacketHeaderInfo(buffer, packet)
if not self.parsePacketToken(buffer, packet):
continue
if not parsePacketOptionsAndPayload(buffer, packet):
return False
# Handle RST — deregister observer (RFC 7641 §3.6)
if packet.type == macros.COAP_TYPE.COAP_RESET:
self.log("RST received, deregistering any observer with matching token")
for res_url in self.observe.get_all_resources():
self.observe.deregister_by_token(res_url, packet.token)
return True
# Beta: discard retransmissions
if self.discardRetransmissions:
if packet.toString() == self.lastPacketStr:
self.log("Discarded retransmission: " + packet.toString())
return False
else:
self.lastPacketStr = packet.toString()
if not self.isServer or not self.handleIncomingRequest(
packet, remoteAddress[0], remoteAddress[1]
):
# Separate response handling (RFC 7252 §5.2.2)
if (packet.type == macros.COAP_TYPE.COAP_ACK and
packet.method == macros.COAP_METHOD.COAP_EMPTY_MESSAGE):
self.state = self.TRANSMISSION_STATE.STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA
return False
else:
if self.state == self.TRANSMISSION_STATE.STATE_SEPARATE_ACK_RECEIVED_WAITING_DATA:
self.state = self.TRANSMISSION_STATE.STATE_IDLE
self.sendResponse(
remoteAddress[0], remoteAddress[1],
packet.messageid, None,
macros.COAP_TYPE.COAP_ACK,
macros.COAP_CONTENT_FORMAT.COAP_NONE,
packet.token,
)
if self.responseCallback is not None:
self.responseCallback(packet, remoteAddress)
return True
return False
def poll(self, timeoutMs=-1, pollPeriodMs=500):
"""Poll for incoming packets for up to timeoutMs milliseconds."""
start_time = time.ticks_ms()
status = False
while not status:
status = self.loop(False)
if time.ticks_diff(time.ticks_ms(), start_time) >= timeoutMs:
break
time.sleep_ms(pollPeriodMs)
return status

View File

@@ -0,0 +1,144 @@
"""
CoAP Observe Manager (RFC 7641)
Manages observer registrations for CoAP resources on the ESP server side.
Each resource can have multiple observers. When the resource state changes,
all registered observers receive a notification.
Observer entry structure:
{
"ip": str, # Observer IP address
"port": int, # Observer UDP port
"token": bytearray, # Token from the original GET request (for matching)
}
"""
try:
import time
except ImportError:
import utime as time
class ObserveManager:
"""Manages CoAP Observe subscriptions per resource URI."""
# Maximum observers per resource (memory constrained on ESP)
MAX_OBSERVERS_PER_RESOURCE = 4
# Maximum total observers across all resources
MAX_TOTAL_OBSERVERS = 8
def __init__(self, debug=True):
# Dict of resource_url -> list of observer entries
self._observers = {}
# Per-resource sequence counter (24-bit, wraps at 0xFFFFFF)
self._sequence = {}
self.debug = debug
def log(self, s):
if self.debug:
print("[observe]: " + s)
def register(self, resource_url, ip, port, token):
"""Register an observer for a resource.
Returns True if successfully registered, False if limits exceeded.
"""
if resource_url not in self._observers:
self._observers[resource_url] = []
self._sequence[resource_url] = 0
observers = self._observers[resource_url]
# Check if this observer is already registered (same ip+port+token)
for obs in observers:
if obs["ip"] == ip and obs["port"] == port:
# Update the token (re-registration)
obs["token"] = token
self.log("Re-registered observer {}:{} for {}".format(ip, port, resource_url))
return True
# Check limits
total = sum(len(v) for v in self._observers.values())
if total >= self.MAX_TOTAL_OBSERVERS:
self.log("Max total observers reached, rejecting registration")
return False
if len(observers) >= self.MAX_OBSERVERS_PER_RESOURCE:
self.log("Max observers for {} reached, rejecting".format(resource_url))
return False
observers.append({
"ip": ip,
"port": port,
"token": token,
})
self.log("Registered observer {}:{} for {} (token={})".format(
ip, port, resource_url, token
))
return True
def deregister(self, resource_url, ip, port):
"""Remove an observer for a resource."""
if resource_url not in self._observers:
return
observers = self._observers[resource_url]
self._observers[resource_url] = [
obs for obs in observers
if not (obs["ip"] == ip and obs["port"] == port)
]
self.log("Deregistered observer {}:{} from {}".format(ip, port, resource_url))
def deregister_by_token(self, resource_url, token):
"""Remove an observer by token (used when RST is received)."""
if resource_url not in self._observers:
return
observers = self._observers[resource_url]
self._observers[resource_url] = [
obs for obs in observers
if obs["token"] != token
]
def deregister_all(self, resource_url):
"""Remove all observers for a resource."""
if resource_url in self._observers:
del self._observers[resource_url]
del self._sequence[resource_url]
def get_observers(self, resource_url):
"""Get the list of observers for a resource."""
return self._observers.get(resource_url, [])
def has_observers(self, resource_url):
"""Check if a resource has any registered observers."""
return len(self._observers.get(resource_url, [])) > 0
def next_sequence(self, resource_url):
"""Get and increment the sequence number for a resource (24-bit wrap)."""
if resource_url not in self._sequence:
self._sequence[resource_url] = 0
seq = self._sequence[resource_url]
self._sequence[resource_url] = (seq + 1) & 0xFFFFFF
return seq
def get_all_resources(self):
"""Get all resource URLs that have observers."""
return list(self._observers.keys())
def observer_count(self, resource_url=None):
"""Get observer count. If resource_url is None, returns total count."""
if resource_url:
return len(self._observers.get(resource_url, []))
return sum(len(v) for v in self._observers.values())
def summary(self):
"""Return a summary string of all observer registrations."""
parts = []
for url, observers in self._observers.items():
addrs = ["{}:{}".format(o["ip"], o["port"]) for o in observers]
parts.append("{} -> [{}]".format(url, ", ".join(addrs)))
return "; ".join(parts) if parts else "(no observers)"