functionality updates

This commit is contained in:
2026-09-15 17:13:32 +06:00
parent bd7ac94e74
commit b8d3a44d22
11 changed files with 338 additions and 533 deletions

View File

@@ -2,6 +2,7 @@
name = "coap-hello-udp" name = "coap-hello-udp"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "coap-hello-udp"
[dependencies] [dependencies]
coap-lite = "0.13.3" coap-lite = "0.13.3"

View File

@@ -46,20 +46,19 @@ cargo run --release --bin coap-hello-udp
cargo run --release --bin bridge cargo run --release --bin bridge
``` ```
Then open <http://localhost:8080/> for a test console with a name field and a Every response shows the CoAP code mapped onto an HTTP status, with the raw
button per route. Every response shows both the CoAP code and the HTTP status CoAP code returned verbatim in an `X-Coap-Code` header.
it was mapped to; the raw CoAP code is also returned in an `X-Coap-Code` header.
These are now ordinary URLs — clickable, curl-able, bookmarkable: These are now ordinary URLs — clickable, curl-able, bookmarkable:
| URL | Response | | URL | Response |
|--------------------------------------------------|------------------------------| |--------------------------------------------------|------------------------------|
| `http://localhost:8080/` | the test console | | `http://localhost:8080/` | the CoAP reference page |
| `http://localhost:8080/hello` | `Hello, World!` | | `http://localhost:8080/hello` | `Hello, World!` |
| `http://localhost:8080/hello?name=Imran` | `Hello, Imran!` | | `http://localhost:8080/hello?name=Imran` | `Hello, Imran!` |
| `http://localhost:8080/health` | `{"status":"ok","uptime":12}`| | `http://localhost:8080/health` | `{"status":"ok","uptime":12}`|
POST needs a body, so it is easiest from the console page or curl: POST needs a body, so use curl:
```bash ```bash
curl -X POST -d "Imran" http://localhost:8080/hello curl -X POST -d "Imran" http://localhost:8080/hello
@@ -104,7 +103,7 @@ decoding.
| `src/lib.rs` | Shared CoAP client used by the CLI and the bridge. | | `src/lib.rs` | Shared CoAP client used by the CLI and the bridge. |
| `src/bin/client.rs` | CLI client for smoke tests. | | `src/bin/client.rs` | CLI client for smoke tests. |
| `src/bin/bridge.rs` | HTTP -> CoAP proxy so a browser can reach the server. | | `src/bin/bridge.rs` | HTTP -> CoAP proxy so a browser can reach the server. |
| `static/index.html` | The browser test console the bridge serves at `/`. | | `static/index.html` | CoAP reference page: routes, codes, copyable commands. |
| `nodejs/` | The earlier Node.js version, kept for reference. | | `nodejs/` | The earlier Node.js version, kept for reference. |
## Config ## Config

Binary file not shown.

200
coap-inspect.py Executable file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Send one CoAP request over UDP and show both datagrams byte by byte.
./coap-inspect.py get "/hello?name=Imran"
./coap-inspect.py post /hello Imran
./coap-inspect.py get /health
No CoAP library: the packet is built and decoded right here, so every byte on
screen is accounted for. COAP_HOST / COAP_PORT retarget it (127.0.0.1:5683).
"""
import os
import random
import socket
import sys
import time
TYPES = {0: "CON confirmable", 1: "NON non-confirmable", 2: "ACK acknowledgement", 3: "RST reset"}
METHODS = {"get": 1, "post": 2, "put": 3, "delete": 4}
CODES = {
"0.00": "Empty", "0.01": "GET", "0.02": "POST", "0.03": "PUT", "0.04": "DELETE",
"2.01": "Created", "2.02": "Deleted", "2.03": "Valid", "2.04": "Changed", "2.05": "Content",
"4.00": "Bad Request", "4.04": "Not Found", "4.05": "Method Not Allowed",
"5.00": "Internal Server Error",
}
OPTIONS = {3: "Uri-Host", 7: "Uri-Port", 11: "Uri-Path", 12: "Content-Format", 15: "Uri-Query", 17: "Accept"}
FORMATS = {0: "text/plain", 50: "application/json"}
TTY = sys.stdout.isatty()
def bold(text):
return f"\033[1m{text}\033[0m" if TTY else text
def encode_option(delta, value):
"""One option: a byte of 4-bit delta + 4-bit length, then any extended bytes, then the value."""
def nibble(n):
if n < 13:
return n, b""
if n < 269:
return 13, bytes([n - 13])
return 14, (n - 269).to_bytes(2, "big")
d, d_ext = nibble(delta)
l, l_ext = nibble(len(value))
return bytes([(d << 4) | l]) + d_ext + l_ext + value
def build_request(method, path, query, payload):
token = os.urandom(4)
header = bytes([(1 << 6) | (0 << 4) | len(token), METHODS[method]]) # ver 1, CON, TKL, code
header += random.randrange(1 << 16).to_bytes(2, "big") + token # message id, token
# Options must be in ascending number order; each stores the gap from the previous one.
options = [(11, s.encode()) for s in path.split("/") if s]
options += [(15, q.encode()) for q in query.split("&") if q]
body, last = b"", 0
for number, value in options:
body += encode_option(number - last, value)
last = number
if payload:
body += b"\xff" + payload
return header + body
def decode(pkt):
"""Return (rows, meta). Each row is (start, end, field, meaning) over pkt[start:end]."""
if len(pkt) < 4:
raise ValueError(f"{len(pkt)} bytes is shorter than the 4-byte CoAP header")
rows = []
b0 = pkt[0]
ver, typ, tkl = b0 >> 6, (b0 >> 4) & 0b11, b0 & 0b1111
rows.append((0, 1, "ver|type|tkl",
f"{ver:02b} {typ:02b} {tkl:04b} -> version {ver}, {TYPES[typ]}, token {tkl} bytes"))
cls, detail = pkt[1] >> 5, pkt[1] & 0b11111
code = f"{cls}.{detail:02d}"
rows.append((1, 2, "code", f"{cls:03b} {detail:05b} -> {code} {CODES.get(code, '')}"))
mid = int.from_bytes(pkt[2:4], "big")
rows.append((2, 4, "message id", f"{mid} (0x{mid:04x})"))
token = pkt[4:4 + tkl]
if tkl:
rows.append((4, 4 + tkl, "token", f"0x{token.hex()}"))
i, number = 4 + tkl, 0
while i < len(pkt):
if pkt[i] == 0xFF:
body = pkt[i + 1:]
rows.append((i, i + 1, "payload marker", "0xff -> the rest is the body"))
rows.append((i + 1, len(pkt), "payload",
f"{len(body)} bytes: {body.decode('utf-8', 'replace')}"))
break
start = i
delta, length = pkt[i] >> 4, pkt[i] & 0b1111
i += 1
extended = []
for which, n in (("delta", delta), ("length", length)):
if n == 13:
n, i = pkt[i] + 13, i + 1
elif n == 14:
n, i = int.from_bytes(pkt[i:i + 2], "big") + 269, i + 2
extended.append(n)
delta, length = extended
number += delta
value = pkt[i:i + length]
i += length
if number in (12, 17):
n = int.from_bytes(value, "big") if value else 0
shown = f"{n} = {FORMATS.get(n, 'unknown format')}"
else:
shown = f'"{value.decode("utf-8", "replace")}"'
rows.append((start, i, OPTIONS.get(number, f"option {number}"),
f"delta {delta} -> option {number}, {length} bytes: {shown}"))
return rows, {"type": typ, "code": code, "mid": mid, "token": token}
def show(title, pkt):
print(bold(f"\n{title} ({len(pkt)} bytes)"))
print(bold("\n hex + ascii"))
for off in range(0, len(pkt), 16):
chunk = pkt[off:off + 16]
hexes = " ".join(f"{b:02x}" for b in chunk)
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
print(f" {off:04x} {hexes:<47} |{text}|")
print(bold("\n binary"))
for off in range(0, len(pkt), 4):
chunk = pkt[off:off + 4]
bits = " ".join(f"{b:08b}" for b in chunk)
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
print(f" {off:04x} {bits:<35} |{text}|")
rows, meta = decode(pkt)
print(bold("\n fields"))
print(f" {'byte':<7} {'hex':<24} {'field':<15} meaning")
for start, end, field, meaning in rows:
span = f"{start}" if end - start == 1 else f"{start}-{end - 1}"
hexes = pkt[start:end].hex(" ")
if len(hexes) > 23:
hexes = hexes[:20] + "..."
print(f" {span:<7} {hexes:<24} {field:<15} {meaning}")
return meta
def main():
args = sys.argv[1:]
if not args or args[0].lower() not in METHODS:
print(__doc__.strip())
return 2
method = args[0].lower()
target = args[1] if len(args) > 1 else "/hello"
payload = args[2].encode() if len(args) > 2 else b""
path, _, query = target.partition("?")
host = os.environ.get("COAP_HOST", "127.0.0.1")
port = int(os.environ.get("COAP_PORT", "5683"))
request = build_request(method, path, query, payload)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # SOCK_DGRAM = UDP
sock.settimeout(3)
sock.connect((host, port))
local = "%s:%d" % sock.getsockname()
req = show(f">>> REQUEST udp {local} -> {host}:{port}", request)
sent_at = time.perf_counter()
sock.send(request)
try:
response = sock.recv(1152)
except socket.timeout:
print(f"\nno reply within 3s - is the CoAP server running on {host}:{port}?")
return 1
except ConnectionRefusedError:
print(f"\nport unreachable - nothing is listening on udp {host}:{port}")
return 1
elapsed = (time.perf_counter() - sent_at) * 1000
res = show(f"<<< RESPONSE udp {host}:{port} -> {local}", response)
print(bold("\n exchange"))
print(f" request {TYPES[req['type']].split()[0]} {req['code']} mid 0x{req['mid']:04x} "
f"token 0x{req['token'].hex()} {len(request)} bytes")
print(f" response {TYPES[res['type']].split()[0]} {res['code']} mid 0x{res['mid']:04x} "
f"token 0x{res['token'].hex()} {len(response)} bytes after {elapsed:.2f} ms")
same_mid = "yes" if req["mid"] == res["mid"] else "NO"
same_token = "yes" if req["token"] == res["token"] else "NO"
kind = " (piggybacked: the ACK carries the answer)" if res["type"] == 2 else ""
print(f" message id matches: {same_mid} token matches: {same_token}{kind}")
return 0
if __name__ == "__main__":
sys.exit(main())

31
healthcheck.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Liveness check for the CoAP server. Exits 0 if healthy, 1 if not.
# ./healthcheck.sh quiet, for cron/systemd/monitoring
# ./healthcheck.sh -v print what it found
# Retargets with COAP_HOST / COAP_PORT.
CLIENT=${CLIENT:-$(dirname "$0")/target/release/client}
verbose=false; [[ $1 == -v ]] && verbose=true
say() { $verbose && echo "$@"; }
host=${COAP_HOST:-127.0.0.1}; port=${COAP_PORT:-5683}
[[ -x $CLIENT ]] || { echo "healthcheck: no client binary at $CLIENT (cargo build --release)"; exit 1; }
reply=$("$CLIENT" get /health 2>&1)
# A bound socket proves nothing on UDP - the round trip is the real test.
if [[ $reply != "<- 2.05 "* ]]; then
echo "UNHEALTHY: coap://$host:$port/health -> ${reply:-no reply}"
exit 1
fi
body=${reply#<- 2.05 }
if [[ $body != *'"status":"ok"'* ]]; then
echo "UNHEALTHY: unexpected body from coap://$host:$port/health -> $body"
exit 1
fi
uptime=$(sed -n 's/.*"uptime":\([0-9]*\).*/\1/p' <<<"$body")
say "HEALTHY: coap://$host:$port up ${uptime}s (code 2.05)"
exit 0

View File

@@ -1,32 +0,0 @@
'use strict';
// Tiny CLI client:
// node client.js get /hello?name=Imran
// node client.js post /hello Imran
// node client.js get /health
const coap = require('coap');
const [, , methodArg = 'get', pathArg = '/hello', payload] = process.argv;
const req = coap.request({
host: process.env.COAP_HOST || 'localhost',
port: Number(process.env.COAP_PORT) || 5683,
pathname: pathArg.split('?')[0],
query: pathArg.split('?')[1] || '',
method: methodArg.toUpperCase(),
confirmable: true,
});
req.on('response', (res) => {
console.log(`<- ${res.code} ${res.payload.toString()}`);
process.exit(0);
});
req.on('error', (err) => {
console.error('request failed:', err.message);
process.exit(1);
});
if (payload) req.write(payload);
req.end();

309
nodejs/package-lock.json generated
View File

@@ -1,309 +0,0 @@
{
"name": "coapwithudp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coapwithudp",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"coap": "^1.6.0"
}
},
"node_modules/@types/node": {
"version": "26.4.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.24",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/capitalize": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/capitalize/-/capitalize-2.0.4.tgz",
"integrity": "sha512-wcSyiFqXRYyCoqu0o0ekXzJAKCLMkqWS5QWGlgTJFJKwRmI6pzcN2hBl5VPq9RzLW5Uf4FF/V/lcFfjCtVak2w==",
"license": "MIT"
},
"node_modules/cbor": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.2.tgz",
"integrity": "sha512-JPypkxsB10s9QOWwa6zwPzqE1Md3vqpPc+cai4sAecuCsRyAtAl/pMyhPlMbT/xtPnm2dznJZYRLui57qiRhaQ==",
"license": "MIT",
"dependencies": {
"nofilter": "^3.1.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/coap": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/coap/-/coap-1.6.0.tgz",
"integrity": "sha512-AvPHT2yQet0wOhlmIqFk11NQPnsKt30yRk6IGp9Yhb6Xnp3ozExPfklJXiE28Xb47tjU0M2OV2W++h8f350Mjg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.14",
"bl": "^6.0.12",
"capitalize": "^2.0.4",
"coap-oscore": "^2.2.3",
"coap-packet": "^1.1.1",
"debug": "^4.3.5",
"fastseries": "^2.0.0",
"lru-cache": "^11.0.2",
"readable-stream": "^4.5.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/coap-oscore": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/coap-oscore/-/coap-oscore-2.2.3.tgz",
"integrity": "sha512-uF592/QIDgxbwhvAHCiWJat0xIoWsc8K7ubzYK1jXM4ihe50sHLAM7pl361NzZXLOMkkYnHCBSq07DA56OIs/A==",
"license": "MIT",
"dependencies": {
"cbor": "^9.0.2"
}
},
"node_modules/coap-packet": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/coap-packet/-/coap-packet-1.1.1.tgz",
"integrity": "sha512-Bkz2ZKI/7hU2gm6nUuo5l+MBSkdFJx7My1ZgNEhKUC7K2yYfQYVbBPRa64BBYLcEcYgaSlau4A1Uw5xfM2I0zw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fastseries": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz",
"integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==",
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nofilter": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz",
"integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==",
"license": "MIT",
"engines": {
"node": ">=12.19"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
}
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "coap-hello-udp",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"client": "node client.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "Minimal CoAP-over-UDP hello world server with GET/POST routes",
"dependencies": {
"coap": "^1.6.0"
}
}

View File

@@ -1,102 +0,0 @@
'use strict';
const coap = require('coap');
const PORT = Number(process.env.COAP_PORT) || 5683;
const HOST = process.env.COAP_HOST || '0.0.0.0';
// --- helpers ---------------------------------------------------------------
const TEXT = 'text/plain';
const JSON_FMT = 'application/json';
/** Split "/hello?name=Bob" into ["/hello", "name=Bob"]. */
function splitUrl(url) {
const i = url.indexOf('?');
const path = (i === -1 ? url : url.slice(0, i)).replace(/\/+$/, '') || '/';
return [path, i === -1 ? '' : url.slice(i + 1)];
}
/** Pull a name out of "?name=Bob" style queries. */
function nameFromQuery(query) {
return new URLSearchParams(query || '').get('name');
}
/** Pull a name out of a POST body: raw text or {"name":"Bob"}. */
function nameFromBody(buf) {
const body = buf.toString('utf8').trim();
if (!body) return null;
if (body.startsWith('{')) {
try {
const parsed = JSON.parse(body);
return typeof parsed.name === 'string' ? parsed.name.trim() : null;
} catch {
return null;
}
}
return body;
}
function greet(name) {
return `Hello, ${name && name.length ? name : 'World'}!`;
}
function send(res, code, payload, format = TEXT) {
res.code = code;
res.setOption('Content-Format', format);
res.end(typeof payload === 'string' ? payload : JSON.stringify(payload));
}
// --- routes ----------------------------------------------------------------
// key: "METHOD /path"
const routes = {
'GET /hello': (req, res) => {
send(res, '2.05', greet(nameFromQuery(req.query)));
},
'POST /hello': (req, res) => {
const name = nameFromBody(req.payload) || nameFromQuery(req.query);
if (!name) {
return send(res, '4.00', 'Bad Request: send a name as the payload');
}
send(res, '2.01', greet(name));
},
'GET /health': (_req, res) => {
send(res, '2.05', { status: 'ok', uptime: Math.round(process.uptime()) }, JSON_FMT);
},
};
// --- server ----------------------------------------------------------------
const server = coap.createServer({ type: 'udp4' });
server.on('request', (req, res) => {
const [path, query] = splitUrl(req.url);
req.query = query; // node-coap only gives us req.url on the server side
const key = `${req.method} ${path}`;
const handler = routes[key];
console.log(`${req.method} ${req.url} from ${req.rsinfo.address}:${req.rsinfo.port}`);
if (!handler) return send(res, '4.04', `Not Found: ${path}`);
try {
handler(req, res);
} catch (err) {
console.error('handler error:', err);
send(res, '5.00', 'Internal Server Error');
}
});
server.on('error', (err) => console.error('server error:', err));
server.listen(PORT, HOST, () => {
console.log(`CoAP server (UDP) listening on coap://${HOST}:${PORT}`);
console.log(' GET /hello?name=Imran');
console.log(' POST /hello (payload: Imran | {"name":"Imran"})');
console.log(' GET /health');
});
process.on('SIGINT', () => server.close(() => process.exit(0)));

View File

@@ -269,6 +269,7 @@ mod tests {
} }
#[test] #[test]
fn reads_name_from_text_and_json_payloads() { fn reads_name_from_text_and_json_payloads() {
assert_eq!(name_from_payload(b"Imran").as_deref(), Some("Imran")); assert_eq!(name_from_payload(b"Imran").as_deref(), Some("Imran"));
assert_eq!( assert_eq!(

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoAP Hello — Test Console</title> <title>CoAP Hello — UDP Reference</title>
<style> <style>
:root { :root {
color-scheme: light dark; color-scheme: light dark;
@@ -23,112 +23,144 @@
margin: 0; padding: 2rem 1.25rem; background: var(--bg); color: var(--fg); margin: 0; padding: 2rem 1.25rem; background: var(--bg); color: var(--fg);
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
} }
main { max-width: 640px; margin: 0 auto; } main { max-width: 680px; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 .35rem; letter-spacing: -.01em; } h1 { font-size: 1.4rem; margin: 0 0 .35rem; letter-spacing: -.01em; }
.sub { color: var(--muted); margin: 0 0 1.75rem; font-size: .9rem; } .sub { color: var(--muted); margin: 0 0 1.75rem; font-size: .9rem; }
.card { .card {
background: var(--card); border: 1px solid var(--border); background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 1.25rem; margin-bottom: 1rem; border-radius: 10px; padding: 1.25rem; margin-bottom: 1rem;
} }
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .06em;
color: var(--muted); margin: 0 0 .7rem; }
label { display: block; font-weight: 600; font-size: .82rem; margin-bottom: .4rem; } label { display: block; font-weight: 600; font-size: .82rem; margin-bottom: .4rem; }
input { input {
width: 100%; padding: .6rem .7rem; font: inherit; border-radius: 7px; width: 100%; padding: .6rem .7rem; font: inherit; border-radius: 7px;
border: 1px solid var(--border); background: var(--bg); color: var(--fg); border: 1px solid var(--border); background: var(--bg); color: var(--fg);
} }
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .85rem; } .hint { color: var(--muted); font-size: .82rem; margin: .8rem 0 0; }
button { .cmd { display: flex; gap: .5rem; align-items: stretch; margin-top: .55rem; }
font: inherit; font-weight: 600; padding: .55rem 1rem; border-radius: 7px; .cmd pre { flex: 1; margin: 0; }
border: 1px solid var(--border); background: var(--bg); color: var(--fg); cursor: pointer;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { opacity: .88; color: #fff; }
pre { pre {
background: var(--code-bg); border: 1px solid var(--border); border-radius: 7px; background: var(--code-bg); border: 1px solid var(--border); border-radius: 7px;
padding: .8rem; overflow-x: auto; margin: 0; padding: .7rem .8rem; overflow-x: auto; margin: 0;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
} }
.status { font-weight: 600; } button {
.status.ok { color: var(--ok); } font: inherit; font-size: .8rem; font-weight: 600; padding: .35rem .7rem;
.status.err { color: var(--err); } border-radius: 7px; border: 1px solid var(--border);
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .06em; background: var(--bg); color: var(--muted); cursor: pointer; white-space: nowrap;
color: var(--muted); margin: 0 0 .7rem; } }
.hint { color: var(--muted); font-size: .82rem; margin: .8rem 0 0; } button:hover { border-color: var(--accent); color: var(--accent); }
button.copied { color: var(--ok); border-color: var(--ok); }
.what { font-size: .8rem; color: var(--muted); margin: 1rem 0 .1rem; }
.what:first-of-type { margin-top: 0; }
table { width: 100%; border-collapse: collapse; font-size: .87rem; }
th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid var(--border); }
th { font-size: .74rem; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); font-weight: 600; }
tr:last-child td { border-bottom: none; }
td.code { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }
.ok { color: var(--ok); font-weight: 600; }
.err { color: var(--err); font-weight: 600; }
code { background: var(--code-bg); padding: .1em .35em; border-radius: 4px; code { background: var(--code-bg); padding: .1em .35em; border-radius: 4px;
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; } font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
a { color: var(--accent); }
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<h1>CoAP Hello — Test Console</h1> <h1>CoAP Hello — UDP Reference</h1>
<p class="sub">This page talks HTTP to a local bridge, which reissues each <p class="sub">This server speaks CoAP over UDP only, so there is nothing here
call as CoAP over UDP and shows you the raw response code.</p> for a browser to call. Type a name and copy the command for the client you
use — the page is static and works straight off disk.</p>
<div class="card"> <div class="card">
<label for="name">Name</label> <label for="name">Name</label>
<input id="name" value="Imran" autocomplete="off" placeholder="leave blank for &quot;World&quot;"> <input id="name" value="Imran" autocomplete="off" spellcheck="false"
<div class="row"> placeholder="leave blank for &quot;World&quot;">
<button class="primary" data-act="get">GET /hello</button>
<button data-act="post">POST /hello</button> <p class="what">Bundled client — <code>cargo build --release</code> first</p>
<button data-act="json">POST (JSON body)</button> <div class="cmd"><pre id="c-get"></pre><button data-copy="c-get">Copy</button></div>
<button data-act="health">GET /health</button> <div class="cmd"><pre id="c-post"></pre><button data-copy="c-post">Copy</button></div>
<button data-act="notfound">GET /nope</button> <div class="cmd"><pre id="c-json"></pre><button data-copy="c-json">Copy</button></div>
</div> <div class="cmd"><pre id="c-health"></pre><button data-copy="c-health">Copy</button></div>
<p class="hint">Or hit an endpoint directly:
<a href="/hello?name=Imran">/hello?name=Imran</a> · <p class="what">Any other CoAP client, e.g. <a href="https://www.npmjs.com/package/coap-cli">coap-cli</a></p>
<a href="/health">/health</a></p> <div class="cmd"><pre id="c-cli"></pre><button data-copy="c-cli">Copy</button></div>
<p class="hint">Set <code>COAP_HOST</code> / <code>COAP_PORT</code> to point at
another server; both default to <code>127.0.0.1:5683</code>.</p>
</div> </div>
<div class="card"> <div class="card">
<h2>Response</h2> <h2>Routes</h2>
<p id="status" class="status">—</p> <table>
<pre id="out">Pick an action above.</pre> <thead><tr><th>Request</th><th>Code</th><th>Response</th></tr></thead>
<tbody>
<tr><td class="code">GET /hello</td><td class="code ok">2.05</td><td class="code">Hello, World!</td></tr>
<tr><td class="code">GET /hello?name=…</td><td class="code ok">2.05</td><td class="code">Hello, …!</td></tr>
<tr><td class="code">POST /hello</td><td class="code ok">2.01</td><td class="code">Hello, &lt;payload&gt;!</td></tr>
<tr><td class="code">GET /health</td><td class="code ok">2.05</td><td class="code">{"status":"ok","uptime":12}</td></tr>
<tr><td class="code">POST /hello (empty)</td><td class="code err">4.00</td><td class="code">Bad Request</td></tr>
<tr><td class="code">GET /nope</td><td class="code err">4.04</td><td class="code">Not Found</td></tr>
<tr><td class="code">PUT /hello</td><td class="code err">4.05</td><td class="code">Method Not Allowed</td></tr>
</tbody>
</table>
<p class="hint">CoAP writes response codes as <code>class.detail</code>:
<code>2.05</code> is HTTP's 200, <code>4.04</code> its 404.
<code>POST /hello</code> takes raw text or <code>{"name":"…"}</code>.</p>
</div>
<div class="card">
<h2>Scripts</h2>
<p class="what">Full request/response suite against a running server</p>
<div class="cmd"><pre id="c-test">./test-coap.sh</pre><button data-copy="c-test">Copy</button></div>
<p class="what">Liveness check — exit 0 healthy, 1 not</p>
<div class="cmd"><pre id="c-health-sh">./healthcheck.sh -v</pre><button data-copy="c-health-sh">Copy</button></div>
<p class="what">Start the server</p>
<div class="cmd"><pre id="c-run">cargo run --release</pre><button data-copy="c-run">Copy</button></div>
</div> </div>
</main> </main>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const shell = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
async function call(method, path, body) { function render() {
$('status').textContent = '…';
$('status').className = 'status';
$('out').textContent = '';
try {
const res = await fetch(path, {
method,
body,
headers: body ? { 'Content-Type': 'text/plain' } : undefined,
});
const text = await res.text();
const coap = res.headers.get('X-Coap-Code') || '—';
$('status').textContent = `CoAP ${coap} · HTTP ${res.status}`;
$('status').className = 'status ' + (res.ok ? 'ok' : 'err');
$('out').textContent = `${method} ${path}\n${body ? 'body: ' + body + '\n' : ''}\n${text || '(empty)'}`;
} catch (err) {
$('status').textContent = 'Request failed';
$('status').className = 'status err';
$('out').textContent = String(err) + '\n\nIs the bridge still running?';
}
}
document.querySelectorAll('button[data-act]').forEach((btn) => {
btn.addEventListener('click', () => {
const name = $('name').value.trim(); const name = $('name').value.trim();
switch (btn.dataset.act) { const q = name ? '/hello?name=' + encodeURIComponent(name) : '/hello';
case 'get': const who = name || 'Imran';
return call('GET', '/hello' + (name ? '?name=' + encodeURIComponent(name) : ''));
case 'post': $('c-get').textContent = `./target/release/client get ${shell(q)}`;
return call('POST', '/hello', name || ''); $('c-post').textContent = `./target/release/client post /hello ${shell(who)}`;
case 'json': $('c-json').textContent = `./target/release/client post /hello ${shell(JSON.stringify({ name: who }))}`;
return call('POST', '/hello', JSON.stringify({ name })); $('c-health').textContent = `./target/release/client get /health`;
case 'health': $('c-cli').textContent = `coap get ${shell('coap://localhost:5683' + q)}`;
return call('GET', '/health');
case 'notfound':
return call('GET', '/nope');
} }
document.querySelectorAll('button[data-copy]').forEach((btn) => {
btn.addEventListener('click', async () => {
const text = $(btn.dataset.copy).textContent;
try {
await navigator.clipboard.writeText(text);
} catch {
// file:// and non-secure origins deny the clipboard API; fall back.
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1200);
}); });
}); });
$('name').addEventListener('input', render);
render();
</script> </script>
</body> </body>
</html> </html>