iotlabkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iotlabkit/__init__.py +30 -0
- iotlabkit/cli.py +239 -0
- iotlabkit/coap_util.py +322 -0
- iotlabkit/data/beijing_air/beijing_sample.csv +801 -0
- iotlabkit/data/india_air/city_day_sample.csv +901 -0
- iotlabkit/data/occupancy/datatraining.txt +801 -0
- iotlabkit/doctor.py +160 -0
- iotlabkit/generator.py +110 -0
- iotlabkit/registry.py +705 -0
- iotlabkit/templates/program_05.py +436 -0
- iotlabkit/templates/program_06.py +404 -0
- iotlabkit/templates/program_07.py +249 -0
- iotlabkit/templates/program_08.py +278 -0
- iotlabkit/templates/program_09.py +329 -0
- iotlabkit/templates/program_10.py +338 -0
- iotlabkit/templates/program_11.py +347 -0
- iotlabkit/templates/program_12.py +317 -0
- iotlabkit/templates/program_13.py +272 -0
- iotlabkit/templates/program_14.py +287 -0
- iotlabkit/templates/program_15.py +411 -0
- iotlabkit/templates/program_16.py +382 -0
- iotlabkit/templates/program_17.py +422 -0
- iotlabkit-1.0.0.dist-info/LICENSE +21 -0
- iotlabkit-1.0.0.dist-info/METADATA +550 -0
- iotlabkit-1.0.0.dist-info/RECORD +28 -0
- iotlabkit-1.0.0.dist-info/WHEEL +5 -0
- iotlabkit-1.0.0.dist-info/entry_points.txt +2 -0
- iotlabkit-1.0.0.dist-info/top_level.txt +1 -0
iotlabkit/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""iotlabkit - A Beginner-Friendly IoT Lab Code Generator.
|
|
2
|
+
|
|
3
|
+
Designed for MSc/MCA Semester 3 IoT Laboratory practicals (Programs 5-17).
|
|
4
|
+
Generates clean, working, self-contained project templates in Learning and Exam modes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
__author__ = "IoT Lab Contributors"
|
|
9
|
+
__license__ = "MIT"
|
|
10
|
+
|
|
11
|
+
from iotlabkit.registry import (
|
|
12
|
+
PROGRAMS,
|
|
13
|
+
SUPPORTED_PROGRAM_IDS,
|
|
14
|
+
EXCLUDED_PROGRAM_IDS,
|
|
15
|
+
get_program,
|
|
16
|
+
list_programs,
|
|
17
|
+
)
|
|
18
|
+
from iotlabkit.generator import generate_program
|
|
19
|
+
from iotlabkit.doctor import run_doctor
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"PROGRAMS",
|
|
24
|
+
"SUPPORTED_PROGRAM_IDS",
|
|
25
|
+
"EXCLUDED_PROGRAM_IDS",
|
|
26
|
+
"get_program",
|
|
27
|
+
"list_programs",
|
|
28
|
+
"generate_program",
|
|
29
|
+
"run_doctor",
|
|
30
|
+
]
|
iotlabkit/cli.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Command-Line Interface (CLI) for iotlabkit.
|
|
2
|
+
|
|
3
|
+
Provides beginner-friendly commands:
|
|
4
|
+
- `iotlabkit list`
|
|
5
|
+
- `iotlabkit info <id>`
|
|
6
|
+
- `iotlabkit create <id> [--mode learning|exam] [--output <dir>] [--force]`
|
|
7
|
+
- `iotlabkit doctor`
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from iotlabkit import __version__
|
|
13
|
+
from iotlabkit.registry import (
|
|
14
|
+
PROGRAMS,
|
|
15
|
+
SUPPORTED_PROGRAM_IDS,
|
|
16
|
+
EXCLUDED_PROGRAM_IDS,
|
|
17
|
+
get_program,
|
|
18
|
+
list_programs,
|
|
19
|
+
)
|
|
20
|
+
from iotlabkit.generator import generate_program
|
|
21
|
+
from iotlabkit.doctor import run_doctor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
25
|
+
"""Displays all supported practical programs and their operational flags."""
|
|
26
|
+
programs = list_programs()
|
|
27
|
+
|
|
28
|
+
print("\n" + "=" * 90)
|
|
29
|
+
print(" iotlabkit — MSc/MCA IoT Laboratory Practicals (Programs 5-17)")
|
|
30
|
+
print("=" * 90)
|
|
31
|
+
print(
|
|
32
|
+
f"{'ID':<4} | {'Title':<45} | {'Net':<4} | {'Keys':<5} | {'Broker':<6} | {'Data':<5} | {'Server':<6}"
|
|
33
|
+
)
|
|
34
|
+
print("-" * 90)
|
|
35
|
+
|
|
36
|
+
current_layer = None
|
|
37
|
+
for p in programs:
|
|
38
|
+
if p.layer != current_layer:
|
|
39
|
+
current_layer = p.layer
|
|
40
|
+
print(f"\n--- {current_layer} ---")
|
|
41
|
+
|
|
42
|
+
net_str = "Yes" if p.requires_internet else "No"
|
|
43
|
+
keys_str = "Yes" if p.requires_credentials else "No"
|
|
44
|
+
broker_str = "Yes" if p.requires_broker else "No"
|
|
45
|
+
data_str = "Yes" if p.requires_dataset else "No"
|
|
46
|
+
srv_str = "Yes" if p.requires_server else "No"
|
|
47
|
+
|
|
48
|
+
title_trunc = p.title if len(p.title) <= 45 else p.title[:42] + "..."
|
|
49
|
+
print(
|
|
50
|
+
f"{p.id:<4} | {title_trunc:<45} | {net_str:<4} | {keys_str:<5} | {broker_str:<6} | {data_str:<5} | {srv_str:<6}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
print("\n" + "-" * 90)
|
|
54
|
+
print("Legend:")
|
|
55
|
+
print(" Net : Requires active internet connectivity")
|
|
56
|
+
print(" Keys : Requires cloud credentials (e.g. Supabase, Firebase, ThingsBoard)")
|
|
57
|
+
print(" Broker : Requires external or local MQTT broker")
|
|
58
|
+
print(" Data : Requires local dataset file (included or downloadable)")
|
|
59
|
+
print(" Server : Runs or requires a local server component")
|
|
60
|
+
print("\nCommands:")
|
|
61
|
+
print(" iotlabkit info <id> # View full details & viva for a program")
|
|
62
|
+
print(" iotlabkit create <id> # Generate project in learning mode")
|
|
63
|
+
print(" iotlabkit create <id> --mode exam # Generate concise project for lab exams")
|
|
64
|
+
print("=" * 90 + "\n")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
69
|
+
"""Displays detailed information, setup steps, and viva preview for a practical."""
|
|
70
|
+
program_id = args.program_id
|
|
71
|
+
|
|
72
|
+
if program_id in EXCLUDED_PROGRAM_IDS:
|
|
73
|
+
print(f"\n❌ Error: Program {program_id} is outside the supported syllabus scope.")
|
|
74
|
+
print(" Only Programs 5 through 17 are included in iotlabkit.\n")
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
p = get_program(program_id)
|
|
79
|
+
except ValueError as err:
|
|
80
|
+
print(f"\n❌ Error: {err}\n")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
print("\n" + "=" * 75)
|
|
84
|
+
print(f"Program {p.id}: {p.title}")
|
|
85
|
+
print("=" * 75)
|
|
86
|
+
print(f"Layer : {p.layer}")
|
|
87
|
+
print(f"Description : {p.description}")
|
|
88
|
+
print(f"Core Objective : {p.objective}")
|
|
89
|
+
print(f"Data Source / API: {p.source_info}")
|
|
90
|
+
|
|
91
|
+
print("\nOperational Flags:")
|
|
92
|
+
print(f" • Internet Required : {'Yes' if p.requires_internet else 'No'}")
|
|
93
|
+
print(f" • Cloud Credentials : {'Yes' if p.requires_credentials else 'No'}")
|
|
94
|
+
print(f" • External Broker : {'Yes' if p.requires_broker else 'No'}")
|
|
95
|
+
print(f" • Local Dataset : {'Yes' if p.requires_dataset else 'No'}")
|
|
96
|
+
print(f" • Local Server : {'Yes' if p.requires_server else 'No'}")
|
|
97
|
+
|
|
98
|
+
print("\nRequired Python Dependencies:")
|
|
99
|
+
if p.dependencies:
|
|
100
|
+
print(f" {', '.join(p.dependencies)}")
|
|
101
|
+
else:
|
|
102
|
+
print(" None (Pure Python standard library)")
|
|
103
|
+
|
|
104
|
+
print("\nPrerequisites:")
|
|
105
|
+
for pre in p.prerequisites:
|
|
106
|
+
print(f" - {pre}")
|
|
107
|
+
|
|
108
|
+
print("\nSetup & Execution Steps:")
|
|
109
|
+
for step in p.setup_steps:
|
|
110
|
+
print(f" {step}")
|
|
111
|
+
|
|
112
|
+
print("\nGenerated Files:")
|
|
113
|
+
for gf in p.generated_files:
|
|
114
|
+
print(f" • {gf}")
|
|
115
|
+
|
|
116
|
+
if p.viva_preview:
|
|
117
|
+
print("\nSample Viva Questions & Answers:")
|
|
118
|
+
for q, a in p.viva_preview:
|
|
119
|
+
print(f" Q: {q}")
|
|
120
|
+
print(f" A: {a}\n")
|
|
121
|
+
|
|
122
|
+
print("=" * 75)
|
|
123
|
+
print(f"To generate this practical:")
|
|
124
|
+
print(f" iotlabkit create {p.id} --mode learning # Detailed guide & comments")
|
|
125
|
+
print(f" iotlabkit create {p.id} --mode exam # Concise code for lab examination")
|
|
126
|
+
print("=" * 75 + "\n")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_create(args: argparse.Namespace) -> int:
|
|
131
|
+
"""Generates project directory for requested practical."""
|
|
132
|
+
program_id = args.program_id
|
|
133
|
+
|
|
134
|
+
if program_id in EXCLUDED_PROGRAM_IDS:
|
|
135
|
+
print(f"\n❌ Error: Program {program_id} is outside the supported syllabus scope.")
|
|
136
|
+
print(" Only Programs 5 through 17 are included in iotlabkit.\n")
|
|
137
|
+
return 1
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
generate_program(
|
|
141
|
+
program_id=program_id,
|
|
142
|
+
output_dir=args.output,
|
|
143
|
+
mode=args.mode,
|
|
144
|
+
force=args.force,
|
|
145
|
+
interactive=not args.force,
|
|
146
|
+
)
|
|
147
|
+
return 0
|
|
148
|
+
except Exception as err:
|
|
149
|
+
print(f"\n❌ Error generating practical {program_id}: {err}\n")
|
|
150
|
+
return 1
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
154
|
+
"""Runs system diagnostics."""
|
|
155
|
+
return run_doctor()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
159
|
+
"""Builds and returns the main argument parser."""
|
|
160
|
+
parser = argparse.ArgumentParser(
|
|
161
|
+
prog="iotlabkit",
|
|
162
|
+
description="iotlabkit — A Beginner-Friendly IoT Lab Code Generator (Programs 5-17).",
|
|
163
|
+
epilog="Designed for MSc/MCA Semester 3 IoT Laboratory practicals.",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
170
|
+
|
|
171
|
+
# list
|
|
172
|
+
parser_list = subparsers.add_parser("list", help="List all supported practical programs")
|
|
173
|
+
parser_list.set_defaults(func=cmd_list)
|
|
174
|
+
|
|
175
|
+
# info
|
|
176
|
+
parser_info = subparsers.add_parser(
|
|
177
|
+
"info", help="Display description, prerequisites, and viva for a program"
|
|
178
|
+
)
|
|
179
|
+
parser_info.add_argument(
|
|
180
|
+
"program_id", type=int, help="Program number (e.g. 5, 8, 12, 15, 17)"
|
|
181
|
+
)
|
|
182
|
+
parser_info.set_defaults(func=cmd_info)
|
|
183
|
+
|
|
184
|
+
# create
|
|
185
|
+
parser_create = subparsers.add_parser(
|
|
186
|
+
"create", help="Generate a complete project directory for a program"
|
|
187
|
+
)
|
|
188
|
+
parser_create.add_argument(
|
|
189
|
+
"program_id", type=int, help="Program number (5 through 17)"
|
|
190
|
+
)
|
|
191
|
+
parser_create.add_argument(
|
|
192
|
+
"-m",
|
|
193
|
+
"--mode",
|
|
194
|
+
choices=["learning", "exam"],
|
|
195
|
+
default="learning",
|
|
196
|
+
help="Presentation mode: 'learning' (detailed guides) or 'exam' (concise lab submission)",
|
|
197
|
+
)
|
|
198
|
+
parser_create.add_argument(
|
|
199
|
+
"-o",
|
|
200
|
+
"--output",
|
|
201
|
+
type=str,
|
|
202
|
+
default=None,
|
|
203
|
+
help="Custom output directory name (defaults to 'labXX')",
|
|
204
|
+
)
|
|
205
|
+
parser_create.add_argument(
|
|
206
|
+
"-f",
|
|
207
|
+
"--force",
|
|
208
|
+
action="store_true",
|
|
209
|
+
help="Overwrite existing files without prompting for confirmation",
|
|
210
|
+
)
|
|
211
|
+
parser_create.set_defaults(func=cmd_create)
|
|
212
|
+
|
|
213
|
+
# doctor
|
|
214
|
+
parser_doctor = subparsers.add_parser(
|
|
215
|
+
"doctor", help="Check Python environment, dependencies, and configuration"
|
|
216
|
+
)
|
|
217
|
+
parser_doctor.set_defaults(func=cmd_doctor)
|
|
218
|
+
|
|
219
|
+
return parser
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main(argv=None) -> int:
|
|
223
|
+
"""CLI main entry point."""
|
|
224
|
+
parser = build_parser()
|
|
225
|
+
args = parser.parse_args(argv)
|
|
226
|
+
|
|
227
|
+
if not args.command:
|
|
228
|
+
parser.print_help()
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
if hasattr(args, "func"):
|
|
232
|
+
return args.func(args)
|
|
233
|
+
|
|
234
|
+
parser.print_help()
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
sys.exit(main())
|
iotlabkit/coap_util.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Pure Python RFC 7252 CoAP (Constrained Application Protocol) Client and Server.
|
|
2
|
+
|
|
3
|
+
This module provides a lightweight, pure-Python implementation of RFC 7252 CoAP over UDP.
|
|
4
|
+
It operates on UDP port 5683 by default and requires ZERO external dependencies,
|
|
5
|
+
ensuring beginner students can immediately run Programs 8 and 10 in standard lab environments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
import struct
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
# CoAP Message Types
|
|
15
|
+
TYPE_CON = 0 # Confirmable
|
|
16
|
+
TYPE_NON = 1 # Non-confirmable
|
|
17
|
+
TYPE_ACK = 2 # Acknowledgement
|
|
18
|
+
TYPE_RST = 3 # Reset
|
|
19
|
+
|
|
20
|
+
# CoAP Method Codes (Request)
|
|
21
|
+
METHOD_EMPTY = 0
|
|
22
|
+
METHOD_GET = 1
|
|
23
|
+
METHOD_POST = 2
|
|
24
|
+
METHOD_PUT = 3
|
|
25
|
+
METHOD_DELETE = 4
|
|
26
|
+
|
|
27
|
+
# CoAP Response Codes: (class << 5) | detail
|
|
28
|
+
CODE_CONTENT = (2 << 5) | 5 # 2.05 Content (69)
|
|
29
|
+
CODE_CHANGED = (2 << 5) | 4 # 2.04 Changed (68)
|
|
30
|
+
CODE_CREATED = (2 << 5) | 1 # 2.01 Created (65)
|
|
31
|
+
CODE_BAD_REQUEST = (4 << 5) | 0 # 4.00 Bad Request (128)
|
|
32
|
+
CODE_NOT_FOUND = (4 << 5) | 4 # 4.04 Not Found (132)
|
|
33
|
+
CODE_INTERNAL_ERR = (5 << 5) | 0 # 5.00 Internal Server Error (160)
|
|
34
|
+
|
|
35
|
+
# CoAP Option Numbers
|
|
36
|
+
OPT_URI_PATH = 11
|
|
37
|
+
OPT_CONTENT_FORMAT = 12
|
|
38
|
+
OPT_URI_QUERY = 15
|
|
39
|
+
|
|
40
|
+
# Content Formats
|
|
41
|
+
FORMAT_TEXT_PLAIN = 0
|
|
42
|
+
FORMAT_APP_JSON = 50
|
|
43
|
+
|
|
44
|
+
DEFAULT_COAP_PORT = 5683
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CoAPMessage:
|
|
48
|
+
"""Represents a standard RFC 7252 CoAP message."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
msg_type: int = TYPE_CON,
|
|
53
|
+
code: int = METHOD_GET,
|
|
54
|
+
msg_id: int = 1,
|
|
55
|
+
token: bytes = b"",
|
|
56
|
+
options: Optional[List[Tuple[int, bytes]]] = None,
|
|
57
|
+
payload: bytes = b"",
|
|
58
|
+
):
|
|
59
|
+
self.version = 1
|
|
60
|
+
self.msg_type = msg_type
|
|
61
|
+
self.code = code
|
|
62
|
+
self.msg_id = msg_id
|
|
63
|
+
self.token = token
|
|
64
|
+
self.options = options or []
|
|
65
|
+
self.payload = payload
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def path(self) -> str:
|
|
69
|
+
"""Extracts the URI path from Uri-Path options."""
|
|
70
|
+
segments = []
|
|
71
|
+
for opt_num, opt_val in self.options:
|
|
72
|
+
if opt_num == OPT_URI_PATH:
|
|
73
|
+
segments.append(opt_val.decode("utf-8", errors="replace"))
|
|
74
|
+
return "/" + "/".join(segments) if segments else "/"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def payload_text(self) -> str:
|
|
78
|
+
"""Returns payload decoded as UTF-8 text."""
|
|
79
|
+
return self.payload.decode("utf-8", errors="replace")
|
|
80
|
+
|
|
81
|
+
def encode(self) -> bytes:
|
|
82
|
+
"""Encodes the CoAP message into standard RFC 7252 binary format."""
|
|
83
|
+
tkl = len(self.token) & 0x0F
|
|
84
|
+
first_byte = ((self.version & 0x03) << 6) | ((self.msg_type & 0x03) << 4) | tkl
|
|
85
|
+
header = struct.pack("!BBH", first_byte, self.code & 0xFF, self.msg_id & 0xFFFF)
|
|
86
|
+
buf = bytearray(header)
|
|
87
|
+
buf.extend(self.token[:tkl])
|
|
88
|
+
|
|
89
|
+
# Sort options by option number
|
|
90
|
+
sorted_opts = sorted(self.options, key=lambda x: x[0])
|
|
91
|
+
last_opt_num = 0
|
|
92
|
+
|
|
93
|
+
for opt_num, opt_val in sorted_opts:
|
|
94
|
+
delta = opt_num - last_opt_num
|
|
95
|
+
length = len(opt_val)
|
|
96
|
+
|
|
97
|
+
delta_nibble, delta_ext = self._encode_opt_field(delta)
|
|
98
|
+
length_nibble, length_ext = self._encode_opt_field(length)
|
|
99
|
+
|
|
100
|
+
opt_header = ((delta_nibble & 0x0F) << 4) | (length_nibble & 0x0F)
|
|
101
|
+
buf.append(opt_header)
|
|
102
|
+
buf.extend(delta_ext)
|
|
103
|
+
buf.extend(length_ext)
|
|
104
|
+
buf.extend(opt_val)
|
|
105
|
+
|
|
106
|
+
last_opt_num = opt_num
|
|
107
|
+
|
|
108
|
+
if self.payload:
|
|
109
|
+
buf.append(0xFF) # Payload marker
|
|
110
|
+
buf.extend(self.payload)
|
|
111
|
+
|
|
112
|
+
return bytes(buf)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def decode(cls, data: bytes) -> "CoAPMessage":
|
|
116
|
+
"""Decodes an RFC 7252 binary buffer into a CoAPMessage."""
|
|
117
|
+
if len(data) < 4:
|
|
118
|
+
raise ValueError(f"CoAP message too short: {len(data)} bytes")
|
|
119
|
+
|
|
120
|
+
first_byte, code, msg_id = struct.unpack("!BBH", data[:4])
|
|
121
|
+
version = (first_byte >> 6) & 0x03
|
|
122
|
+
msg_type = (first_byte >> 4) & 0x03
|
|
123
|
+
tkl = first_byte & 0x0F
|
|
124
|
+
|
|
125
|
+
if version != 1:
|
|
126
|
+
raise ValueError(f"Unsupported CoAP version: {version}")
|
|
127
|
+
|
|
128
|
+
idx = 4
|
|
129
|
+
token = data[idx : idx + tkl]
|
|
130
|
+
idx += tkl
|
|
131
|
+
|
|
132
|
+
options = []
|
|
133
|
+
last_opt_num = 0
|
|
134
|
+
payload = b""
|
|
135
|
+
|
|
136
|
+
while idx < len(data):
|
|
137
|
+
if data[idx] == 0xFF:
|
|
138
|
+
# Payload marker reached
|
|
139
|
+
payload = data[idx + 1 :]
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
opt_header = data[idx]
|
|
143
|
+
idx += 1
|
|
144
|
+
delta_nibble = (opt_header >> 4) & 0x0F
|
|
145
|
+
length_nibble = opt_header & 0x0F
|
|
146
|
+
|
|
147
|
+
delta, idx = cls._decode_opt_field(delta_nibble, data, idx)
|
|
148
|
+
length, idx = cls._decode_opt_field(length_nibble, data, idx)
|
|
149
|
+
|
|
150
|
+
opt_num = last_opt_num + delta
|
|
151
|
+
opt_val = data[idx : idx + length]
|
|
152
|
+
idx += length
|
|
153
|
+
|
|
154
|
+
options.append((opt_num, opt_val))
|
|
155
|
+
last_opt_num = opt_num
|
|
156
|
+
|
|
157
|
+
msg = cls(
|
|
158
|
+
msg_type=msg_type,
|
|
159
|
+
code=code,
|
|
160
|
+
msg_id=msg_id,
|
|
161
|
+
token=token,
|
|
162
|
+
options=options,
|
|
163
|
+
payload=payload,
|
|
164
|
+
)
|
|
165
|
+
return msg
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _encode_opt_field(val: int) -> Tuple[int, bytes]:
|
|
169
|
+
if val < 13:
|
|
170
|
+
return val, b""
|
|
171
|
+
elif val < 269:
|
|
172
|
+
return 13, struct.pack("!B", val - 13)
|
|
173
|
+
else:
|
|
174
|
+
return 14, struct.pack("!H", val - 269)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _decode_opt_field(nibble: int, data: bytes, idx: int) -> Tuple[int, int]:
|
|
178
|
+
if nibble < 13:
|
|
179
|
+
return nibble, idx
|
|
180
|
+
elif nibble == 13:
|
|
181
|
+
if idx >= len(data):
|
|
182
|
+
raise ValueError("Unexpected end of option delta/length")
|
|
183
|
+
val = data[idx] + 13
|
|
184
|
+
return val, idx + 1
|
|
185
|
+
elif nibble == 14:
|
|
186
|
+
if idx + 1 >= len(data):
|
|
187
|
+
raise ValueError("Unexpected end of option delta/length (2-byte)")
|
|
188
|
+
val = struct.unpack("!H", data[idx : idx + 2])[0] + 269
|
|
189
|
+
return val, idx + 2
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError(f"Reserved option nibble: {nibble}")
|
|
192
|
+
|
|
193
|
+
def __repr__(self) -> str:
|
|
194
|
+
code_str = f"{self.code >> 5}.{self.code & 0x1F:02d}"
|
|
195
|
+
return (
|
|
196
|
+
f"CoAPMessage(type={self.msg_type}, code={code_str}, id={self.msg_id}, "
|
|
197
|
+
f"path='{self.path}', payload_len={len(self.payload)})"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class CoAPServer:
|
|
202
|
+
"""A lightweight, multi-threaded CoAP UDP Server."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, host: str = "0.0.0.0", port: int = DEFAULT_COAP_PORT):
|
|
205
|
+
self.host = host
|
|
206
|
+
self.port = port
|
|
207
|
+
self.resources: Dict[str, Callable[[], Tuple[int, str]]] = {}
|
|
208
|
+
self.sock: Optional[socket.socket] = None
|
|
209
|
+
self._running = False
|
|
210
|
+
self._thread: Optional[threading.Thread] = None
|
|
211
|
+
|
|
212
|
+
def add_resource(self, path: str, handler: Callable[[], Tuple[int, str]]):
|
|
213
|
+
"""Registers a URL path handler that returns (status_code, payload_text)."""
|
|
214
|
+
clean_path = "/" + path.strip("/")
|
|
215
|
+
self.resources[clean_path] = handler
|
|
216
|
+
|
|
217
|
+
def start(self, blocking: bool = False):
|
|
218
|
+
"""Binds socket and starts listening for CoAP client requests."""
|
|
219
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
220
|
+
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
221
|
+
self.sock.bind((self.host, self.port))
|
|
222
|
+
self.sock.settimeout(1.0)
|
|
223
|
+
self._running = True
|
|
224
|
+
|
|
225
|
+
if blocking:
|
|
226
|
+
self._serve()
|
|
227
|
+
else:
|
|
228
|
+
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
229
|
+
self._thread.start()
|
|
230
|
+
|
|
231
|
+
def _serve(self):
|
|
232
|
+
while self._running:
|
|
233
|
+
try:
|
|
234
|
+
data, addr = self.sock.recvfrom(2048)
|
|
235
|
+
except (socket.timeout, OSError):
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
req = CoAPMessage.decode(data)
|
|
240
|
+
resp = self._handle_request(req)
|
|
241
|
+
self.sock.sendto(resp.encode(), addr)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def _handle_request(self, req: CoAPMessage) -> CoAPMessage:
|
|
246
|
+
path = req.path
|
|
247
|
+
handler = self.resources.get(path)
|
|
248
|
+
|
|
249
|
+
if not handler:
|
|
250
|
+
return CoAPMessage(
|
|
251
|
+
msg_type=TYPE_ACK if req.msg_type == TYPE_CON else TYPE_NON,
|
|
252
|
+
code=CODE_NOT_FOUND,
|
|
253
|
+
msg_id=req.msg_id,
|
|
254
|
+
token=req.token,
|
|
255
|
+
payload=f"Resource '{path}' not found".encode("utf-8"),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
status_code, payload_text = handler()
|
|
260
|
+
payload_bytes = payload_text.encode("utf-8")
|
|
261
|
+
return CoAPMessage(
|
|
262
|
+
msg_type=TYPE_ACK if req.msg_type == TYPE_CON else TYPE_NON,
|
|
263
|
+
code=status_code,
|
|
264
|
+
msg_id=req.msg_id,
|
|
265
|
+
token=req.token,
|
|
266
|
+
options=[(OPT_CONTENT_FORMAT, struct.pack("!B", FORMAT_APP_JSON))],
|
|
267
|
+
payload=payload_bytes,
|
|
268
|
+
)
|
|
269
|
+
except Exception as err:
|
|
270
|
+
return CoAPMessage(
|
|
271
|
+
msg_type=TYPE_ACK if req.msg_type == TYPE_CON else TYPE_NON,
|
|
272
|
+
code=CODE_INTERNAL_ERR,
|
|
273
|
+
msg_id=req.msg_id,
|
|
274
|
+
token=req.token,
|
|
275
|
+
payload=f"Server error: {err}".encode("utf-8"),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def stop(self):
|
|
279
|
+
"""Stops the server and releases the socket."""
|
|
280
|
+
self._running = False
|
|
281
|
+
if self._thread and self._thread.is_alive():
|
|
282
|
+
self._thread.join(timeout=2.0)
|
|
283
|
+
if self.sock:
|
|
284
|
+
try:
|
|
285
|
+
self.sock.close()
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class CoAPClient:
|
|
291
|
+
"""A synchronous CoAP client for making GET requests."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, timeout: float = 3.0):
|
|
294
|
+
self.timeout = timeout
|
|
295
|
+
self._msg_counter = 100
|
|
296
|
+
|
|
297
|
+
def get(self, host: str, port: int, path: str) -> CoAPMessage:
|
|
298
|
+
"""Sends a CoAP GET request to the specified host:port/path."""
|
|
299
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
300
|
+
sock.settimeout(self.timeout)
|
|
301
|
+
|
|
302
|
+
self._msg_counter = (self._msg_counter + 1) % 65535
|
|
303
|
+
clean_path = path.strip("/")
|
|
304
|
+
options = []
|
|
305
|
+
if clean_path:
|
|
306
|
+
for segment in clean_path.split("/"):
|
|
307
|
+
options.append((OPT_URI_PATH, segment.encode("utf-8")))
|
|
308
|
+
|
|
309
|
+
req = CoAPMessage(
|
|
310
|
+
msg_type=TYPE_CON,
|
|
311
|
+
code=METHOD_GET,
|
|
312
|
+
msg_id=self._msg_counter,
|
|
313
|
+
token=struct.pack("!H", self._msg_counter),
|
|
314
|
+
options=options,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
sock.sendto(req.encode(), (host, port))
|
|
319
|
+
data, _ = sock.recvfrom(2048)
|
|
320
|
+
return CoAPMessage.decode(data)
|
|
321
|
+
finally:
|
|
322
|
+
sock.close()
|