oscura 0.5.1__py3-none-any.whl → 0.7.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.
Files changed (497) hide show
  1. oscura/__init__.py +169 -167
  2. oscura/analyzers/__init__.py +3 -0
  3. oscura/analyzers/classification.py +659 -0
  4. oscura/analyzers/digital/edges.py +325 -65
  5. oscura/analyzers/digital/quality.py +293 -166
  6. oscura/analyzers/digital/timing.py +260 -115
  7. oscura/analyzers/digital/timing_numba.py +334 -0
  8. oscura/analyzers/entropy.py +605 -0
  9. oscura/analyzers/eye/diagram.py +176 -109
  10. oscura/analyzers/eye/metrics.py +5 -5
  11. oscura/analyzers/jitter/__init__.py +6 -4
  12. oscura/analyzers/jitter/ber.py +52 -52
  13. oscura/analyzers/jitter/classification.py +156 -0
  14. oscura/analyzers/jitter/decomposition.py +163 -113
  15. oscura/analyzers/jitter/spectrum.py +80 -64
  16. oscura/analyzers/ml/__init__.py +39 -0
  17. oscura/analyzers/ml/features.py +600 -0
  18. oscura/analyzers/ml/signal_classifier.py +604 -0
  19. oscura/analyzers/packet/daq.py +246 -158
  20. oscura/analyzers/packet/parser.py +12 -1
  21. oscura/analyzers/packet/payload.py +50 -2110
  22. oscura/analyzers/packet/payload_analysis.py +361 -181
  23. oscura/analyzers/packet/payload_patterns.py +133 -70
  24. oscura/analyzers/packet/stream.py +84 -23
  25. oscura/analyzers/patterns/__init__.py +26 -5
  26. oscura/analyzers/patterns/anomaly_detection.py +908 -0
  27. oscura/analyzers/patterns/clustering.py +169 -108
  28. oscura/analyzers/patterns/clustering_optimized.py +227 -0
  29. oscura/analyzers/patterns/discovery.py +1 -1
  30. oscura/analyzers/patterns/matching.py +581 -197
  31. oscura/analyzers/patterns/pattern_mining.py +778 -0
  32. oscura/analyzers/patterns/periodic.py +121 -38
  33. oscura/analyzers/patterns/sequences.py +175 -78
  34. oscura/analyzers/power/conduction.py +1 -1
  35. oscura/analyzers/power/soa.py +6 -6
  36. oscura/analyzers/power/switching.py +250 -110
  37. oscura/analyzers/protocol/__init__.py +17 -1
  38. oscura/analyzers/protocols/base.py +6 -6
  39. oscura/analyzers/protocols/ble/__init__.py +38 -0
  40. oscura/analyzers/protocols/ble/analyzer.py +809 -0
  41. oscura/analyzers/protocols/ble/uuids.py +288 -0
  42. oscura/analyzers/protocols/can.py +257 -127
  43. oscura/analyzers/protocols/can_fd.py +107 -80
  44. oscura/analyzers/protocols/flexray.py +139 -80
  45. oscura/analyzers/protocols/hdlc.py +93 -58
  46. oscura/analyzers/protocols/i2c.py +247 -106
  47. oscura/analyzers/protocols/i2s.py +138 -86
  48. oscura/analyzers/protocols/industrial/__init__.py +40 -0
  49. oscura/analyzers/protocols/industrial/bacnet/__init__.py +33 -0
  50. oscura/analyzers/protocols/industrial/bacnet/analyzer.py +708 -0
  51. oscura/analyzers/protocols/industrial/bacnet/encoding.py +412 -0
  52. oscura/analyzers/protocols/industrial/bacnet/services.py +622 -0
  53. oscura/analyzers/protocols/industrial/ethercat/__init__.py +30 -0
  54. oscura/analyzers/protocols/industrial/ethercat/analyzer.py +474 -0
  55. oscura/analyzers/protocols/industrial/ethercat/mailbox.py +339 -0
  56. oscura/analyzers/protocols/industrial/ethercat/topology.py +166 -0
  57. oscura/analyzers/protocols/industrial/modbus/__init__.py +31 -0
  58. oscura/analyzers/protocols/industrial/modbus/analyzer.py +525 -0
  59. oscura/analyzers/protocols/industrial/modbus/crc.py +79 -0
  60. oscura/analyzers/protocols/industrial/modbus/functions.py +436 -0
  61. oscura/analyzers/protocols/industrial/opcua/__init__.py +21 -0
  62. oscura/analyzers/protocols/industrial/opcua/analyzer.py +552 -0
  63. oscura/analyzers/protocols/industrial/opcua/datatypes.py +446 -0
  64. oscura/analyzers/protocols/industrial/opcua/services.py +264 -0
  65. oscura/analyzers/protocols/industrial/profinet/__init__.py +23 -0
  66. oscura/analyzers/protocols/industrial/profinet/analyzer.py +441 -0
  67. oscura/analyzers/protocols/industrial/profinet/dcp.py +263 -0
  68. oscura/analyzers/protocols/industrial/profinet/ptcp.py +200 -0
  69. oscura/analyzers/protocols/jtag.py +180 -98
  70. oscura/analyzers/protocols/lin.py +219 -114
  71. oscura/analyzers/protocols/manchester.py +4 -4
  72. oscura/analyzers/protocols/onewire.py +253 -149
  73. oscura/analyzers/protocols/parallel_bus/__init__.py +20 -0
  74. oscura/analyzers/protocols/parallel_bus/centronics.py +92 -0
  75. oscura/analyzers/protocols/parallel_bus/gpib.py +137 -0
  76. oscura/analyzers/protocols/spi.py +192 -95
  77. oscura/analyzers/protocols/swd.py +321 -167
  78. oscura/analyzers/protocols/uart.py +267 -125
  79. oscura/analyzers/protocols/usb.py +235 -131
  80. oscura/analyzers/side_channel/power.py +17 -12
  81. oscura/analyzers/signal/__init__.py +15 -0
  82. oscura/analyzers/signal/timing_analysis.py +1086 -0
  83. oscura/analyzers/signal_integrity/__init__.py +4 -1
  84. oscura/analyzers/signal_integrity/sparams.py +2 -19
  85. oscura/analyzers/spectral/chunked.py +129 -60
  86. oscura/analyzers/spectral/chunked_fft.py +300 -94
  87. oscura/analyzers/spectral/chunked_wavelet.py +100 -80
  88. oscura/analyzers/statistical/checksum.py +376 -217
  89. oscura/analyzers/statistical/classification.py +229 -107
  90. oscura/analyzers/statistical/entropy.py +78 -53
  91. oscura/analyzers/statistics/correlation.py +407 -211
  92. oscura/analyzers/statistics/outliers.py +2 -2
  93. oscura/analyzers/statistics/streaming.py +30 -5
  94. oscura/analyzers/validation.py +216 -101
  95. oscura/analyzers/waveform/measurements.py +9 -0
  96. oscura/analyzers/waveform/measurements_with_uncertainty.py +31 -15
  97. oscura/analyzers/waveform/spectral.py +500 -228
  98. oscura/api/__init__.py +31 -5
  99. oscura/api/dsl/__init__.py +582 -0
  100. oscura/{dsl → api/dsl}/commands.py +43 -76
  101. oscura/{dsl → api/dsl}/interpreter.py +26 -51
  102. oscura/{dsl → api/dsl}/parser.py +107 -77
  103. oscura/{dsl → api/dsl}/repl.py +2 -2
  104. oscura/api/dsl.py +1 -1
  105. oscura/{integrations → api/integrations}/__init__.py +1 -1
  106. oscura/{integrations → api/integrations}/llm.py +201 -102
  107. oscura/api/operators.py +3 -3
  108. oscura/api/optimization.py +144 -30
  109. oscura/api/rest_server.py +921 -0
  110. oscura/api/server/__init__.py +17 -0
  111. oscura/api/server/dashboard.py +850 -0
  112. oscura/api/server/static/README.md +34 -0
  113. oscura/api/server/templates/base.html +181 -0
  114. oscura/api/server/templates/export.html +120 -0
  115. oscura/api/server/templates/home.html +284 -0
  116. oscura/api/server/templates/protocols.html +58 -0
  117. oscura/api/server/templates/reports.html +43 -0
  118. oscura/api/server/templates/session_detail.html +89 -0
  119. oscura/api/server/templates/sessions.html +83 -0
  120. oscura/api/server/templates/waveforms.html +73 -0
  121. oscura/automotive/__init__.py +8 -1
  122. oscura/automotive/can/__init__.py +10 -0
  123. oscura/automotive/can/checksum.py +3 -1
  124. oscura/automotive/can/dbc_generator.py +590 -0
  125. oscura/automotive/can/message_wrapper.py +121 -74
  126. oscura/automotive/can/patterns.py +98 -21
  127. oscura/automotive/can/session.py +292 -56
  128. oscura/automotive/can/state_machine.py +6 -3
  129. oscura/automotive/can/stimulus_response.py +97 -75
  130. oscura/automotive/dbc/__init__.py +10 -2
  131. oscura/automotive/dbc/generator.py +84 -56
  132. oscura/automotive/dbc/parser.py +6 -6
  133. oscura/automotive/dtc/data.json +17 -102
  134. oscura/automotive/dtc/database.py +2 -2
  135. oscura/automotive/flexray/__init__.py +31 -0
  136. oscura/automotive/flexray/analyzer.py +504 -0
  137. oscura/automotive/flexray/crc.py +185 -0
  138. oscura/automotive/flexray/fibex.py +449 -0
  139. oscura/automotive/j1939/__init__.py +45 -8
  140. oscura/automotive/j1939/analyzer.py +605 -0
  141. oscura/automotive/j1939/spns.py +326 -0
  142. oscura/automotive/j1939/transport.py +306 -0
  143. oscura/automotive/lin/__init__.py +47 -0
  144. oscura/automotive/lin/analyzer.py +612 -0
  145. oscura/automotive/loaders/blf.py +13 -2
  146. oscura/automotive/loaders/csv_can.py +143 -72
  147. oscura/automotive/loaders/dispatcher.py +50 -2
  148. oscura/automotive/loaders/mdf.py +86 -45
  149. oscura/automotive/loaders/pcap.py +111 -61
  150. oscura/automotive/uds/__init__.py +4 -0
  151. oscura/automotive/uds/analyzer.py +725 -0
  152. oscura/automotive/uds/decoder.py +140 -58
  153. oscura/automotive/uds/models.py +7 -1
  154. oscura/automotive/visualization.py +1 -1
  155. oscura/cli/analyze.py +348 -0
  156. oscura/cli/batch.py +142 -122
  157. oscura/cli/benchmark.py +275 -0
  158. oscura/cli/characterize.py +137 -82
  159. oscura/cli/compare.py +224 -131
  160. oscura/cli/completion.py +250 -0
  161. oscura/cli/config_cmd.py +361 -0
  162. oscura/cli/decode.py +164 -87
  163. oscura/cli/export.py +286 -0
  164. oscura/cli/main.py +115 -31
  165. oscura/{onboarding → cli/onboarding}/__init__.py +3 -3
  166. oscura/{onboarding → cli/onboarding}/help.py +80 -58
  167. oscura/{onboarding → cli/onboarding}/tutorials.py +97 -72
  168. oscura/{onboarding → cli/onboarding}/wizard.py +55 -36
  169. oscura/cli/progress.py +147 -0
  170. oscura/cli/shell.py +157 -135
  171. oscura/cli/validate_cmd.py +204 -0
  172. oscura/cli/visualize.py +158 -0
  173. oscura/convenience.py +125 -79
  174. oscura/core/__init__.py +4 -2
  175. oscura/core/backend_selector.py +3 -3
  176. oscura/core/cache.py +126 -15
  177. oscura/core/cancellation.py +1 -1
  178. oscura/{config → core/config}/__init__.py +20 -11
  179. oscura/{config → core/config}/defaults.py +1 -1
  180. oscura/{config → core/config}/loader.py +7 -5
  181. oscura/{config → core/config}/memory.py +5 -5
  182. oscura/{config → core/config}/migration.py +1 -1
  183. oscura/{config → core/config}/pipeline.py +99 -23
  184. oscura/{config → core/config}/preferences.py +1 -1
  185. oscura/{config → core/config}/protocol.py +3 -3
  186. oscura/{config → core/config}/schema.py +426 -272
  187. oscura/{config → core/config}/settings.py +1 -1
  188. oscura/{config → core/config}/thresholds.py +195 -153
  189. oscura/core/correlation.py +5 -6
  190. oscura/core/cross_domain.py +0 -2
  191. oscura/core/debug.py +9 -5
  192. oscura/{extensibility → core/extensibility}/docs.py +158 -70
  193. oscura/{extensibility → core/extensibility}/extensions.py +160 -76
  194. oscura/{extensibility → core/extensibility}/logging.py +1 -1
  195. oscura/{extensibility → core/extensibility}/measurements.py +1 -1
  196. oscura/{extensibility → core/extensibility}/plugins.py +1 -1
  197. oscura/{extensibility → core/extensibility}/templates.py +73 -3
  198. oscura/{extensibility → core/extensibility}/validation.py +1 -1
  199. oscura/core/gpu_backend.py +11 -7
  200. oscura/core/log_query.py +101 -11
  201. oscura/core/logging.py +126 -54
  202. oscura/core/logging_advanced.py +5 -5
  203. oscura/core/memory_limits.py +108 -70
  204. oscura/core/memory_monitor.py +2 -2
  205. oscura/core/memory_progress.py +7 -7
  206. oscura/core/memory_warnings.py +1 -1
  207. oscura/core/numba_backend.py +13 -13
  208. oscura/{plugins → core/plugins}/__init__.py +9 -9
  209. oscura/{plugins → core/plugins}/base.py +7 -7
  210. oscura/{plugins → core/plugins}/cli.py +3 -3
  211. oscura/{plugins → core/plugins}/discovery.py +186 -106
  212. oscura/{plugins → core/plugins}/lifecycle.py +1 -1
  213. oscura/{plugins → core/plugins}/manager.py +7 -7
  214. oscura/{plugins → core/plugins}/registry.py +3 -3
  215. oscura/{plugins → core/plugins}/versioning.py +1 -1
  216. oscura/core/progress.py +16 -1
  217. oscura/core/provenance.py +8 -2
  218. oscura/{schemas → core/schemas}/__init__.py +2 -2
  219. oscura/{schemas → core/schemas}/device_mapping.json +2 -8
  220. oscura/{schemas → core/schemas}/packet_format.json +4 -24
  221. oscura/{schemas → core/schemas}/protocol_definition.json +2 -12
  222. oscura/core/types.py +4 -0
  223. oscura/core/uncertainty.py +3 -3
  224. oscura/correlation/__init__.py +52 -0
  225. oscura/correlation/multi_protocol.py +811 -0
  226. oscura/discovery/auto_decoder.py +117 -35
  227. oscura/discovery/comparison.py +191 -86
  228. oscura/discovery/quality_validator.py +155 -68
  229. oscura/discovery/signal_detector.py +196 -79
  230. oscura/export/__init__.py +18 -8
  231. oscura/export/kaitai_struct.py +513 -0
  232. oscura/export/scapy_layer.py +801 -0
  233. oscura/export/wireshark/generator.py +1 -1
  234. oscura/export/wireshark/templates/dissector.lua.j2 +2 -2
  235. oscura/export/wireshark_dissector.py +746 -0
  236. oscura/guidance/wizard.py +207 -111
  237. oscura/hardware/__init__.py +19 -0
  238. oscura/{acquisition → hardware/acquisition}/__init__.py +4 -4
  239. oscura/{acquisition → hardware/acquisition}/file.py +2 -2
  240. oscura/{acquisition → hardware/acquisition}/hardware.py +7 -7
  241. oscura/{acquisition → hardware/acquisition}/saleae.py +15 -12
  242. oscura/{acquisition → hardware/acquisition}/socketcan.py +1 -1
  243. oscura/{acquisition → hardware/acquisition}/streaming.py +2 -2
  244. oscura/{acquisition → hardware/acquisition}/synthetic.py +3 -3
  245. oscura/{acquisition → hardware/acquisition}/visa.py +33 -11
  246. oscura/hardware/firmware/__init__.py +29 -0
  247. oscura/hardware/firmware/pattern_recognition.py +874 -0
  248. oscura/hardware/hal_detector.py +736 -0
  249. oscura/hardware/security/__init__.py +37 -0
  250. oscura/hardware/security/side_channel_detector.py +1126 -0
  251. oscura/inference/__init__.py +4 -0
  252. oscura/inference/active_learning/observation_table.py +4 -1
  253. oscura/inference/alignment.py +216 -123
  254. oscura/inference/bayesian.py +113 -33
  255. oscura/inference/crc_reverse.py +101 -55
  256. oscura/inference/logic.py +6 -2
  257. oscura/inference/message_format.py +342 -183
  258. oscura/inference/protocol.py +95 -44
  259. oscura/inference/protocol_dsl.py +180 -82
  260. oscura/inference/signal_intelligence.py +1439 -706
  261. oscura/inference/spectral.py +99 -57
  262. oscura/inference/state_machine.py +810 -158
  263. oscura/inference/stream.py +270 -110
  264. oscura/iot/__init__.py +34 -0
  265. oscura/iot/coap/__init__.py +32 -0
  266. oscura/iot/coap/analyzer.py +668 -0
  267. oscura/iot/coap/options.py +212 -0
  268. oscura/iot/lorawan/__init__.py +21 -0
  269. oscura/iot/lorawan/crypto.py +206 -0
  270. oscura/iot/lorawan/decoder.py +801 -0
  271. oscura/iot/lorawan/mac_commands.py +341 -0
  272. oscura/iot/mqtt/__init__.py +27 -0
  273. oscura/iot/mqtt/analyzer.py +999 -0
  274. oscura/iot/mqtt/properties.py +315 -0
  275. oscura/iot/zigbee/__init__.py +31 -0
  276. oscura/iot/zigbee/analyzer.py +615 -0
  277. oscura/iot/zigbee/security.py +153 -0
  278. oscura/iot/zigbee/zcl.py +349 -0
  279. oscura/jupyter/display.py +125 -45
  280. oscura/{exploratory → jupyter/exploratory}/__init__.py +8 -8
  281. oscura/{exploratory → jupyter/exploratory}/error_recovery.py +298 -141
  282. oscura/jupyter/exploratory/fuzzy.py +746 -0
  283. oscura/{exploratory → jupyter/exploratory}/fuzzy_advanced.py +258 -100
  284. oscura/{exploratory → jupyter/exploratory}/legacy.py +464 -242
  285. oscura/{exploratory → jupyter/exploratory}/parse.py +167 -145
  286. oscura/{exploratory → jupyter/exploratory}/recovery.py +119 -87
  287. oscura/jupyter/exploratory/sync.py +612 -0
  288. oscura/{exploratory → jupyter/exploratory}/unknown.py +299 -176
  289. oscura/jupyter/magic.py +4 -4
  290. oscura/{ui → jupyter/ui}/__init__.py +2 -2
  291. oscura/{ui → jupyter/ui}/formatters.py +3 -3
  292. oscura/{ui → jupyter/ui}/progressive_display.py +153 -82
  293. oscura/loaders/__init__.py +183 -67
  294. oscura/loaders/binary.py +88 -1
  295. oscura/loaders/chipwhisperer.py +153 -137
  296. oscura/loaders/configurable.py +208 -86
  297. oscura/loaders/csv_loader.py +458 -215
  298. oscura/loaders/hdf5_loader.py +278 -119
  299. oscura/loaders/lazy.py +87 -54
  300. oscura/loaders/mmap_loader.py +1 -1
  301. oscura/loaders/numpy_loader.py +253 -116
  302. oscura/loaders/pcap.py +226 -151
  303. oscura/loaders/rigol.py +110 -49
  304. oscura/loaders/sigrok.py +201 -78
  305. oscura/loaders/tdms.py +81 -58
  306. oscura/loaders/tektronix.py +291 -174
  307. oscura/loaders/touchstone.py +182 -87
  308. oscura/loaders/tss.py +456 -0
  309. oscura/loaders/vcd.py +215 -117
  310. oscura/loaders/wav.py +155 -68
  311. oscura/reporting/__init__.py +9 -0
  312. oscura/reporting/analyze.py +352 -146
  313. oscura/reporting/argument_preparer.py +69 -14
  314. oscura/reporting/auto_report.py +97 -61
  315. oscura/reporting/batch.py +131 -58
  316. oscura/reporting/chart_selection.py +57 -45
  317. oscura/reporting/comparison.py +63 -17
  318. oscura/reporting/content/executive.py +76 -24
  319. oscura/reporting/core_formats/multi_format.py +11 -8
  320. oscura/reporting/engine.py +312 -158
  321. oscura/reporting/enhanced_reports.py +949 -0
  322. oscura/reporting/export.py +86 -43
  323. oscura/reporting/formatting/numbers.py +69 -42
  324. oscura/reporting/html.py +139 -58
  325. oscura/reporting/index.py +137 -65
  326. oscura/reporting/output.py +158 -67
  327. oscura/reporting/pdf.py +67 -102
  328. oscura/reporting/plots.py +191 -112
  329. oscura/reporting/sections.py +88 -47
  330. oscura/reporting/standards.py +104 -61
  331. oscura/reporting/summary_generator.py +75 -55
  332. oscura/reporting/tables.py +138 -54
  333. oscura/reporting/templates/enhanced/protocol_re.html +525 -0
  334. oscura/sessions/__init__.py +14 -23
  335. oscura/sessions/base.py +3 -3
  336. oscura/sessions/blackbox.py +106 -10
  337. oscura/sessions/generic.py +2 -2
  338. oscura/sessions/legacy.py +783 -0
  339. oscura/side_channel/__init__.py +63 -0
  340. oscura/side_channel/dpa.py +1025 -0
  341. oscura/utils/__init__.py +15 -1
  342. oscura/utils/bitwise.py +118 -0
  343. oscura/{builders → utils/builders}/__init__.py +1 -1
  344. oscura/{comparison → utils/comparison}/__init__.py +6 -6
  345. oscura/{comparison → utils/comparison}/compare.py +202 -101
  346. oscura/{comparison → utils/comparison}/golden.py +83 -63
  347. oscura/{comparison → utils/comparison}/limits.py +313 -89
  348. oscura/{comparison → utils/comparison}/mask.py +151 -45
  349. oscura/{comparison → utils/comparison}/trace_diff.py +1 -1
  350. oscura/{comparison → utils/comparison}/visualization.py +147 -89
  351. oscura/{component → utils/component}/__init__.py +3 -3
  352. oscura/{component → utils/component}/impedance.py +122 -58
  353. oscura/{component → utils/component}/reactive.py +165 -168
  354. oscura/{component → utils/component}/transmission_line.py +3 -3
  355. oscura/{filtering → utils/filtering}/__init__.py +6 -6
  356. oscura/{filtering → utils/filtering}/base.py +1 -1
  357. oscura/{filtering → utils/filtering}/convenience.py +2 -2
  358. oscura/{filtering → utils/filtering}/design.py +169 -93
  359. oscura/{filtering → utils/filtering}/filters.py +2 -2
  360. oscura/{filtering → utils/filtering}/introspection.py +2 -2
  361. oscura/utils/geometry.py +31 -0
  362. oscura/utils/imports.py +184 -0
  363. oscura/utils/lazy.py +1 -1
  364. oscura/{math → utils/math}/__init__.py +2 -2
  365. oscura/{math → utils/math}/arithmetic.py +114 -48
  366. oscura/{math → utils/math}/interpolation.py +139 -106
  367. oscura/utils/memory.py +129 -66
  368. oscura/utils/memory_advanced.py +92 -9
  369. oscura/utils/memory_extensions.py +10 -8
  370. oscura/{optimization → utils/optimization}/__init__.py +1 -1
  371. oscura/{optimization → utils/optimization}/search.py +2 -2
  372. oscura/utils/performance/__init__.py +58 -0
  373. oscura/utils/performance/caching.py +889 -0
  374. oscura/utils/performance/lsh_clustering.py +333 -0
  375. oscura/utils/performance/memory_optimizer.py +699 -0
  376. oscura/utils/performance/optimizations.py +675 -0
  377. oscura/utils/performance/parallel.py +654 -0
  378. oscura/utils/performance/profiling.py +661 -0
  379. oscura/{pipeline → utils/pipeline}/base.py +1 -1
  380. oscura/{pipeline → utils/pipeline}/composition.py +1 -1
  381. oscura/{pipeline → utils/pipeline}/parallel.py +3 -2
  382. oscura/{pipeline → utils/pipeline}/pipeline.py +1 -1
  383. oscura/{pipeline → utils/pipeline}/reverse_engineering.py +412 -221
  384. oscura/{search → utils/search}/__init__.py +3 -3
  385. oscura/{search → utils/search}/anomaly.py +188 -58
  386. oscura/utils/search/context.py +294 -0
  387. oscura/{search → utils/search}/pattern.py +138 -10
  388. oscura/utils/serial.py +51 -0
  389. oscura/utils/storage/__init__.py +61 -0
  390. oscura/utils/storage/database.py +1166 -0
  391. oscura/{streaming → utils/streaming}/chunked.py +302 -143
  392. oscura/{streaming → utils/streaming}/progressive.py +1 -1
  393. oscura/{streaming → utils/streaming}/realtime.py +3 -2
  394. oscura/{triggering → utils/triggering}/__init__.py +6 -6
  395. oscura/{triggering → utils/triggering}/base.py +6 -6
  396. oscura/{triggering → utils/triggering}/edge.py +2 -2
  397. oscura/{triggering → utils/triggering}/pattern.py +2 -2
  398. oscura/{triggering → utils/triggering}/pulse.py +115 -74
  399. oscura/{triggering → utils/triggering}/window.py +2 -2
  400. oscura/utils/validation.py +32 -0
  401. oscura/validation/__init__.py +121 -0
  402. oscura/{compliance → validation/compliance}/__init__.py +5 -5
  403. oscura/{compliance → validation/compliance}/advanced.py +5 -5
  404. oscura/{compliance → validation/compliance}/masks.py +1 -1
  405. oscura/{compliance → validation/compliance}/reporting.py +127 -53
  406. oscura/{compliance → validation/compliance}/testing.py +114 -52
  407. oscura/validation/compliance_tests.py +915 -0
  408. oscura/validation/fuzzer.py +990 -0
  409. oscura/validation/grammar_tests.py +596 -0
  410. oscura/validation/grammar_validator.py +904 -0
  411. oscura/validation/hil_testing.py +977 -0
  412. oscura/{quality → validation/quality}/__init__.py +4 -4
  413. oscura/{quality → validation/quality}/ensemble.py +251 -171
  414. oscura/{quality → validation/quality}/explainer.py +3 -3
  415. oscura/{quality → validation/quality}/scoring.py +1 -1
  416. oscura/{quality → validation/quality}/warnings.py +4 -4
  417. oscura/validation/regression_suite.py +808 -0
  418. oscura/validation/replay.py +788 -0
  419. oscura/{testing → validation/testing}/__init__.py +2 -2
  420. oscura/{testing → validation/testing}/synthetic.py +5 -5
  421. oscura/visualization/__init__.py +9 -0
  422. oscura/visualization/accessibility.py +1 -1
  423. oscura/visualization/annotations.py +64 -67
  424. oscura/visualization/colors.py +7 -7
  425. oscura/visualization/digital.py +180 -81
  426. oscura/visualization/eye.py +236 -85
  427. oscura/visualization/interactive.py +320 -143
  428. oscura/visualization/jitter.py +587 -247
  429. oscura/visualization/layout.py +169 -134
  430. oscura/visualization/optimization.py +103 -52
  431. oscura/visualization/palettes.py +1 -1
  432. oscura/visualization/power.py +427 -211
  433. oscura/visualization/power_extended.py +626 -297
  434. oscura/visualization/presets.py +2 -0
  435. oscura/visualization/protocols.py +495 -181
  436. oscura/visualization/render.py +79 -63
  437. oscura/visualization/reverse_engineering.py +171 -124
  438. oscura/visualization/signal_integrity.py +460 -279
  439. oscura/visualization/specialized.py +190 -100
  440. oscura/visualization/spectral.py +670 -255
  441. oscura/visualization/thumbnails.py +166 -137
  442. oscura/visualization/waveform.py +150 -63
  443. oscura/workflows/__init__.py +3 -0
  444. oscura/{batch → workflows/batch}/__init__.py +5 -5
  445. oscura/{batch → workflows/batch}/advanced.py +150 -75
  446. oscura/workflows/batch/aggregate.py +531 -0
  447. oscura/workflows/batch/analyze.py +236 -0
  448. oscura/{batch → workflows/batch}/logging.py +2 -2
  449. oscura/{batch → workflows/batch}/metrics.py +1 -1
  450. oscura/workflows/complete_re.py +1144 -0
  451. oscura/workflows/compliance.py +44 -54
  452. oscura/workflows/digital.py +197 -51
  453. oscura/workflows/legacy/__init__.py +12 -0
  454. oscura/{workflow → workflows/legacy}/dag.py +4 -1
  455. oscura/workflows/multi_trace.py +9 -9
  456. oscura/workflows/power.py +42 -62
  457. oscura/workflows/protocol.py +82 -49
  458. oscura/workflows/reverse_engineering.py +351 -150
  459. oscura/workflows/signal_integrity.py +157 -82
  460. oscura-0.7.0.dist-info/METADATA +661 -0
  461. oscura-0.7.0.dist-info/RECORD +591 -0
  462. oscura/batch/aggregate.py +0 -300
  463. oscura/batch/analyze.py +0 -139
  464. oscura/dsl/__init__.py +0 -73
  465. oscura/exceptions.py +0 -59
  466. oscura/exploratory/fuzzy.py +0 -513
  467. oscura/exploratory/sync.py +0 -384
  468. oscura/exporters/__init__.py +0 -94
  469. oscura/exporters/csv.py +0 -303
  470. oscura/exporters/exporters.py +0 -44
  471. oscura/exporters/hdf5.py +0 -217
  472. oscura/exporters/html_export.py +0 -701
  473. oscura/exporters/json_export.py +0 -291
  474. oscura/exporters/markdown_export.py +0 -367
  475. oscura/exporters/matlab_export.py +0 -354
  476. oscura/exporters/npz_export.py +0 -219
  477. oscura/exporters/spice_export.py +0 -210
  478. oscura/search/context.py +0 -149
  479. oscura/session/__init__.py +0 -34
  480. oscura/session/annotations.py +0 -289
  481. oscura/session/history.py +0 -313
  482. oscura/session/session.py +0 -520
  483. oscura/workflow/__init__.py +0 -13
  484. oscura-0.5.1.dist-info/METADATA +0 -583
  485. oscura-0.5.1.dist-info/RECORD +0 -481
  486. /oscura/core/{config.py → config/legacy.py} +0 -0
  487. /oscura/{extensibility → core/extensibility}/__init__.py +0 -0
  488. /oscura/{extensibility → core/extensibility}/registry.py +0 -0
  489. /oscura/{plugins → core/plugins}/isolation.py +0 -0
  490. /oscura/{schemas → core/schemas}/bus_configuration.json +0 -0
  491. /oscura/{builders → utils/builders}/signal_builder.py +0 -0
  492. /oscura/{optimization → utils/optimization}/parallel.py +0 -0
  493. /oscura/{pipeline → utils/pipeline}/__init__.py +0 -0
  494. /oscura/{streaming → utils/streaming}/__init__.py +0 -0
  495. {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/WHEEL +0 -0
  496. {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/entry_points.txt +0 -0
  497. {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,801 @@
1
+ """LoRaWAN protocol decoder with MAC layer parsing and payload decryption.
2
+
3
+ This module provides comprehensive LoRaWAN MAC frame decoding including:
4
+ - MAC header (MHDR) parsing
5
+ - Frame control (FCtrl) field parsing
6
+ - MAC command parsing from FOpts field
7
+ - Payload decryption using AES-128 CTR mode
8
+ - Message Integrity Code (MIC) verification
9
+
10
+ References:
11
+ LoRaWAN Specification 1.0.3: https://lora-alliance.org/resource_hub/lorawan-specification-v1-0-3/
12
+ Section 4 - MAC Message Formats
13
+ Section 4.3 - MAC Frame Payload Encryption
14
+ Section 4.4 - Message Integrity Code (MIC)
15
+
16
+ Example:
17
+ >>> from oscura.iot.lorawan import LoRaWANDecoder, LoRaWANKeys
18
+ >>> keys = LoRaWANKeys(
19
+ ... app_skey=bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"),
20
+ ... nwk_skey=bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"),
21
+ ... )
22
+ >>> decoder = LoRaWANDecoder(keys=keys)
23
+ >>> frame_bytes = bytes.fromhex("40...")
24
+ >>> frame = decoder.decode_frame(frame_bytes, timestamp=0.0)
25
+ >>> print(f"MType: {frame.mtype}, DevAddr: 0x{frame.dev_addr:08X}")
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, ClassVar, Literal
32
+
33
+ from oscura.iot.lorawan.mac_commands import parse_mac_commands
34
+
35
+
36
+ @dataclass
37
+ class LoRaWANKeys:
38
+ """LoRaWAN encryption keys.
39
+
40
+ Attributes:
41
+ app_skey: Application session key (16 bytes) for encrypting application data.
42
+ nwk_skey: Network session key (16 bytes) for MIC calculation.
43
+ app_key: Application key (16 bytes) for Join-accept decryption.
44
+
45
+ Example:
46
+ >>> keys = LoRaWANKeys(
47
+ ... app_skey=bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"),
48
+ ... nwk_skey=bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"),
49
+ ... )
50
+ """
51
+
52
+ app_skey: bytes | None = None
53
+ nwk_skey: bytes | None = None
54
+ app_key: bytes | None = None
55
+
56
+ def __post_init__(self) -> None:
57
+ """Validate key lengths."""
58
+ if self.app_skey is not None and len(self.app_skey) != 16:
59
+ msg = f"AppSKey must be 16 bytes, got {len(self.app_skey)}"
60
+ raise ValueError(msg)
61
+ if self.nwk_skey is not None and len(self.nwk_skey) != 16:
62
+ msg = f"NwkSKey must be 16 bytes, got {len(self.nwk_skey)}"
63
+ raise ValueError(msg)
64
+ if self.app_key is not None and len(self.app_key) != 16:
65
+ msg = f"AppKey must be 16 bytes, got {len(self.app_key)}"
66
+ raise ValueError(msg)
67
+
68
+
69
+ @dataclass
70
+ class LoRaWANFrame:
71
+ """LoRaWAN MAC frame representation.
72
+
73
+ Attributes:
74
+ timestamp: Frame timestamp in seconds.
75
+ mtype: Message type string (e.g., "Unconfirmed Data Up").
76
+ dev_addr: Device address (4 bytes, optional).
77
+ fctrl: Frame control flags dictionary.
78
+ fcnt: Frame counter.
79
+ fopts: Frame options (MAC commands).
80
+ fport: Application port number.
81
+ frm_payload: Encrypted or plaintext payload.
82
+ mic: Message Integrity Code (32-bit).
83
+ decrypted_payload: Decrypted payload if keys available.
84
+ parsed_mac_commands: Parsed MAC commands from FOpts.
85
+ mic_valid: Whether MIC verification passed (None if not checked).
86
+ errors: List of parsing/validation errors.
87
+
88
+ Example:
89
+ >>> frame = LoRaWANFrame(
90
+ ... timestamp=0.0,
91
+ ... mtype="Unconfirmed Data Up",
92
+ ... dev_addr=0x01020304,
93
+ ... fcnt=1,
94
+ ... )
95
+ """
96
+
97
+ timestamp: float
98
+ mtype: str
99
+ dev_addr: int | None = None
100
+ fctrl: dict[str, bool | int] | None = None
101
+ fcnt: int | None = None
102
+ fopts: bytes = b""
103
+ fport: int | None = None
104
+ frm_payload: bytes = b""
105
+ mic: int | None = None
106
+ decrypted_payload: bytes | None = None
107
+ parsed_mac_commands: list[dict[str, Any]] = field(default_factory=list)
108
+ mic_valid: bool | None = None
109
+ errors: list[str] = field(default_factory=list)
110
+
111
+
112
+ class LoRaWANDecoder:
113
+ """LoRaWAN protocol decoder with payload decryption.
114
+
115
+ Supports all LoRaWAN message types and provides MAC command parsing
116
+ and optional payload decryption when session keys are provided.
117
+
118
+ Attributes:
119
+ MTYPES: Message type lookup table.
120
+ MAJOR_VERSIONS: LoRaWAN version lookup table.
121
+
122
+ Example:
123
+ >>> decoder = LoRaWANDecoder()
124
+ >>> frame = decoder.decode_frame(raw_bytes, timestamp=0.0)
125
+ >>> print(f"MType: {frame.mtype}")
126
+ """
127
+
128
+ # Message types (MType field in MHDR)
129
+ MTYPES: ClassVar[dict[int, str]] = {
130
+ 0x00: "Join-request",
131
+ 0x01: "Join-accept",
132
+ 0x02: "Unconfirmed Data Up",
133
+ 0x03: "Unconfirmed Data Down",
134
+ 0x04: "Confirmed Data Up",
135
+ 0x05: "Confirmed Data Down",
136
+ 0x06: "RFU",
137
+ 0x07: "Proprietary",
138
+ }
139
+
140
+ # Major version
141
+ MAJOR_VERSIONS: ClassVar[dict[int, str]] = {
142
+ 0x00: "LoRaWAN R1",
143
+ }
144
+
145
+ def __init__(self, keys: LoRaWANKeys | None = None) -> None:
146
+ """Initialize LoRaWAN decoder with optional encryption keys.
147
+
148
+ Args:
149
+ keys: LoRaWAN encryption keys for payload decryption and MIC verification.
150
+
151
+ Example:
152
+ >>> keys = LoRaWANKeys(app_skey=bytes(16), nwk_skey=bytes(16))
153
+ >>> decoder = LoRaWANDecoder(keys=keys)
154
+ """
155
+ self.keys = keys or LoRaWANKeys()
156
+ self.frames: list[LoRaWANFrame] = []
157
+
158
+ def set_keys(self, keys: LoRaWANKeys) -> None:
159
+ """Set encryption keys for payload decryption.
160
+
161
+ Args:
162
+ keys: LoRaWAN encryption keys.
163
+
164
+ Example:
165
+ >>> decoder.set_keys(LoRaWANKeys(app_skey=bytes(16)))
166
+ """
167
+ self.keys = keys
168
+
169
+ def decode_frame(self, data: bytes, timestamp: float = 0.0) -> LoRaWANFrame:
170
+ """Decode LoRaWAN MAC frame.
171
+
172
+ Frame Format:
173
+ MHDR (1 byte) | MACPayload (variable) | MIC (4 bytes)
174
+
175
+ Data Frame MACPayload:
176
+ FHDR | FPort (optional) | FRMPayload (optional)
177
+
178
+ FHDR Format:
179
+ DevAddr (4 bytes) | FCtrl (1 byte) | FCnt (2 bytes) | FOpts (0-15 bytes)
180
+
181
+ Args:
182
+ data: Raw frame bytes.
183
+ timestamp: Frame timestamp in seconds.
184
+
185
+ Returns:
186
+ Decoded LoRaWAN frame.
187
+
188
+ Raises:
189
+ ValueError: If frame is too short or malformed.
190
+
191
+ Example:
192
+ >>> frame = decoder.decode_frame(bytes.fromhex("40..."), timestamp=1.0)
193
+ >>> print(f"DevAddr: 0x{frame.dev_addr:08X}")
194
+ """
195
+ errors: list[str] = []
196
+
197
+ # Minimum frame: MHDR (1) + MIC (4) = 5 bytes
198
+ if len(data) < 5:
199
+ msg = f"Frame too short: {len(data)} bytes (minimum 5)"
200
+ raise ValueError(msg)
201
+
202
+ # Parse frame components
203
+ mtype, mac_payload, mic = self._extract_frame_components(data)
204
+
205
+ # Route to specific decoder based on message type
206
+ return self._route_frame_decoder(mtype, mac_payload, mic, timestamp, data, errors)
207
+
208
+ def _extract_frame_components(self, data: bytes) -> tuple[str, bytes, int]:
209
+ """Extract MHDR, MACPayload, and MIC from frame.
210
+
211
+ Args:
212
+ data: Raw frame bytes.
213
+
214
+ Returns:
215
+ Tuple of (mtype, mac_payload, mic).
216
+ """
217
+ # Parse MHDR (MAC Header)
218
+ mhdr = data[0]
219
+ mtype_val, rfu, major = self._parse_mhdr(mhdr)
220
+ mtype = self.MTYPES.get(mtype_val, f"Unknown_0x{mtype_val:02X}")
221
+
222
+ # Extract MIC (last 4 bytes)
223
+ mic = int.from_bytes(data[-4:], "little")
224
+
225
+ # Extract MACPayload (between MHDR and MIC)
226
+ mac_payload = data[1:-4]
227
+
228
+ return mtype, mac_payload, mic
229
+
230
+ def _route_frame_decoder(
231
+ self,
232
+ mtype: str,
233
+ mac_payload: bytes,
234
+ mic: int,
235
+ timestamp: float,
236
+ full_frame: bytes,
237
+ errors: list[str],
238
+ ) -> LoRaWANFrame:
239
+ """Route frame to appropriate decoder based on message type.
240
+
241
+ Args:
242
+ mtype: Message type string.
243
+ mac_payload: MACPayload bytes.
244
+ mic: Message Integrity Code.
245
+ timestamp: Frame timestamp.
246
+ full_frame: Complete frame including MHDR and MIC.
247
+ errors: Error list.
248
+
249
+ Returns:
250
+ Decoded LoRaWAN frame.
251
+ """
252
+ # Data frames (uplink/downlink)
253
+ if mtype in (
254
+ "Unconfirmed Data Up",
255
+ "Unconfirmed Data Down",
256
+ "Confirmed Data Up",
257
+ "Confirmed Data Down",
258
+ ):
259
+ return self._decode_data_frame(mtype, mac_payload, mic, timestamp, full_frame, errors)
260
+
261
+ # Join frames
262
+ if mtype == "Join-request":
263
+ return self._decode_join_request(mac_payload, mic, timestamp, errors)
264
+ if mtype == "Join-accept":
265
+ return self._decode_join_accept(mac_payload, mic, timestamp, errors)
266
+
267
+ # Unknown or proprietary frame
268
+ frame = LoRaWANFrame(
269
+ timestamp=timestamp,
270
+ mtype=mtype,
271
+ frm_payload=mac_payload,
272
+ mic=mic,
273
+ errors=errors,
274
+ )
275
+ return frame
276
+
277
+ def _parse_mhdr(self, mhdr: int) -> tuple[int, int, int]:
278
+ """Parse MAC header (MHDR) into MType, RFU, Major.
279
+
280
+ MHDR format (1 byte):
281
+ Bits 7-5: MType (message type)
282
+ Bits 4-2: RFU (reserved for future use)
283
+ Bits 1-0: Major (LoRaWAN version)
284
+
285
+ Args:
286
+ mhdr: MHDR byte value.
287
+
288
+ Returns:
289
+ Tuple of (mtype, rfu, major).
290
+
291
+ Example:
292
+ >>> mtype, rfu, major = decoder._parse_mhdr(0x40)
293
+ >>> mtype
294
+ 2
295
+ """
296
+ mtype = (mhdr >> 5) & 0x07
297
+ rfu = (mhdr >> 2) & 0x07
298
+ major = mhdr & 0x03
299
+ return mtype, rfu, major
300
+
301
+ def _parse_fctrl(self, fctrl: int, direction: Literal["up", "down"]) -> dict[str, bool | int]:
302
+ """Parse frame control byte.
303
+
304
+ FCtrl format (1 byte):
305
+ Uplink:
306
+ Bit 7: ADR (Adaptive Data Rate)
307
+ Bit 6: ADRACKReq
308
+ Bit 5: ACK
309
+ Bit 4: ClassB
310
+ Bits 3-0: FOptsLen
311
+ Downlink:
312
+ Bit 7: ADR
313
+ Bit 6: RFU
314
+ Bit 5: ACK
315
+ Bit 4: FPending
316
+ Bits 3-0: FOptsLen
317
+
318
+ Args:
319
+ fctrl: FCtrl byte value.
320
+ direction: "up" for uplink, "down" for downlink.
321
+
322
+ Returns:
323
+ Dictionary of frame control flags.
324
+
325
+ Example:
326
+ >>> flags = decoder._parse_fctrl(0x80, "up")
327
+ >>> flags["adr"]
328
+ True
329
+ """
330
+ result: dict[str, bool | int] = {
331
+ "adr": bool(fctrl & 0x80),
332
+ "ack": bool(fctrl & 0x20),
333
+ "fopts_len": fctrl & 0x0F,
334
+ }
335
+
336
+ if direction == "up":
337
+ result["adr_ack_req"] = bool(fctrl & 0x40)
338
+ result["class_b"] = bool(fctrl & 0x10)
339
+ else: # downlink
340
+ result["fpending"] = bool(fctrl & 0x10)
341
+
342
+ return result
343
+
344
+ def _decode_data_frame(
345
+ self,
346
+ mtype: str,
347
+ mac_payload: bytes,
348
+ mic: int,
349
+ timestamp: float,
350
+ full_frame: bytes,
351
+ errors: list[str],
352
+ ) -> LoRaWANFrame:
353
+ """Decode data frame (unconfirmed or confirmed).
354
+
355
+ Args:
356
+ mtype: Message type string.
357
+ mac_payload: MACPayload bytes (FHDR | FPort | FRMPayload).
358
+ mic: Message Integrity Code.
359
+ timestamp: Frame timestamp.
360
+ full_frame: Complete frame including MHDR and MIC.
361
+ errors: Error list to append to.
362
+
363
+ Returns:
364
+ Decoded LoRaWAN frame.
365
+
366
+ Example:
367
+ >>> decoder = LoRaWANDecoder()
368
+ >>> frame = decoder._decode_data_frame(
369
+ ... "Unconfirmed Data Up", b"\\x01\\x02\\x03\\x04...", 0x12345678, 0.0, b"...", []
370
+ ... )
371
+ """
372
+ if len(mac_payload) < 7: # Minimum FHDR length
373
+ errors.append("MACPayload too short for data frame")
374
+ return LoRaWANFrame(
375
+ timestamp=timestamp,
376
+ mtype=mtype,
377
+ mic=mic,
378
+ errors=errors,
379
+ )
380
+
381
+ direction: Literal["up", "down"] = "up" if "Up" in mtype else "down"
382
+
383
+ # Parse all frame components
384
+ frame_data = self._parse_data_frame_components(
385
+ mac_payload, mtype, full_frame, mic, direction, errors
386
+ )
387
+
388
+ # Create and store frame
389
+ frame = LoRaWANFrame(
390
+ timestamp=timestamp,
391
+ mtype=mtype,
392
+ dev_addr=frame_data["dev_addr"],
393
+ fctrl=frame_data["fctrl"],
394
+ fcnt=frame_data["fcnt"],
395
+ fopts=frame_data["fopts"],
396
+ fport=frame_data["fport"],
397
+ frm_payload=frame_data["frm_payload"],
398
+ mic=mic,
399
+ decrypted_payload=frame_data["decrypted_payload"],
400
+ parsed_mac_commands=frame_data["parsed_mac_commands"],
401
+ mic_valid=frame_data["mic_valid"],
402
+ errors=errors,
403
+ )
404
+
405
+ self.frames.append(frame)
406
+ return frame
407
+
408
+ def _parse_data_frame_components(
409
+ self,
410
+ mac_payload: bytes,
411
+ mtype: str,
412
+ full_frame: bytes,
413
+ mic: int,
414
+ direction: Literal["up", "down"],
415
+ errors: list[str],
416
+ ) -> dict[str, Any]:
417
+ """Parse all components of data frame.
418
+
419
+ Args:
420
+ mac_payload: MACPayload bytes.
421
+ mtype: Message type string.
422
+ full_frame: Complete frame.
423
+ mic: Message Integrity Code.
424
+ direction: Frame direction.
425
+ errors: Error list.
426
+
427
+ Returns:
428
+ Dictionary of parsed frame components.
429
+ """
430
+ # Parse frame header
431
+ dev_addr, fctrl, fcnt, fopts = self._parse_fhdr(mac_payload, mtype, errors)
432
+
433
+ # Extract port and payload
434
+ fport, frm_payload = self._extract_port_and_payload(mac_payload, fopts)
435
+
436
+ # Parse MAC commands
437
+ parsed_mac_commands = self._parse_fopts_mac_commands(fopts, direction, errors)
438
+
439
+ # Decrypt payload
440
+ decrypted_payload = self._decrypt_frm_payload(
441
+ fport, frm_payload, dev_addr, fcnt, direction, errors
442
+ )
443
+
444
+ # Verify MIC
445
+ mic_valid = self._verify_frame_mic(full_frame, mic, dev_addr, fcnt, direction, errors)
446
+
447
+ return {
448
+ "dev_addr": dev_addr,
449
+ "fctrl": fctrl,
450
+ "fcnt": fcnt,
451
+ "fopts": fopts,
452
+ "fport": fport,
453
+ "frm_payload": frm_payload,
454
+ "decrypted_payload": decrypted_payload,
455
+ "parsed_mac_commands": parsed_mac_commands,
456
+ "mic_valid": mic_valid,
457
+ }
458
+
459
+ def _parse_fhdr(
460
+ self,
461
+ mac_payload: bytes,
462
+ mtype: str,
463
+ errors: list[str],
464
+ ) -> tuple[int, dict[str, bool | int], int, bytes]:
465
+ """Parse frame header (FHDR) fields.
466
+
467
+ Args:
468
+ mac_payload: MACPayload bytes.
469
+ mtype: Message type string.
470
+ errors: Error list to append to.
471
+
472
+ Returns:
473
+ Tuple of (DevAddr, FCtrl, FCnt, FOpts).
474
+ """
475
+ dev_addr = int.from_bytes(mac_payload[0:4], "little")
476
+ fctrl_byte = mac_payload[4]
477
+ fcnt = int.from_bytes(mac_payload[5:7], "little")
478
+
479
+ direction: Literal["up", "down"] = "up" if "Up" in mtype else "down"
480
+ fctrl = self._parse_fctrl(fctrl_byte, direction)
481
+
482
+ fopts_len = fctrl["fopts_len"]
483
+ if fopts_len > 15:
484
+ errors.append(f"Invalid FOpts length: {fopts_len}")
485
+ fopts_len = 0
486
+
487
+ fopts = mac_payload[7 : 7 + fopts_len] if fopts_len > 0 else b""
488
+ return dev_addr, fctrl, fcnt, fopts
489
+
490
+ def _extract_port_and_payload(
491
+ self, mac_payload: bytes, fopts: bytes
492
+ ) -> tuple[int | None, bytes]:
493
+ """Extract FPort and FRMPayload from MAC payload.
494
+
495
+ Args:
496
+ mac_payload: MACPayload bytes.
497
+ fopts: FOpts bytes (for calculating offset).
498
+
499
+ Returns:
500
+ Tuple of (FPort, FRMPayload). FPort is None if not present.
501
+ """
502
+ offset = 7 + len(fopts)
503
+ fport = None
504
+ frm_payload = b""
505
+
506
+ if offset < len(mac_payload):
507
+ fport = mac_payload[offset]
508
+ frm_payload = mac_payload[offset + 1 :] if offset + 1 < len(mac_payload) else b""
509
+
510
+ return fport, frm_payload
511
+
512
+ def _parse_fopts_mac_commands(
513
+ self,
514
+ fopts: bytes,
515
+ direction: Literal["up", "down"],
516
+ errors: list[str],
517
+ ) -> list[dict[str, Any]]:
518
+ """Parse MAC commands from FOpts field.
519
+
520
+ Args:
521
+ fopts: FOpts bytes.
522
+ direction: Message direction ("up" or "down").
523
+ errors: Error list to append to.
524
+
525
+ Returns:
526
+ List of parsed MAC command dictionaries.
527
+ """
528
+ if not fopts:
529
+ return []
530
+
531
+ try:
532
+ return parse_mac_commands(fopts, direction)
533
+ except Exception as exc:
534
+ errors.append(f"Failed to parse MAC commands: {exc}")
535
+ return []
536
+
537
+ def _decrypt_frm_payload(
538
+ self,
539
+ fport: int | None,
540
+ frm_payload: bytes,
541
+ dev_addr: int,
542
+ fcnt: int,
543
+ direction: Literal["up", "down"],
544
+ errors: list[str],
545
+ ) -> bytes | None:
546
+ """Decrypt FRMPayload using AES-128 CTR mode.
547
+
548
+ Args:
549
+ fport: FPort value.
550
+ frm_payload: Encrypted FRMPayload.
551
+ dev_addr: Device address.
552
+ fcnt: Frame counter.
553
+ direction: Message direction.
554
+ errors: Error list to append to.
555
+
556
+ Returns:
557
+ Decrypted payload bytes, or None if decryption not performed.
558
+ """
559
+ if fport is None or not frm_payload or not self.keys.app_skey:
560
+ return None
561
+
562
+ try:
563
+ from oscura.iot.lorawan.crypto import decrypt_payload
564
+
565
+ # Use AppSKey for FPort != 0, NwkSKey for FPort == 0
566
+ key = self.keys.nwk_skey if fport == 0 else self.keys.app_skey
567
+ if key:
568
+ return decrypt_payload(frm_payload, key, dev_addr, fcnt, direction)
569
+ except ImportError:
570
+ errors.append("PyCryptodome not available for decryption")
571
+ except Exception as exc:
572
+ errors.append(f"Decryption failed: {exc}")
573
+
574
+ return None
575
+
576
+ def _verify_frame_mic(
577
+ self,
578
+ full_frame: bytes,
579
+ mic: int,
580
+ dev_addr: int,
581
+ fcnt: int,
582
+ direction: Literal["up", "down"],
583
+ errors: list[str],
584
+ ) -> bool | None:
585
+ """Verify Message Integrity Code (MIC).
586
+
587
+ Args:
588
+ full_frame: Complete frame including MHDR and MIC.
589
+ mic: Received MIC value.
590
+ dev_addr: Device address.
591
+ fcnt: Frame counter.
592
+ direction: Message direction.
593
+ errors: Error list to append to.
594
+
595
+ Returns:
596
+ True if MIC valid, False if invalid, None if not checked.
597
+ """
598
+ if not self.keys.nwk_skey:
599
+ return None
600
+
601
+ try:
602
+ from oscura.iot.lorawan.crypto import verify_mic
603
+
604
+ # MIC is computed over MHDR | FHDR | FPort | FRMPayload
605
+ mic_data = full_frame[:-4]
606
+ mic_valid = verify_mic(mic_data, mic, self.keys.nwk_skey, dev_addr, fcnt, direction)
607
+ if not mic_valid:
608
+ errors.append("MIC verification failed")
609
+ return mic_valid
610
+ except ImportError:
611
+ return None # Crypto not available
612
+ except Exception as exc:
613
+ errors.append(f"MIC verification error: {exc}")
614
+ return None
615
+
616
+ def _decode_join_request(
617
+ self,
618
+ mac_payload: bytes,
619
+ mic: int,
620
+ timestamp: float,
621
+ errors: list[str],
622
+ ) -> LoRaWANFrame:
623
+ """Decode Join-request frame.
624
+
625
+ Join-request format:
626
+ AppEUI (8 bytes) | DevEUI (8 bytes) | DevNonce (2 bytes)
627
+
628
+ Args:
629
+ mac_payload: Join-request payload.
630
+ mic: Message Integrity Code.
631
+ timestamp: Frame timestamp.
632
+ errors: Error list.
633
+
634
+ Returns:
635
+ Decoded frame.
636
+ """
637
+ if len(mac_payload) < 18:
638
+ errors.append(f"Join-request too short: {len(mac_payload)} bytes")
639
+
640
+ frame = LoRaWANFrame(
641
+ timestamp=timestamp,
642
+ mtype="Join-request",
643
+ frm_payload=mac_payload,
644
+ mic=mic,
645
+ errors=errors,
646
+ )
647
+
648
+ self.frames.append(frame)
649
+ return frame
650
+
651
+ def _decode_join_accept(
652
+ self,
653
+ mac_payload: bytes,
654
+ mic: int,
655
+ timestamp: float,
656
+ errors: list[str],
657
+ ) -> LoRaWANFrame:
658
+ """Decode Join-accept frame.
659
+
660
+ Join-accept is encrypted with AppKey and includes:
661
+ AppNonce (3 bytes) | NetID (3 bytes) | DevAddr (4 bytes) |
662
+ DLSettings (1 byte) | RxDelay (1 byte) | CFList (optional, 16 bytes)
663
+
664
+ Args:
665
+ mac_payload: Encrypted Join-accept payload.
666
+ mic: Message Integrity Code.
667
+ timestamp: Frame timestamp.
668
+ errors: Error list.
669
+
670
+ Returns:
671
+ Decoded frame.
672
+ """
673
+ # Join-accept decryption requires AppKey
674
+ frame = LoRaWANFrame(
675
+ timestamp=timestamp,
676
+ mtype="Join-accept",
677
+ frm_payload=mac_payload,
678
+ mic=mic,
679
+ errors=errors,
680
+ )
681
+
682
+ self.frames.append(frame)
683
+ return frame
684
+
685
+ def export_json(self) -> list[dict[str, Any]]:
686
+ """Export decoded frames as JSON-serializable list.
687
+
688
+ Returns:
689
+ List of frame dictionaries.
690
+
691
+ Example:
692
+ >>> frames_json = decoder.export_json()
693
+ >>> import json
694
+ >>> print(json.dumps(frames_json, indent=2))
695
+ """
696
+ result = []
697
+ for frame in self.frames:
698
+ frame_dict: dict[str, Any] = {
699
+ "timestamp": frame.timestamp,
700
+ "mtype": frame.mtype,
701
+ }
702
+
703
+ if frame.dev_addr is not None:
704
+ frame_dict["dev_addr"] = f"0x{frame.dev_addr:08X}"
705
+
706
+ if frame.fctrl:
707
+ frame_dict["fctrl"] = frame.fctrl
708
+
709
+ if frame.fcnt is not None:
710
+ frame_dict["fcnt"] = frame.fcnt
711
+
712
+ if frame.fopts:
713
+ frame_dict["fopts"] = frame.fopts.hex()
714
+
715
+ if frame.fport is not None:
716
+ frame_dict["fport"] = frame.fport
717
+
718
+ if frame.frm_payload:
719
+ frame_dict["frm_payload"] = frame.frm_payload.hex()
720
+
721
+ if frame.decrypted_payload:
722
+ frame_dict["decrypted_payload"] = frame.decrypted_payload.hex()
723
+
724
+ if frame.mic is not None:
725
+ frame_dict["mic"] = f"0x{frame.mic:08X}"
726
+
727
+ if frame.mic_valid is not None:
728
+ frame_dict["mic_valid"] = frame.mic_valid
729
+
730
+ if frame.parsed_mac_commands:
731
+ frame_dict["mac_commands"] = frame.parsed_mac_commands
732
+
733
+ if frame.errors:
734
+ frame_dict["errors"] = frame.errors
735
+
736
+ result.append(frame_dict)
737
+
738
+ return result
739
+
740
+ def export_csv_rows(self) -> list[dict[str, str]]:
741
+ """Export decoded frames as CSV rows.
742
+
743
+ Returns:
744
+ List of dictionaries suitable for CSV export.
745
+
746
+ Example:
747
+ >>> import csv
748
+ >>> rows = decoder.export_csv_rows()
749
+ >>> with open("frames.csv", "w") as f:
750
+ ... writer = csv.DictWriter(f, fieldnames=rows[0].keys())
751
+ ... writer.writeheader()
752
+ ... writer.writerows(rows)
753
+ """
754
+ rows = []
755
+ for frame in self.frames:
756
+ row = {
757
+ "timestamp": str(frame.timestamp),
758
+ "mtype": frame.mtype,
759
+ "dev_addr": f"0x{frame.dev_addr:08X}" if frame.dev_addr else "",
760
+ "fcnt": str(frame.fcnt) if frame.fcnt is not None else "",
761
+ "fport": str(frame.fport) if frame.fport is not None else "",
762
+ "payload_hex": frame.frm_payload.hex(),
763
+ "decrypted_hex": frame.decrypted_payload.hex() if frame.decrypted_payload else "",
764
+ "mic": f"0x{frame.mic:08X}" if frame.mic is not None else "",
765
+ "mic_valid": str(frame.mic_valid) if frame.mic_valid is not None else "",
766
+ "errors": "; ".join(frame.errors),
767
+ }
768
+ rows.append(row)
769
+
770
+ return rows
771
+
772
+
773
+ def decode_lorawan_frame(
774
+ data: bytes,
775
+ timestamp: float = 0.0,
776
+ keys: LoRaWANKeys | None = None,
777
+ ) -> LoRaWANFrame:
778
+ """Convenience function to decode a single LoRaWAN frame.
779
+
780
+ Args:
781
+ data: Raw frame bytes.
782
+ timestamp: Frame timestamp in seconds.
783
+ keys: Optional encryption keys for decryption and MIC verification.
784
+
785
+ Returns:
786
+ Decoded LoRaWAN frame.
787
+
788
+ Example:
789
+ >>> frame = decode_lorawan_frame(bytes.fromhex("40..."))
790
+ >>> print(f"MType: {frame.mtype}")
791
+ """
792
+ decoder = LoRaWANDecoder(keys=keys)
793
+ return decoder.decode_frame(data, timestamp=timestamp)
794
+
795
+
796
+ __all__ = [
797
+ "LoRaWANDecoder",
798
+ "LoRaWANFrame",
799
+ "LoRaWANKeys",
800
+ "decode_lorawan_frame",
801
+ ]