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 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()