docs: add project description and server setup instructions to README
This commit is contained in:
7
esp/microcoapy/__init__.py
Normal file
7
esp/microcoapy/__init__.py
Normal 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
|
||||
112
esp/microcoapy/coap_macros.py
Normal file
112
esp/microcoapy/coap_macros.py
Normal 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")
|
||||
10
esp/microcoapy/coap_option.py
Normal file
10
esp/microcoapy/coap_option.py
Normal 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
|
||||
83
esp/microcoapy/coap_packet.py
Normal file
83
esp/microcoapy/coap_packet.py
Normal 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
|
||||
)
|
||||
88
esp/microcoapy/coap_reader.py
Normal file
88
esp/microcoapy/coap_reader.py
Normal 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
|
||||
67
esp/microcoapy/coap_writer.py
Normal file
67
esp/microcoapy/coap_writer.py
Normal 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)
|
||||
512
esp/microcoapy/microcoapy.py
Normal file
512
esp/microcoapy/microcoapy.py
Normal 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
|
||||
144
esp/microcoapy/observe_manager.py
Normal file
144
esp/microcoapy/observe_manager.py
Normal 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)"
|
||||
Reference in New Issue
Block a user