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.
- oscura/__init__.py +169 -167
- oscura/analyzers/__init__.py +3 -0
- oscura/analyzers/classification.py +659 -0
- oscura/analyzers/digital/edges.py +325 -65
- oscura/analyzers/digital/quality.py +293 -166
- oscura/analyzers/digital/timing.py +260 -115
- oscura/analyzers/digital/timing_numba.py +334 -0
- oscura/analyzers/entropy.py +605 -0
- oscura/analyzers/eye/diagram.py +176 -109
- oscura/analyzers/eye/metrics.py +5 -5
- oscura/analyzers/jitter/__init__.py +6 -4
- oscura/analyzers/jitter/ber.py +52 -52
- oscura/analyzers/jitter/classification.py +156 -0
- oscura/analyzers/jitter/decomposition.py +163 -113
- oscura/analyzers/jitter/spectrum.py +80 -64
- oscura/analyzers/ml/__init__.py +39 -0
- oscura/analyzers/ml/features.py +600 -0
- oscura/analyzers/ml/signal_classifier.py +604 -0
- oscura/analyzers/packet/daq.py +246 -158
- oscura/analyzers/packet/parser.py +12 -1
- oscura/analyzers/packet/payload.py +50 -2110
- oscura/analyzers/packet/payload_analysis.py +361 -181
- oscura/analyzers/packet/payload_patterns.py +133 -70
- oscura/analyzers/packet/stream.py +84 -23
- oscura/analyzers/patterns/__init__.py +26 -5
- oscura/analyzers/patterns/anomaly_detection.py +908 -0
- oscura/analyzers/patterns/clustering.py +169 -108
- oscura/analyzers/patterns/clustering_optimized.py +227 -0
- oscura/analyzers/patterns/discovery.py +1 -1
- oscura/analyzers/patterns/matching.py +581 -197
- oscura/analyzers/patterns/pattern_mining.py +778 -0
- oscura/analyzers/patterns/periodic.py +121 -38
- oscura/analyzers/patterns/sequences.py +175 -78
- oscura/analyzers/power/conduction.py +1 -1
- oscura/analyzers/power/soa.py +6 -6
- oscura/analyzers/power/switching.py +250 -110
- oscura/analyzers/protocol/__init__.py +17 -1
- oscura/analyzers/protocols/base.py +6 -6
- oscura/analyzers/protocols/ble/__init__.py +38 -0
- oscura/analyzers/protocols/ble/analyzer.py +809 -0
- oscura/analyzers/protocols/ble/uuids.py +288 -0
- oscura/analyzers/protocols/can.py +257 -127
- oscura/analyzers/protocols/can_fd.py +107 -80
- oscura/analyzers/protocols/flexray.py +139 -80
- oscura/analyzers/protocols/hdlc.py +93 -58
- oscura/analyzers/protocols/i2c.py +247 -106
- oscura/analyzers/protocols/i2s.py +138 -86
- oscura/analyzers/protocols/industrial/__init__.py +40 -0
- oscura/analyzers/protocols/industrial/bacnet/__init__.py +33 -0
- oscura/analyzers/protocols/industrial/bacnet/analyzer.py +708 -0
- oscura/analyzers/protocols/industrial/bacnet/encoding.py +412 -0
- oscura/analyzers/protocols/industrial/bacnet/services.py +622 -0
- oscura/analyzers/protocols/industrial/ethercat/__init__.py +30 -0
- oscura/analyzers/protocols/industrial/ethercat/analyzer.py +474 -0
- oscura/analyzers/protocols/industrial/ethercat/mailbox.py +339 -0
- oscura/analyzers/protocols/industrial/ethercat/topology.py +166 -0
- oscura/analyzers/protocols/industrial/modbus/__init__.py +31 -0
- oscura/analyzers/protocols/industrial/modbus/analyzer.py +525 -0
- oscura/analyzers/protocols/industrial/modbus/crc.py +79 -0
- oscura/analyzers/protocols/industrial/modbus/functions.py +436 -0
- oscura/analyzers/protocols/industrial/opcua/__init__.py +21 -0
- oscura/analyzers/protocols/industrial/opcua/analyzer.py +552 -0
- oscura/analyzers/protocols/industrial/opcua/datatypes.py +446 -0
- oscura/analyzers/protocols/industrial/opcua/services.py +264 -0
- oscura/analyzers/protocols/industrial/profinet/__init__.py +23 -0
- oscura/analyzers/protocols/industrial/profinet/analyzer.py +441 -0
- oscura/analyzers/protocols/industrial/profinet/dcp.py +263 -0
- oscura/analyzers/protocols/industrial/profinet/ptcp.py +200 -0
- oscura/analyzers/protocols/jtag.py +180 -98
- oscura/analyzers/protocols/lin.py +219 -114
- oscura/analyzers/protocols/manchester.py +4 -4
- oscura/analyzers/protocols/onewire.py +253 -149
- oscura/analyzers/protocols/parallel_bus/__init__.py +20 -0
- oscura/analyzers/protocols/parallel_bus/centronics.py +92 -0
- oscura/analyzers/protocols/parallel_bus/gpib.py +137 -0
- oscura/analyzers/protocols/spi.py +192 -95
- oscura/analyzers/protocols/swd.py +321 -167
- oscura/analyzers/protocols/uart.py +267 -125
- oscura/analyzers/protocols/usb.py +235 -131
- oscura/analyzers/side_channel/power.py +17 -12
- oscura/analyzers/signal/__init__.py +15 -0
- oscura/analyzers/signal/timing_analysis.py +1086 -0
- oscura/analyzers/signal_integrity/__init__.py +4 -1
- oscura/analyzers/signal_integrity/sparams.py +2 -19
- oscura/analyzers/spectral/chunked.py +129 -60
- oscura/analyzers/spectral/chunked_fft.py +300 -94
- oscura/analyzers/spectral/chunked_wavelet.py +100 -80
- oscura/analyzers/statistical/checksum.py +376 -217
- oscura/analyzers/statistical/classification.py +229 -107
- oscura/analyzers/statistical/entropy.py +78 -53
- oscura/analyzers/statistics/correlation.py +407 -211
- oscura/analyzers/statistics/outliers.py +2 -2
- oscura/analyzers/statistics/streaming.py +30 -5
- oscura/analyzers/validation.py +216 -101
- oscura/analyzers/waveform/measurements.py +9 -0
- oscura/analyzers/waveform/measurements_with_uncertainty.py +31 -15
- oscura/analyzers/waveform/spectral.py +500 -228
- oscura/api/__init__.py +31 -5
- oscura/api/dsl/__init__.py +582 -0
- oscura/{dsl → api/dsl}/commands.py +43 -76
- oscura/{dsl → api/dsl}/interpreter.py +26 -51
- oscura/{dsl → api/dsl}/parser.py +107 -77
- oscura/{dsl → api/dsl}/repl.py +2 -2
- oscura/api/dsl.py +1 -1
- oscura/{integrations → api/integrations}/__init__.py +1 -1
- oscura/{integrations → api/integrations}/llm.py +201 -102
- oscura/api/operators.py +3 -3
- oscura/api/optimization.py +144 -30
- oscura/api/rest_server.py +921 -0
- oscura/api/server/__init__.py +17 -0
- oscura/api/server/dashboard.py +850 -0
- oscura/api/server/static/README.md +34 -0
- oscura/api/server/templates/base.html +181 -0
- oscura/api/server/templates/export.html +120 -0
- oscura/api/server/templates/home.html +284 -0
- oscura/api/server/templates/protocols.html +58 -0
- oscura/api/server/templates/reports.html +43 -0
- oscura/api/server/templates/session_detail.html +89 -0
- oscura/api/server/templates/sessions.html +83 -0
- oscura/api/server/templates/waveforms.html +73 -0
- oscura/automotive/__init__.py +8 -1
- oscura/automotive/can/__init__.py +10 -0
- oscura/automotive/can/checksum.py +3 -1
- oscura/automotive/can/dbc_generator.py +590 -0
- oscura/automotive/can/message_wrapper.py +121 -74
- oscura/automotive/can/patterns.py +98 -21
- oscura/automotive/can/session.py +292 -56
- oscura/automotive/can/state_machine.py +6 -3
- oscura/automotive/can/stimulus_response.py +97 -75
- oscura/automotive/dbc/__init__.py +10 -2
- oscura/automotive/dbc/generator.py +84 -56
- oscura/automotive/dbc/parser.py +6 -6
- oscura/automotive/dtc/data.json +17 -102
- oscura/automotive/dtc/database.py +2 -2
- oscura/automotive/flexray/__init__.py +31 -0
- oscura/automotive/flexray/analyzer.py +504 -0
- oscura/automotive/flexray/crc.py +185 -0
- oscura/automotive/flexray/fibex.py +449 -0
- oscura/automotive/j1939/__init__.py +45 -8
- oscura/automotive/j1939/analyzer.py +605 -0
- oscura/automotive/j1939/spns.py +326 -0
- oscura/automotive/j1939/transport.py +306 -0
- oscura/automotive/lin/__init__.py +47 -0
- oscura/automotive/lin/analyzer.py +612 -0
- oscura/automotive/loaders/blf.py +13 -2
- oscura/automotive/loaders/csv_can.py +143 -72
- oscura/automotive/loaders/dispatcher.py +50 -2
- oscura/automotive/loaders/mdf.py +86 -45
- oscura/automotive/loaders/pcap.py +111 -61
- oscura/automotive/uds/__init__.py +4 -0
- oscura/automotive/uds/analyzer.py +725 -0
- oscura/automotive/uds/decoder.py +140 -58
- oscura/automotive/uds/models.py +7 -1
- oscura/automotive/visualization.py +1 -1
- oscura/cli/analyze.py +348 -0
- oscura/cli/batch.py +142 -122
- oscura/cli/benchmark.py +275 -0
- oscura/cli/characterize.py +137 -82
- oscura/cli/compare.py +224 -131
- oscura/cli/completion.py +250 -0
- oscura/cli/config_cmd.py +361 -0
- oscura/cli/decode.py +164 -87
- oscura/cli/export.py +286 -0
- oscura/cli/main.py +115 -31
- oscura/{onboarding → cli/onboarding}/__init__.py +3 -3
- oscura/{onboarding → cli/onboarding}/help.py +80 -58
- oscura/{onboarding → cli/onboarding}/tutorials.py +97 -72
- oscura/{onboarding → cli/onboarding}/wizard.py +55 -36
- oscura/cli/progress.py +147 -0
- oscura/cli/shell.py +157 -135
- oscura/cli/validate_cmd.py +204 -0
- oscura/cli/visualize.py +158 -0
- oscura/convenience.py +125 -79
- oscura/core/__init__.py +4 -2
- oscura/core/backend_selector.py +3 -3
- oscura/core/cache.py +126 -15
- oscura/core/cancellation.py +1 -1
- oscura/{config → core/config}/__init__.py +20 -11
- oscura/{config → core/config}/defaults.py +1 -1
- oscura/{config → core/config}/loader.py +7 -5
- oscura/{config → core/config}/memory.py +5 -5
- oscura/{config → core/config}/migration.py +1 -1
- oscura/{config → core/config}/pipeline.py +99 -23
- oscura/{config → core/config}/preferences.py +1 -1
- oscura/{config → core/config}/protocol.py +3 -3
- oscura/{config → core/config}/schema.py +426 -272
- oscura/{config → core/config}/settings.py +1 -1
- oscura/{config → core/config}/thresholds.py +195 -153
- oscura/core/correlation.py +5 -6
- oscura/core/cross_domain.py +0 -2
- oscura/core/debug.py +9 -5
- oscura/{extensibility → core/extensibility}/docs.py +158 -70
- oscura/{extensibility → core/extensibility}/extensions.py +160 -76
- oscura/{extensibility → core/extensibility}/logging.py +1 -1
- oscura/{extensibility → core/extensibility}/measurements.py +1 -1
- oscura/{extensibility → core/extensibility}/plugins.py +1 -1
- oscura/{extensibility → core/extensibility}/templates.py +73 -3
- oscura/{extensibility → core/extensibility}/validation.py +1 -1
- oscura/core/gpu_backend.py +11 -7
- oscura/core/log_query.py +101 -11
- oscura/core/logging.py +126 -54
- oscura/core/logging_advanced.py +5 -5
- oscura/core/memory_limits.py +108 -70
- oscura/core/memory_monitor.py +2 -2
- oscura/core/memory_progress.py +7 -7
- oscura/core/memory_warnings.py +1 -1
- oscura/core/numba_backend.py +13 -13
- oscura/{plugins → core/plugins}/__init__.py +9 -9
- oscura/{plugins → core/plugins}/base.py +7 -7
- oscura/{plugins → core/plugins}/cli.py +3 -3
- oscura/{plugins → core/plugins}/discovery.py +186 -106
- oscura/{plugins → core/plugins}/lifecycle.py +1 -1
- oscura/{plugins → core/plugins}/manager.py +7 -7
- oscura/{plugins → core/plugins}/registry.py +3 -3
- oscura/{plugins → core/plugins}/versioning.py +1 -1
- oscura/core/progress.py +16 -1
- oscura/core/provenance.py +8 -2
- oscura/{schemas → core/schemas}/__init__.py +2 -2
- oscura/{schemas → core/schemas}/device_mapping.json +2 -8
- oscura/{schemas → core/schemas}/packet_format.json +4 -24
- oscura/{schemas → core/schemas}/protocol_definition.json +2 -12
- oscura/core/types.py +4 -0
- oscura/core/uncertainty.py +3 -3
- oscura/correlation/__init__.py +52 -0
- oscura/correlation/multi_protocol.py +811 -0
- oscura/discovery/auto_decoder.py +117 -35
- oscura/discovery/comparison.py +191 -86
- oscura/discovery/quality_validator.py +155 -68
- oscura/discovery/signal_detector.py +196 -79
- oscura/export/__init__.py +18 -8
- oscura/export/kaitai_struct.py +513 -0
- oscura/export/scapy_layer.py +801 -0
- oscura/export/wireshark/generator.py +1 -1
- oscura/export/wireshark/templates/dissector.lua.j2 +2 -2
- oscura/export/wireshark_dissector.py +746 -0
- oscura/guidance/wizard.py +207 -111
- oscura/hardware/__init__.py +19 -0
- oscura/{acquisition → hardware/acquisition}/__init__.py +4 -4
- oscura/{acquisition → hardware/acquisition}/file.py +2 -2
- oscura/{acquisition → hardware/acquisition}/hardware.py +7 -7
- oscura/{acquisition → hardware/acquisition}/saleae.py +15 -12
- oscura/{acquisition → hardware/acquisition}/socketcan.py +1 -1
- oscura/{acquisition → hardware/acquisition}/streaming.py +2 -2
- oscura/{acquisition → hardware/acquisition}/synthetic.py +3 -3
- oscura/{acquisition → hardware/acquisition}/visa.py +33 -11
- oscura/hardware/firmware/__init__.py +29 -0
- oscura/hardware/firmware/pattern_recognition.py +874 -0
- oscura/hardware/hal_detector.py +736 -0
- oscura/hardware/security/__init__.py +37 -0
- oscura/hardware/security/side_channel_detector.py +1126 -0
- oscura/inference/__init__.py +4 -0
- oscura/inference/active_learning/observation_table.py +4 -1
- oscura/inference/alignment.py +216 -123
- oscura/inference/bayesian.py +113 -33
- oscura/inference/crc_reverse.py +101 -55
- oscura/inference/logic.py +6 -2
- oscura/inference/message_format.py +342 -183
- oscura/inference/protocol.py +95 -44
- oscura/inference/protocol_dsl.py +180 -82
- oscura/inference/signal_intelligence.py +1439 -706
- oscura/inference/spectral.py +99 -57
- oscura/inference/state_machine.py +810 -158
- oscura/inference/stream.py +270 -110
- oscura/iot/__init__.py +34 -0
- oscura/iot/coap/__init__.py +32 -0
- oscura/iot/coap/analyzer.py +668 -0
- oscura/iot/coap/options.py +212 -0
- oscura/iot/lorawan/__init__.py +21 -0
- oscura/iot/lorawan/crypto.py +206 -0
- oscura/iot/lorawan/decoder.py +801 -0
- oscura/iot/lorawan/mac_commands.py +341 -0
- oscura/iot/mqtt/__init__.py +27 -0
- oscura/iot/mqtt/analyzer.py +999 -0
- oscura/iot/mqtt/properties.py +315 -0
- oscura/iot/zigbee/__init__.py +31 -0
- oscura/iot/zigbee/analyzer.py +615 -0
- oscura/iot/zigbee/security.py +153 -0
- oscura/iot/zigbee/zcl.py +349 -0
- oscura/jupyter/display.py +125 -45
- oscura/{exploratory → jupyter/exploratory}/__init__.py +8 -8
- oscura/{exploratory → jupyter/exploratory}/error_recovery.py +298 -141
- oscura/jupyter/exploratory/fuzzy.py +746 -0
- oscura/{exploratory → jupyter/exploratory}/fuzzy_advanced.py +258 -100
- oscura/{exploratory → jupyter/exploratory}/legacy.py +464 -242
- oscura/{exploratory → jupyter/exploratory}/parse.py +167 -145
- oscura/{exploratory → jupyter/exploratory}/recovery.py +119 -87
- oscura/jupyter/exploratory/sync.py +612 -0
- oscura/{exploratory → jupyter/exploratory}/unknown.py +299 -176
- oscura/jupyter/magic.py +4 -4
- oscura/{ui → jupyter/ui}/__init__.py +2 -2
- oscura/{ui → jupyter/ui}/formatters.py +3 -3
- oscura/{ui → jupyter/ui}/progressive_display.py +153 -82
- oscura/loaders/__init__.py +183 -67
- oscura/loaders/binary.py +88 -1
- oscura/loaders/chipwhisperer.py +153 -137
- oscura/loaders/configurable.py +208 -86
- oscura/loaders/csv_loader.py +458 -215
- oscura/loaders/hdf5_loader.py +278 -119
- oscura/loaders/lazy.py +87 -54
- oscura/loaders/mmap_loader.py +1 -1
- oscura/loaders/numpy_loader.py +253 -116
- oscura/loaders/pcap.py +226 -151
- oscura/loaders/rigol.py +110 -49
- oscura/loaders/sigrok.py +201 -78
- oscura/loaders/tdms.py +81 -58
- oscura/loaders/tektronix.py +291 -174
- oscura/loaders/touchstone.py +182 -87
- oscura/loaders/tss.py +456 -0
- oscura/loaders/vcd.py +215 -117
- oscura/loaders/wav.py +155 -68
- oscura/reporting/__init__.py +9 -0
- oscura/reporting/analyze.py +352 -146
- oscura/reporting/argument_preparer.py +69 -14
- oscura/reporting/auto_report.py +97 -61
- oscura/reporting/batch.py +131 -58
- oscura/reporting/chart_selection.py +57 -45
- oscura/reporting/comparison.py +63 -17
- oscura/reporting/content/executive.py +76 -24
- oscura/reporting/core_formats/multi_format.py +11 -8
- oscura/reporting/engine.py +312 -158
- oscura/reporting/enhanced_reports.py +949 -0
- oscura/reporting/export.py +86 -43
- oscura/reporting/formatting/numbers.py +69 -42
- oscura/reporting/html.py +139 -58
- oscura/reporting/index.py +137 -65
- oscura/reporting/output.py +158 -67
- oscura/reporting/pdf.py +67 -102
- oscura/reporting/plots.py +191 -112
- oscura/reporting/sections.py +88 -47
- oscura/reporting/standards.py +104 -61
- oscura/reporting/summary_generator.py +75 -55
- oscura/reporting/tables.py +138 -54
- oscura/reporting/templates/enhanced/protocol_re.html +525 -0
- oscura/sessions/__init__.py +14 -23
- oscura/sessions/base.py +3 -3
- oscura/sessions/blackbox.py +106 -10
- oscura/sessions/generic.py +2 -2
- oscura/sessions/legacy.py +783 -0
- oscura/side_channel/__init__.py +63 -0
- oscura/side_channel/dpa.py +1025 -0
- oscura/utils/__init__.py +15 -1
- oscura/utils/bitwise.py +118 -0
- oscura/{builders → utils/builders}/__init__.py +1 -1
- oscura/{comparison → utils/comparison}/__init__.py +6 -6
- oscura/{comparison → utils/comparison}/compare.py +202 -101
- oscura/{comparison → utils/comparison}/golden.py +83 -63
- oscura/{comparison → utils/comparison}/limits.py +313 -89
- oscura/{comparison → utils/comparison}/mask.py +151 -45
- oscura/{comparison → utils/comparison}/trace_diff.py +1 -1
- oscura/{comparison → utils/comparison}/visualization.py +147 -89
- oscura/{component → utils/component}/__init__.py +3 -3
- oscura/{component → utils/component}/impedance.py +122 -58
- oscura/{component → utils/component}/reactive.py +165 -168
- oscura/{component → utils/component}/transmission_line.py +3 -3
- oscura/{filtering → utils/filtering}/__init__.py +6 -6
- oscura/{filtering → utils/filtering}/base.py +1 -1
- oscura/{filtering → utils/filtering}/convenience.py +2 -2
- oscura/{filtering → utils/filtering}/design.py +169 -93
- oscura/{filtering → utils/filtering}/filters.py +2 -2
- oscura/{filtering → utils/filtering}/introspection.py +2 -2
- oscura/utils/geometry.py +31 -0
- oscura/utils/imports.py +184 -0
- oscura/utils/lazy.py +1 -1
- oscura/{math → utils/math}/__init__.py +2 -2
- oscura/{math → utils/math}/arithmetic.py +114 -48
- oscura/{math → utils/math}/interpolation.py +139 -106
- oscura/utils/memory.py +129 -66
- oscura/utils/memory_advanced.py +92 -9
- oscura/utils/memory_extensions.py +10 -8
- oscura/{optimization → utils/optimization}/__init__.py +1 -1
- oscura/{optimization → utils/optimization}/search.py +2 -2
- oscura/utils/performance/__init__.py +58 -0
- oscura/utils/performance/caching.py +889 -0
- oscura/utils/performance/lsh_clustering.py +333 -0
- oscura/utils/performance/memory_optimizer.py +699 -0
- oscura/utils/performance/optimizations.py +675 -0
- oscura/utils/performance/parallel.py +654 -0
- oscura/utils/performance/profiling.py +661 -0
- oscura/{pipeline → utils/pipeline}/base.py +1 -1
- oscura/{pipeline → utils/pipeline}/composition.py +1 -1
- oscura/{pipeline → utils/pipeline}/parallel.py +3 -2
- oscura/{pipeline → utils/pipeline}/pipeline.py +1 -1
- oscura/{pipeline → utils/pipeline}/reverse_engineering.py +412 -221
- oscura/{search → utils/search}/__init__.py +3 -3
- oscura/{search → utils/search}/anomaly.py +188 -58
- oscura/utils/search/context.py +294 -0
- oscura/{search → utils/search}/pattern.py +138 -10
- oscura/utils/serial.py +51 -0
- oscura/utils/storage/__init__.py +61 -0
- oscura/utils/storage/database.py +1166 -0
- oscura/{streaming → utils/streaming}/chunked.py +302 -143
- oscura/{streaming → utils/streaming}/progressive.py +1 -1
- oscura/{streaming → utils/streaming}/realtime.py +3 -2
- oscura/{triggering → utils/triggering}/__init__.py +6 -6
- oscura/{triggering → utils/triggering}/base.py +6 -6
- oscura/{triggering → utils/triggering}/edge.py +2 -2
- oscura/{triggering → utils/triggering}/pattern.py +2 -2
- oscura/{triggering → utils/triggering}/pulse.py +115 -74
- oscura/{triggering → utils/triggering}/window.py +2 -2
- oscura/utils/validation.py +32 -0
- oscura/validation/__init__.py +121 -0
- oscura/{compliance → validation/compliance}/__init__.py +5 -5
- oscura/{compliance → validation/compliance}/advanced.py +5 -5
- oscura/{compliance → validation/compliance}/masks.py +1 -1
- oscura/{compliance → validation/compliance}/reporting.py +127 -53
- oscura/{compliance → validation/compliance}/testing.py +114 -52
- oscura/validation/compliance_tests.py +915 -0
- oscura/validation/fuzzer.py +990 -0
- oscura/validation/grammar_tests.py +596 -0
- oscura/validation/grammar_validator.py +904 -0
- oscura/validation/hil_testing.py +977 -0
- oscura/{quality → validation/quality}/__init__.py +4 -4
- oscura/{quality → validation/quality}/ensemble.py +251 -171
- oscura/{quality → validation/quality}/explainer.py +3 -3
- oscura/{quality → validation/quality}/scoring.py +1 -1
- oscura/{quality → validation/quality}/warnings.py +4 -4
- oscura/validation/regression_suite.py +808 -0
- oscura/validation/replay.py +788 -0
- oscura/{testing → validation/testing}/__init__.py +2 -2
- oscura/{testing → validation/testing}/synthetic.py +5 -5
- oscura/visualization/__init__.py +9 -0
- oscura/visualization/accessibility.py +1 -1
- oscura/visualization/annotations.py +64 -67
- oscura/visualization/colors.py +7 -7
- oscura/visualization/digital.py +180 -81
- oscura/visualization/eye.py +236 -85
- oscura/visualization/interactive.py +320 -143
- oscura/visualization/jitter.py +587 -247
- oscura/visualization/layout.py +169 -134
- oscura/visualization/optimization.py +103 -52
- oscura/visualization/palettes.py +1 -1
- oscura/visualization/power.py +427 -211
- oscura/visualization/power_extended.py +626 -297
- oscura/visualization/presets.py +2 -0
- oscura/visualization/protocols.py +495 -181
- oscura/visualization/render.py +79 -63
- oscura/visualization/reverse_engineering.py +171 -124
- oscura/visualization/signal_integrity.py +460 -279
- oscura/visualization/specialized.py +190 -100
- oscura/visualization/spectral.py +670 -255
- oscura/visualization/thumbnails.py +166 -137
- oscura/visualization/waveform.py +150 -63
- oscura/workflows/__init__.py +3 -0
- oscura/{batch → workflows/batch}/__init__.py +5 -5
- oscura/{batch → workflows/batch}/advanced.py +150 -75
- oscura/workflows/batch/aggregate.py +531 -0
- oscura/workflows/batch/analyze.py +236 -0
- oscura/{batch → workflows/batch}/logging.py +2 -2
- oscura/{batch → workflows/batch}/metrics.py +1 -1
- oscura/workflows/complete_re.py +1144 -0
- oscura/workflows/compliance.py +44 -54
- oscura/workflows/digital.py +197 -51
- oscura/workflows/legacy/__init__.py +12 -0
- oscura/{workflow → workflows/legacy}/dag.py +4 -1
- oscura/workflows/multi_trace.py +9 -9
- oscura/workflows/power.py +42 -62
- oscura/workflows/protocol.py +82 -49
- oscura/workflows/reverse_engineering.py +351 -150
- oscura/workflows/signal_integrity.py +157 -82
- oscura-0.7.0.dist-info/METADATA +661 -0
- oscura-0.7.0.dist-info/RECORD +591 -0
- oscura/batch/aggregate.py +0 -300
- oscura/batch/analyze.py +0 -139
- oscura/dsl/__init__.py +0 -73
- oscura/exceptions.py +0 -59
- oscura/exploratory/fuzzy.py +0 -513
- oscura/exploratory/sync.py +0 -384
- oscura/exporters/__init__.py +0 -94
- oscura/exporters/csv.py +0 -303
- oscura/exporters/exporters.py +0 -44
- oscura/exporters/hdf5.py +0 -217
- oscura/exporters/html_export.py +0 -701
- oscura/exporters/json_export.py +0 -291
- oscura/exporters/markdown_export.py +0 -367
- oscura/exporters/matlab_export.py +0 -354
- oscura/exporters/npz_export.py +0 -219
- oscura/exporters/spice_export.py +0 -210
- oscura/search/context.py +0 -149
- oscura/session/__init__.py +0 -34
- oscura/session/annotations.py +0 -289
- oscura/session/history.py +0 -313
- oscura/session/session.py +0 -520
- oscura/workflow/__init__.py +0 -13
- oscura-0.5.1.dist-info/METADATA +0 -583
- oscura-0.5.1.dist-info/RECORD +0 -481
- /oscura/core/{config.py → config/legacy.py} +0 -0
- /oscura/{extensibility → core/extensibility}/__init__.py +0 -0
- /oscura/{extensibility → core/extensibility}/registry.py +0 -0
- /oscura/{plugins → core/plugins}/isolation.py +0 -0
- /oscura/{schemas → core/schemas}/bus_configuration.json +0 -0
- /oscura/{builders → utils/builders}/signal_builder.py +0 -0
- /oscura/{optimization → utils/optimization}/parallel.py +0 -0
- /oscura/{pipeline → utils/pipeline}/__init__.py +0 -0
- /oscura/{streaming → utils/streaming}/__init__.py +0 -0
- {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/WHEEL +0 -0
- {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
]
|