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,999 @@
|
|
|
1
|
+
"""MQTT protocol analyzer for versions 3.1.1 and 5.0.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive MQTT protocol analysis including packet
|
|
4
|
+
parsing, session tracking, and topic hierarchy discovery.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from oscura.iot.mqtt import MQTTAnalyzer
|
|
8
|
+
>>> analyzer = MQTTAnalyzer()
|
|
9
|
+
>>> packet = analyzer.parse_packet(data, timestamp=0.0)
|
|
10
|
+
>>> topology = analyzer.get_topic_hierarchy()
|
|
11
|
+
|
|
12
|
+
References:
|
|
13
|
+
MQTT 3.1.1: http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/
|
|
14
|
+
MQTT 5.0: https://docs.oasis-open.org/mqtt/mqtt/v5.0/
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, ClassVar
|
|
23
|
+
|
|
24
|
+
from oscura.iot.mqtt.properties import parse_properties
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MQTTPacket:
|
|
29
|
+
"""MQTT control packet representation.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
timestamp: Packet timestamp in seconds.
|
|
33
|
+
packet_type: Packet type name ("CONNECT", "PUBLISH", etc.).
|
|
34
|
+
protocol_version: MQTT protocol version ("3.1.1" or "5.0").
|
|
35
|
+
flags: Packet flags (DUP, QoS, RETAIN, etc.).
|
|
36
|
+
packet_id: Packet identifier for QoS > 0 (optional).
|
|
37
|
+
topic: Topic name for PUBLISH/SUBSCRIBE packets (optional).
|
|
38
|
+
payload: Packet payload bytes.
|
|
39
|
+
properties: MQTT 5.0 properties dictionary.
|
|
40
|
+
qos: Quality of Service level (0, 1, or 2).
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> packet = MQTTPacket(
|
|
44
|
+
... timestamp=0.0,
|
|
45
|
+
... packet_type="PUBLISH",
|
|
46
|
+
... protocol_version="3.1.1",
|
|
47
|
+
... flags={"dup": False, "qos": 0, "retain": False},
|
|
48
|
+
... topic="home/sensor/temperature",
|
|
49
|
+
... payload=b"22.5",
|
|
50
|
+
... qos=0,
|
|
51
|
+
... )
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
timestamp: float
|
|
55
|
+
packet_type: str
|
|
56
|
+
protocol_version: str
|
|
57
|
+
flags: dict[str, bool | int]
|
|
58
|
+
packet_id: int | None = None
|
|
59
|
+
topic: str | None = None
|
|
60
|
+
payload: bytes = b""
|
|
61
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
qos: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class MQTTSession:
|
|
67
|
+
"""MQTT session information.
|
|
68
|
+
|
|
69
|
+
Tracks connection details and topic subscriptions for a client.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
client_id: Client identifier string.
|
|
73
|
+
username: Authentication username (optional).
|
|
74
|
+
protocol_version: MQTT protocol version ("3.1.1" or "5.0").
|
|
75
|
+
keep_alive: Keep-alive interval in seconds.
|
|
76
|
+
clean_session: Clean session flag (3.1.1) or clean start flag (5.0).
|
|
77
|
+
will_topic: Last Will and Testament topic (optional).
|
|
78
|
+
will_message: Last Will and Testament message payload (optional).
|
|
79
|
+
subscribed_topics: List of subscribed topic filters.
|
|
80
|
+
published_topics: Dictionary mapping topics to publish counts.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
>>> session = MQTTSession(
|
|
84
|
+
... client_id="sensor_01",
|
|
85
|
+
... username="admin",
|
|
86
|
+
... protocol_version="3.1.1",
|
|
87
|
+
... keep_alive=60,
|
|
88
|
+
... )
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
client_id: str
|
|
92
|
+
username: str | None = None
|
|
93
|
+
protocol_version: str = "3.1.1"
|
|
94
|
+
keep_alive: int = 60
|
|
95
|
+
clean_session: bool = True
|
|
96
|
+
will_topic: str | None = None
|
|
97
|
+
will_message: bytes | None = None
|
|
98
|
+
subscribed_topics: list[str] = field(default_factory=list)
|
|
99
|
+
published_topics: dict[str, int] = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MQTTAnalyzer:
|
|
103
|
+
"""MQTT protocol analyzer for versions 3.1.1 and 5.0.
|
|
104
|
+
|
|
105
|
+
Parses MQTT control packets, tracks sessions, and builds topic hierarchies.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
PACKET_TYPES: Mapping of packet type codes to names.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> analyzer = MQTTAnalyzer()
|
|
112
|
+
>>> packet = analyzer.parse_packet(mqtt_data)
|
|
113
|
+
>>> hierarchy = analyzer.get_topic_hierarchy()
|
|
114
|
+
>>> analyzer.export_topology(Path("mqtt_topology.json"))
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# MQTT control packet types
|
|
118
|
+
PACKET_TYPES: ClassVar[dict[int, str]] = {
|
|
119
|
+
1: "CONNECT",
|
|
120
|
+
2: "CONNACK",
|
|
121
|
+
3: "PUBLISH",
|
|
122
|
+
4: "PUBACK",
|
|
123
|
+
5: "PUBREC",
|
|
124
|
+
6: "PUBREL",
|
|
125
|
+
7: "PUBCOMP",
|
|
126
|
+
8: "SUBSCRIBE",
|
|
127
|
+
9: "SUBACK",
|
|
128
|
+
10: "UNSUBSCRIBE",
|
|
129
|
+
11: "UNSUBACK",
|
|
130
|
+
12: "PINGREQ",
|
|
131
|
+
13: "PINGRESP",
|
|
132
|
+
14: "DISCONNECT",
|
|
133
|
+
15: "AUTH",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
"""Initialize MQTT analyzer.
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
>>> analyzer = MQTTAnalyzer()
|
|
141
|
+
>>> len(analyzer.packets)
|
|
142
|
+
0
|
|
143
|
+
"""
|
|
144
|
+
self.packets: list[MQTTPacket] = []
|
|
145
|
+
self.sessions: dict[str, MQTTSession] = {}
|
|
146
|
+
self.topics: set[str] = set()
|
|
147
|
+
|
|
148
|
+
def _calculate_header_size(self, data: bytes) -> int:
|
|
149
|
+
"""Calculate fixed header size including length encoding.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
data: Raw packet data
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Size of fixed header in bytes
|
|
156
|
+
"""
|
|
157
|
+
header_size = 1
|
|
158
|
+
temp_data = data[1:]
|
|
159
|
+
while temp_data and (temp_data[0] & 0x80):
|
|
160
|
+
header_size += 1
|
|
161
|
+
temp_data = temp_data[1:]
|
|
162
|
+
return header_size + 1
|
|
163
|
+
|
|
164
|
+
def _parse_packet_data(
|
|
165
|
+
self, packet_type: str, var_header_payload: bytes, flags: int
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
"""Parse variable header and payload based on packet type.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
packet_type: Type of MQTT packet
|
|
171
|
+
var_header_payload: Variable header and payload bytes
|
|
172
|
+
flags: Packet flags
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Parsed packet data dictionary
|
|
176
|
+
"""
|
|
177
|
+
parsers: dict[str, Any] = {
|
|
178
|
+
"CONNECT": lambda: self._parse_connect(var_header_payload),
|
|
179
|
+
"PUBLISH": lambda: self._parse_publish(var_header_payload, flags),
|
|
180
|
+
"SUBSCRIBE": lambda: self._parse_subscribe(var_header_payload),
|
|
181
|
+
"SUBACK": lambda: self._parse_suback(var_header_payload),
|
|
182
|
+
"UNSUBSCRIBE": lambda: self._parse_unsubscribe(var_header_payload),
|
|
183
|
+
"CONNACK": lambda: self._parse_connack(var_header_payload),
|
|
184
|
+
"DISCONNECT": lambda: self._parse_disconnect(var_header_payload),
|
|
185
|
+
"AUTH": lambda: self._parse_auth(var_header_payload),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if packet_type in parsers:
|
|
189
|
+
# Lambda returns dict[str, Any] from parser methods
|
|
190
|
+
from typing import cast
|
|
191
|
+
|
|
192
|
+
return cast("dict[str, Any]", parsers[packet_type]())
|
|
193
|
+
|
|
194
|
+
if packet_type in ["PUBACK", "PUBREC", "PUBREL", "PUBCOMP", "UNSUBACK"]:
|
|
195
|
+
return self._parse_ack(var_header_payload, packet_type)
|
|
196
|
+
|
|
197
|
+
if packet_type in ["PINGREQ", "PINGRESP"]:
|
|
198
|
+
return {}
|
|
199
|
+
|
|
200
|
+
return {"payload": var_header_payload}
|
|
201
|
+
|
|
202
|
+
def _track_packet_metadata(self, packet: MQTTPacket, parsed_data: dict[str, Any]) -> None:
|
|
203
|
+
"""Track packet metadata (topics, sessions, subscriptions).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
packet: Parsed MQTT packet
|
|
207
|
+
parsed_data: Raw parsed data
|
|
208
|
+
"""
|
|
209
|
+
if packet.topic:
|
|
210
|
+
self.topics.add(packet.topic)
|
|
211
|
+
|
|
212
|
+
if packet.packet_type == "CONNECT" and "client_id" in parsed_data:
|
|
213
|
+
self._track_session(parsed_data)
|
|
214
|
+
|
|
215
|
+
if packet.packet_type == "SUBSCRIBE" and "topics" in parsed_data:
|
|
216
|
+
for topic_filter, _ in parsed_data["topics"]:
|
|
217
|
+
self.topics.add(topic_filter)
|
|
218
|
+
|
|
219
|
+
def parse_packet(self, data: bytes, timestamp: float = 0.0) -> MQTTPacket:
|
|
220
|
+
"""Parse MQTT control packet.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
data: Raw MQTT packet bytes.
|
|
224
|
+
timestamp: Packet timestamp in seconds.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Parsed MQTT packet.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ValueError: If packet is malformed or incomplete.
|
|
231
|
+
|
|
232
|
+
Example:
|
|
233
|
+
>>> analyzer = MQTTAnalyzer()
|
|
234
|
+
>>> # CONNECT packet
|
|
235
|
+
>>> data = b"\\x10\\x10\\x00\\x04MQTT\\x04\\x02\\x00\\x3c\\x00\\x04test"
|
|
236
|
+
>>> packet = analyzer.parse_packet(data)
|
|
237
|
+
>>> packet.packet_type
|
|
238
|
+
'CONNECT'
|
|
239
|
+
"""
|
|
240
|
+
if len(data) < 2:
|
|
241
|
+
raise ValueError("Insufficient data for MQTT packet")
|
|
242
|
+
|
|
243
|
+
# Parse fixed header and extract variable header/payload
|
|
244
|
+
packet_type, var_header_payload, flags = self._extract_packet_components(data)
|
|
245
|
+
|
|
246
|
+
# Parse variable header and payload
|
|
247
|
+
parsed_data = self._parse_packet_data(packet_type, var_header_payload, flags)
|
|
248
|
+
|
|
249
|
+
# Create packet object
|
|
250
|
+
packet = self._create_packet(timestamp, packet_type, parsed_data)
|
|
251
|
+
|
|
252
|
+
# Track metadata
|
|
253
|
+
self.packets.append(packet)
|
|
254
|
+
self._track_packet_metadata(packet, parsed_data)
|
|
255
|
+
|
|
256
|
+
return packet
|
|
257
|
+
|
|
258
|
+
def _extract_packet_components(self, data: bytes) -> tuple[str, bytes, int]:
|
|
259
|
+
"""Extract packet type and variable header/payload from data.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
data: Raw MQTT packet bytes.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Tuple of (packet_type, var_header_payload, flags).
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ValueError: If packet is malformed or incomplete.
|
|
269
|
+
"""
|
|
270
|
+
packet_type_code, flags, remaining_length = self._parse_fixed_header(data)
|
|
271
|
+
|
|
272
|
+
if packet_type_code not in self.PACKET_TYPES:
|
|
273
|
+
raise ValueError(f"Unknown packet type: {packet_type_code}")
|
|
274
|
+
|
|
275
|
+
packet_type = self.PACKET_TYPES[packet_type_code]
|
|
276
|
+
header_size = self._calculate_header_size(data)
|
|
277
|
+
|
|
278
|
+
if len(data) < header_size + remaining_length:
|
|
279
|
+
raise ValueError("Incomplete packet data")
|
|
280
|
+
|
|
281
|
+
var_header_payload = data[header_size : header_size + remaining_length]
|
|
282
|
+
return packet_type, var_header_payload, flags
|
|
283
|
+
|
|
284
|
+
def _create_packet(
|
|
285
|
+
self, timestamp: float, packet_type: str, parsed_data: dict[str, Any]
|
|
286
|
+
) -> MQTTPacket:
|
|
287
|
+
"""Create MQTTPacket from parsed data.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
timestamp: Packet timestamp.
|
|
291
|
+
packet_type: Packet type string.
|
|
292
|
+
parsed_data: Parsed packet data dictionary.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
MQTTPacket instance.
|
|
296
|
+
"""
|
|
297
|
+
return MQTTPacket(
|
|
298
|
+
timestamp=timestamp,
|
|
299
|
+
packet_type=packet_type,
|
|
300
|
+
protocol_version=parsed_data.get("protocol_version", "3.1.1"),
|
|
301
|
+
flags=parsed_data.get("flags", {}),
|
|
302
|
+
packet_id=parsed_data.get("packet_id"),
|
|
303
|
+
topic=parsed_data.get("topic"),
|
|
304
|
+
payload=parsed_data.get("payload", b""),
|
|
305
|
+
properties=parsed_data.get("properties", {}),
|
|
306
|
+
qos=parsed_data.get("qos", 0),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _parse_fixed_header(self, data: bytes) -> tuple[int, int, int]:
|
|
310
|
+
"""Parse MQTT fixed header.
|
|
311
|
+
|
|
312
|
+
The fixed header consists of:
|
|
313
|
+
- Byte 1: Control Packet type (bits 4-7) and Flags (bits 0-3)
|
|
314
|
+
- Byte 2+: Remaining Length (variable length encoding)
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
data: Raw packet data.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Tuple of (packet_type, flags, remaining_length).
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
ValueError: If header is malformed.
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
>>> analyzer = MQTTAnalyzer()
|
|
327
|
+
>>> # PUBLISH packet: type=3, flags=0, length=10
|
|
328
|
+
>>> data = b"\\x30\\x0a..."
|
|
329
|
+
>>> ptype, flags, length = analyzer._parse_fixed_header(data)
|
|
330
|
+
>>> ptype
|
|
331
|
+
3
|
|
332
|
+
"""
|
|
333
|
+
if len(data) < 2:
|
|
334
|
+
raise ValueError("Insufficient data for fixed header")
|
|
335
|
+
|
|
336
|
+
byte1 = data[0]
|
|
337
|
+
packet_type = (byte1 >> 4) & 0x0F
|
|
338
|
+
flags = byte1 & 0x0F
|
|
339
|
+
|
|
340
|
+
# Decode remaining length (variable byte integer)
|
|
341
|
+
multiplier = 1
|
|
342
|
+
value = 0
|
|
343
|
+
index = 1
|
|
344
|
+
|
|
345
|
+
while True:
|
|
346
|
+
if index >= len(data):
|
|
347
|
+
raise ValueError("Incomplete remaining length")
|
|
348
|
+
|
|
349
|
+
encoded_byte = data[index]
|
|
350
|
+
value += (encoded_byte & 0x7F) * multiplier
|
|
351
|
+
|
|
352
|
+
if (encoded_byte & 0x80) == 0:
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
multiplier *= 128
|
|
356
|
+
index += 1
|
|
357
|
+
|
|
358
|
+
if multiplier > 128 * 128 * 128:
|
|
359
|
+
raise ValueError("Remaining length exceeds maximum")
|
|
360
|
+
|
|
361
|
+
return packet_type, flags, value
|
|
362
|
+
|
|
363
|
+
def _parse_mqtt_string(self, data: bytes, offset: int) -> tuple[str, int]:
|
|
364
|
+
"""Parse MQTT UTF-8 encoded string.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
data: Packet data buffer.
|
|
368
|
+
offset: Current offset in buffer.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Tuple of (parsed_string, new_offset).
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
ValueError: If string data is incomplete.
|
|
375
|
+
"""
|
|
376
|
+
if len(data) < offset + 2:
|
|
377
|
+
raise ValueError("Incomplete string length field")
|
|
378
|
+
|
|
379
|
+
str_len = int.from_bytes(data[offset : offset + 2], "big")
|
|
380
|
+
offset += 2
|
|
381
|
+
|
|
382
|
+
if len(data) < offset + str_len:
|
|
383
|
+
raise ValueError("Incomplete string data")
|
|
384
|
+
|
|
385
|
+
parsed_str = data[offset : offset + str_len].decode("utf-8")
|
|
386
|
+
return parsed_str, offset + str_len
|
|
387
|
+
|
|
388
|
+
def _parse_mqtt_binary(self, data: bytes, offset: int) -> tuple[bytes, int]:
|
|
389
|
+
"""Parse MQTT binary data field.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
data: Packet data buffer.
|
|
393
|
+
offset: Current offset in buffer.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Tuple of (binary_data, new_offset).
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
ValueError: If binary data is incomplete.
|
|
400
|
+
"""
|
|
401
|
+
if len(data) < offset + 2:
|
|
402
|
+
raise ValueError("Incomplete binary length field")
|
|
403
|
+
|
|
404
|
+
bin_len = int.from_bytes(data[offset : offset + 2], "big")
|
|
405
|
+
offset += 2
|
|
406
|
+
|
|
407
|
+
if len(data) < offset + bin_len:
|
|
408
|
+
raise ValueError("Incomplete binary data")
|
|
409
|
+
|
|
410
|
+
binary_data = data[offset : offset + bin_len]
|
|
411
|
+
return binary_data, offset + bin_len
|
|
412
|
+
|
|
413
|
+
def _parse_connect_flags(self, connect_flags: int) -> dict[str, bool | int]:
|
|
414
|
+
"""Parse CONNECT packet flags byte.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
connect_flags: Flags byte from CONNECT packet.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Dictionary of parsed flag values.
|
|
421
|
+
"""
|
|
422
|
+
return {
|
|
423
|
+
"clean_session": bool(connect_flags & 0x02),
|
|
424
|
+
"will_flag": bool(connect_flags & 0x04),
|
|
425
|
+
"will_qos": (connect_flags >> 3) & 0x03,
|
|
426
|
+
"will_retain": bool(connect_flags & 0x20),
|
|
427
|
+
"username_flag": bool(connect_flags & 0x40),
|
|
428
|
+
"password_flag": bool(connect_flags & 0x80),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
def _parse_will_data(
|
|
432
|
+
self, data: bytes, offset: int, protocol_version: str
|
|
433
|
+
) -> tuple[str, bytes, int]:
|
|
434
|
+
"""Parse Will topic and message from CONNECT packet.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
data: Packet data buffer.
|
|
438
|
+
offset: Current offset in buffer.
|
|
439
|
+
protocol_version: MQTT protocol version.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Tuple of (will_topic, will_message, new_offset).
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
ValueError: If will data is incomplete.
|
|
446
|
+
"""
|
|
447
|
+
# Will properties (MQTT 5.0 only)
|
|
448
|
+
if protocol_version == "5.0":
|
|
449
|
+
_, consumed = parse_properties(data, offset)
|
|
450
|
+
offset += consumed
|
|
451
|
+
|
|
452
|
+
# Will topic
|
|
453
|
+
will_topic, offset = self._parse_mqtt_string(data, offset)
|
|
454
|
+
|
|
455
|
+
# Will message
|
|
456
|
+
will_message, offset = self._parse_mqtt_binary(data, offset)
|
|
457
|
+
|
|
458
|
+
return will_topic, will_message, offset
|
|
459
|
+
|
|
460
|
+
def _parse_connect(self, data: bytes) -> dict[str, Any]:
|
|
461
|
+
"""Parse CONNECT packet.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
data: Variable header and payload bytes.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Parsed CONNECT fields.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
ValueError: If packet is malformed.
|
|
471
|
+
|
|
472
|
+
Example:
|
|
473
|
+
>>> analyzer = MQTTAnalyzer()
|
|
474
|
+
>>> # MQTT 3.1.1 CONNECT
|
|
475
|
+
>>> data = b"\\x00\\x04MQTT\\x04\\x02\\x00\\x3c\\x00\\x04test"
|
|
476
|
+
>>> result = analyzer._parse_connect(data)
|
|
477
|
+
>>> result["protocol_version"]
|
|
478
|
+
'3.1.1'
|
|
479
|
+
"""
|
|
480
|
+
if len(data) < 10:
|
|
481
|
+
raise ValueError("Insufficient data for CONNECT packet")
|
|
482
|
+
|
|
483
|
+
offset = 0
|
|
484
|
+
|
|
485
|
+
# Protocol name
|
|
486
|
+
protocol_name, offset = self._parse_mqtt_string(data, offset)
|
|
487
|
+
|
|
488
|
+
# Protocol level
|
|
489
|
+
protocol_level = data[offset]
|
|
490
|
+
offset += 1
|
|
491
|
+
|
|
492
|
+
# Map protocol level to version
|
|
493
|
+
version_map = {3: "3.1", 4: "3.1.1", 5: "5.0"}
|
|
494
|
+
protocol_version = version_map.get(protocol_level, "3.1.1")
|
|
495
|
+
|
|
496
|
+
# Connect flags
|
|
497
|
+
connect_flags = data[offset]
|
|
498
|
+
offset += 1
|
|
499
|
+
flags = self._parse_connect_flags(connect_flags)
|
|
500
|
+
|
|
501
|
+
# Keep alive
|
|
502
|
+
keep_alive = int.from_bytes(data[offset : offset + 2], "big")
|
|
503
|
+
offset += 2
|
|
504
|
+
|
|
505
|
+
# MQTT 5.0 properties
|
|
506
|
+
properties: dict[str, Any] = {}
|
|
507
|
+
if protocol_version == "5.0":
|
|
508
|
+
properties, consumed = parse_properties(data, offset)
|
|
509
|
+
offset += consumed
|
|
510
|
+
|
|
511
|
+
# Client ID
|
|
512
|
+
client_id, offset = self._parse_mqtt_string(data, offset)
|
|
513
|
+
|
|
514
|
+
# Will topic and message
|
|
515
|
+
will_topic = None
|
|
516
|
+
will_message = None
|
|
517
|
+
if flags["will_flag"]:
|
|
518
|
+
will_topic, will_message, offset = self._parse_will_data(data, offset, protocol_version)
|
|
519
|
+
|
|
520
|
+
# Username
|
|
521
|
+
username = None
|
|
522
|
+
if flags["username_flag"]:
|
|
523
|
+
username, offset = self._parse_mqtt_string(data, offset)
|
|
524
|
+
|
|
525
|
+
# Password
|
|
526
|
+
password = None
|
|
527
|
+
if flags["password_flag"]:
|
|
528
|
+
password, offset = self._parse_mqtt_binary(data, offset)
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
"protocol_name": protocol_name,
|
|
532
|
+
"protocol_version": protocol_version,
|
|
533
|
+
"flags": flags,
|
|
534
|
+
"keep_alive": keep_alive,
|
|
535
|
+
"properties": properties,
|
|
536
|
+
"client_id": client_id,
|
|
537
|
+
"will_topic": will_topic,
|
|
538
|
+
"will_message": will_message,
|
|
539
|
+
"username": username,
|
|
540
|
+
"password": password,
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
def _parse_publish(self, data: bytes, flags: int) -> dict[str, Any]:
|
|
544
|
+
"""Parse PUBLISH packet.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
data: Variable header and payload bytes.
|
|
548
|
+
flags: Fixed header flags byte.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Parsed PUBLISH fields.
|
|
552
|
+
|
|
553
|
+
Raises:
|
|
554
|
+
ValueError: If packet is malformed.
|
|
555
|
+
|
|
556
|
+
Example:
|
|
557
|
+
>>> analyzer = MQTTAnalyzer()
|
|
558
|
+
>>> # PUBLISH to "test" with QoS 0
|
|
559
|
+
>>> data = b"\\x00\\x04test22.5"
|
|
560
|
+
>>> result = analyzer._parse_publish(data, 0x00)
|
|
561
|
+
>>> result["topic"]
|
|
562
|
+
'test'
|
|
563
|
+
"""
|
|
564
|
+
qos = (flags >> 1) & 0x03
|
|
565
|
+
dup = bool(flags & 0x08)
|
|
566
|
+
retain = bool(flags & 0x01)
|
|
567
|
+
|
|
568
|
+
if len(data) < 2:
|
|
569
|
+
raise ValueError("Insufficient data for topic name length")
|
|
570
|
+
|
|
571
|
+
# Parse topic name
|
|
572
|
+
topic_len = int.from_bytes(data[0:2], "big")
|
|
573
|
+
if len(data) < 2 + topic_len:
|
|
574
|
+
raise ValueError("Insufficient data for topic name")
|
|
575
|
+
|
|
576
|
+
topic = data[2 : 2 + topic_len].decode("utf-8")
|
|
577
|
+
offset = 2 + topic_len
|
|
578
|
+
|
|
579
|
+
# Parse packet identifier (if QoS > 0)
|
|
580
|
+
packet_id = None
|
|
581
|
+
if qos > 0:
|
|
582
|
+
if len(data) < offset + 2:
|
|
583
|
+
raise ValueError("Insufficient data for packet ID")
|
|
584
|
+
packet_id = int.from_bytes(data[offset : offset + 2], "big")
|
|
585
|
+
offset += 2
|
|
586
|
+
|
|
587
|
+
# Parse properties (MQTT 5.0) - detect by checking if next byte looks like property length
|
|
588
|
+
properties: dict[str, Any] = {}
|
|
589
|
+
# We'll skip property parsing here for simplicity in 3.1.1 mode
|
|
590
|
+
# In 5.0, properties would be parsed before payload
|
|
591
|
+
|
|
592
|
+
# Remaining data is payload
|
|
593
|
+
payload = data[offset:]
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
"topic": topic,
|
|
597
|
+
"qos": qos,
|
|
598
|
+
"flags": {"dup": dup, "qos": qos, "retain": retain},
|
|
599
|
+
"packet_id": packet_id,
|
|
600
|
+
"payload": payload,
|
|
601
|
+
"properties": properties,
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
def _parse_subscribe(self, data: bytes) -> dict[str, Any]:
|
|
605
|
+
"""Parse SUBSCRIBE packet.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
data: Variable header and payload bytes.
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
Parsed SUBSCRIBE fields with topic filters.
|
|
612
|
+
|
|
613
|
+
Raises:
|
|
614
|
+
ValueError: If packet is malformed.
|
|
615
|
+
|
|
616
|
+
Example:
|
|
617
|
+
>>> analyzer = MQTTAnalyzer()
|
|
618
|
+
>>> # SUBSCRIBE to "test/+" with QoS 1
|
|
619
|
+
>>> data = b"\\x00\\x01\\x00\\x06test/+\\x01"
|
|
620
|
+
>>> result = analyzer._parse_subscribe(data)
|
|
621
|
+
>>> result["topics"][0]
|
|
622
|
+
('test/+', 1)
|
|
623
|
+
"""
|
|
624
|
+
if len(data) < 2:
|
|
625
|
+
raise ValueError("Insufficient data for packet ID")
|
|
626
|
+
|
|
627
|
+
# Packet identifier
|
|
628
|
+
packet_id = int.from_bytes(data[0:2], "big")
|
|
629
|
+
offset = 2
|
|
630
|
+
|
|
631
|
+
# Properties (MQTT 5.0) - skip for simplicity
|
|
632
|
+
properties: dict[str, Any] = {}
|
|
633
|
+
|
|
634
|
+
# Topic filters
|
|
635
|
+
topics = []
|
|
636
|
+
while offset < len(data):
|
|
637
|
+
if len(data) < offset + 2:
|
|
638
|
+
raise ValueError("Incomplete topic filter")
|
|
639
|
+
|
|
640
|
+
topic_len = int.from_bytes(data[offset : offset + 2], "big")
|
|
641
|
+
offset += 2
|
|
642
|
+
|
|
643
|
+
if len(data) < offset + topic_len:
|
|
644
|
+
raise ValueError("Incomplete topic filter")
|
|
645
|
+
|
|
646
|
+
topic_filter = data[offset : offset + topic_len].decode("utf-8")
|
|
647
|
+
offset += topic_len
|
|
648
|
+
|
|
649
|
+
if offset >= len(data):
|
|
650
|
+
raise ValueError("Missing subscription options")
|
|
651
|
+
|
|
652
|
+
# Subscription options (QoS in lower 2 bits for 3.1.1)
|
|
653
|
+
options = data[offset]
|
|
654
|
+
qos = options & 0x03
|
|
655
|
+
offset += 1
|
|
656
|
+
|
|
657
|
+
topics.append((topic_filter, qos))
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
"packet_id": packet_id,
|
|
661
|
+
"properties": properties,
|
|
662
|
+
"topics": topics,
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
def _parse_ack(self, data: bytes, packet_type: str) -> dict[str, Any]:
|
|
666
|
+
"""Parse acknowledgment packets (PUBACK, PUBREC, PUBREL, PUBCOMP, UNSUBACK).
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
data: Variable header bytes.
|
|
670
|
+
packet_type: Type of acknowledgment packet.
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
Parsed acknowledgment fields.
|
|
674
|
+
|
|
675
|
+
Raises:
|
|
676
|
+
ValueError: If packet is malformed.
|
|
677
|
+
|
|
678
|
+
Example:
|
|
679
|
+
>>> analyzer = MQTTAnalyzer()
|
|
680
|
+
>>> data = b"\\x00\\x01" # Packet ID 1
|
|
681
|
+
>>> result = analyzer._parse_ack(data, "PUBACK")
|
|
682
|
+
>>> result["packet_id"]
|
|
683
|
+
1
|
|
684
|
+
"""
|
|
685
|
+
if len(data) < 2:
|
|
686
|
+
raise ValueError(f"Insufficient data for {packet_type} packet ID")
|
|
687
|
+
|
|
688
|
+
packet_id = int.from_bytes(data[0:2], "big")
|
|
689
|
+
|
|
690
|
+
# MQTT 5.0 may have reason code and properties
|
|
691
|
+
reason_code = None
|
|
692
|
+
properties: dict[str, Any] = {}
|
|
693
|
+
|
|
694
|
+
if len(data) > 2:
|
|
695
|
+
reason_code = data[2]
|
|
696
|
+
if len(data) > 3:
|
|
697
|
+
properties, _ = parse_properties(data, 3)
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
"packet_id": packet_id,
|
|
701
|
+
"reason_code": reason_code,
|
|
702
|
+
"properties": properties,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
def _parse_suback(self, data: bytes) -> dict[str, Any]:
|
|
706
|
+
"""Parse SUBACK packet.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
data: Variable header and payload bytes.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
Parsed SUBACK fields with return codes.
|
|
713
|
+
|
|
714
|
+
Raises:
|
|
715
|
+
ValueError: If packet is malformed.
|
|
716
|
+
|
|
717
|
+
Example:
|
|
718
|
+
>>> analyzer = MQTTAnalyzer()
|
|
719
|
+
>>> data = b"\\x00\\x01\\x00\\x01" # Packet ID 1, QoS 0 and 1 granted
|
|
720
|
+
>>> result = analyzer._parse_suback(data)
|
|
721
|
+
>>> result["return_codes"]
|
|
722
|
+
[0, 1]
|
|
723
|
+
"""
|
|
724
|
+
if len(data) < 2:
|
|
725
|
+
raise ValueError("Insufficient data for SUBACK packet ID")
|
|
726
|
+
|
|
727
|
+
packet_id = int.from_bytes(data[0:2], "big")
|
|
728
|
+
offset = 2
|
|
729
|
+
|
|
730
|
+
# Properties (MQTT 5.0)
|
|
731
|
+
properties: dict[str, Any] = {}
|
|
732
|
+
|
|
733
|
+
# Return codes
|
|
734
|
+
return_codes = list(data[offset:])
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
"packet_id": packet_id,
|
|
738
|
+
"properties": properties,
|
|
739
|
+
"return_codes": return_codes,
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
def _parse_unsubscribe(self, data: bytes) -> dict[str, Any]:
|
|
743
|
+
"""Parse UNSUBSCRIBE packet.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
data: Variable header and payload bytes.
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
Parsed UNSUBSCRIBE fields with topic filters.
|
|
750
|
+
|
|
751
|
+
Raises:
|
|
752
|
+
ValueError: If packet is malformed.
|
|
753
|
+
|
|
754
|
+
Example:
|
|
755
|
+
>>> analyzer = MQTTAnalyzer()
|
|
756
|
+
>>> data = b"\\x00\\x01\\x00\\x04test"
|
|
757
|
+
>>> result = analyzer._parse_unsubscribe(data)
|
|
758
|
+
>>> result["topics"]
|
|
759
|
+
['test']
|
|
760
|
+
"""
|
|
761
|
+
if len(data) < 2:
|
|
762
|
+
raise ValueError("Insufficient data for packet ID")
|
|
763
|
+
|
|
764
|
+
packet_id = int.from_bytes(data[0:2], "big")
|
|
765
|
+
offset = 2
|
|
766
|
+
|
|
767
|
+
# Properties (MQTT 5.0)
|
|
768
|
+
properties: dict[str, Any] = {}
|
|
769
|
+
|
|
770
|
+
# Topic filters
|
|
771
|
+
topics = []
|
|
772
|
+
while offset < len(data):
|
|
773
|
+
if len(data) < offset + 2:
|
|
774
|
+
raise ValueError("Incomplete topic filter")
|
|
775
|
+
|
|
776
|
+
topic_len = int.from_bytes(data[offset : offset + 2], "big")
|
|
777
|
+
offset += 2
|
|
778
|
+
|
|
779
|
+
if len(data) < offset + topic_len:
|
|
780
|
+
raise ValueError("Incomplete topic filter")
|
|
781
|
+
|
|
782
|
+
topic_filter = data[offset : offset + topic_len].decode("utf-8")
|
|
783
|
+
offset += topic_len
|
|
784
|
+
|
|
785
|
+
topics.append(topic_filter)
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
"packet_id": packet_id,
|
|
789
|
+
"properties": properties,
|
|
790
|
+
"topics": topics,
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
def _parse_connack(self, data: bytes) -> dict[str, Any]:
|
|
794
|
+
"""Parse CONNACK packet.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
data: Variable header bytes.
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
Parsed CONNACK fields.
|
|
801
|
+
|
|
802
|
+
Raises:
|
|
803
|
+
ValueError: If packet is malformed.
|
|
804
|
+
|
|
805
|
+
Example:
|
|
806
|
+
>>> analyzer = MQTTAnalyzer()
|
|
807
|
+
>>> data = b"\\x00\\x00" # Session present=0, return code=0 (success)
|
|
808
|
+
>>> result = analyzer._parse_connack(data)
|
|
809
|
+
>>> result["return_code"]
|
|
810
|
+
0
|
|
811
|
+
"""
|
|
812
|
+
if len(data) < 2:
|
|
813
|
+
raise ValueError("Insufficient data for CONNACK")
|
|
814
|
+
|
|
815
|
+
acknowledge_flags = data[0]
|
|
816
|
+
session_present = bool(acknowledge_flags & 0x01)
|
|
817
|
+
|
|
818
|
+
return_code = data[1]
|
|
819
|
+
|
|
820
|
+
# Properties (MQTT 5.0)
|
|
821
|
+
properties: dict[str, Any] = {}
|
|
822
|
+
if len(data) > 2:
|
|
823
|
+
properties, _ = parse_properties(data, 2)
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
"flags": {"session_present": session_present},
|
|
827
|
+
"return_code": return_code,
|
|
828
|
+
"properties": properties,
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
def _parse_disconnect(self, data: bytes) -> dict[str, Any]:
|
|
832
|
+
"""Parse DISCONNECT packet.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
data: Variable header bytes.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
Parsed DISCONNECT fields.
|
|
839
|
+
|
|
840
|
+
Example:
|
|
841
|
+
>>> analyzer = MQTTAnalyzer()
|
|
842
|
+
>>> data = b"" # Empty for MQTT 3.1.1
|
|
843
|
+
>>> result = analyzer._parse_disconnect(data)
|
|
844
|
+
>>> result
|
|
845
|
+
{}
|
|
846
|
+
"""
|
|
847
|
+
# MQTT 3.1.1 has no variable header
|
|
848
|
+
if len(data) == 0:
|
|
849
|
+
return {}
|
|
850
|
+
|
|
851
|
+
# MQTT 5.0 has reason code and properties
|
|
852
|
+
reason_code = data[0] if len(data) > 0 else 0
|
|
853
|
+
properties: dict[str, Any] = {}
|
|
854
|
+
|
|
855
|
+
if len(data) > 1:
|
|
856
|
+
properties, _ = parse_properties(data, 1)
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
"reason_code": reason_code,
|
|
860
|
+
"properties": properties,
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
def _parse_auth(self, data: bytes) -> dict[str, Any]:
|
|
864
|
+
"""Parse AUTH packet (MQTT 5.0 only).
|
|
865
|
+
|
|
866
|
+
Args:
|
|
867
|
+
data: Variable header bytes.
|
|
868
|
+
|
|
869
|
+
Returns:
|
|
870
|
+
Parsed AUTH fields.
|
|
871
|
+
|
|
872
|
+
Raises:
|
|
873
|
+
ValueError: If packet is malformed.
|
|
874
|
+
|
|
875
|
+
Example:
|
|
876
|
+
>>> analyzer = MQTTAnalyzer()
|
|
877
|
+
>>> data = b"\\x00" # Reason code
|
|
878
|
+
>>> result = analyzer._parse_auth(data)
|
|
879
|
+
>>> result["reason_code"]
|
|
880
|
+
0
|
|
881
|
+
"""
|
|
882
|
+
if len(data) < 1:
|
|
883
|
+
raise ValueError("Insufficient data for AUTH")
|
|
884
|
+
|
|
885
|
+
reason_code = data[0]
|
|
886
|
+
properties: dict[str, Any] = {}
|
|
887
|
+
|
|
888
|
+
if len(data) > 1:
|
|
889
|
+
properties, _ = parse_properties(data, 1)
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
"protocol_version": "5.0",
|
|
893
|
+
"reason_code": reason_code,
|
|
894
|
+
"properties": properties,
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
def _track_session(self, connect_data: dict[str, Any]) -> None:
|
|
898
|
+
"""Track MQTT session from CONNECT packet.
|
|
899
|
+
|
|
900
|
+
Args:
|
|
901
|
+
connect_data: Parsed CONNECT packet data.
|
|
902
|
+
|
|
903
|
+
Example:
|
|
904
|
+
>>> analyzer = MQTTAnalyzer()
|
|
905
|
+
>>> connect_data = {
|
|
906
|
+
... "client_id": "test",
|
|
907
|
+
... "username": "admin",
|
|
908
|
+
... "protocol_version": "3.1.1",
|
|
909
|
+
... "keep_alive": 60,
|
|
910
|
+
... "flags": {"clean_session": True},
|
|
911
|
+
... }
|
|
912
|
+
>>> analyzer._track_session(connect_data)
|
|
913
|
+
>>> "test" in analyzer.sessions
|
|
914
|
+
True
|
|
915
|
+
"""
|
|
916
|
+
client_id = connect_data["client_id"]
|
|
917
|
+
|
|
918
|
+
session = MQTTSession(
|
|
919
|
+
client_id=client_id,
|
|
920
|
+
username=connect_data.get("username"),
|
|
921
|
+
protocol_version=connect_data.get("protocol_version", "3.1.1"),
|
|
922
|
+
keep_alive=connect_data.get("keep_alive", 60),
|
|
923
|
+
clean_session=connect_data.get("flags", {}).get("clean_session", True),
|
|
924
|
+
will_topic=connect_data.get("will_topic"),
|
|
925
|
+
will_message=connect_data.get("will_message"),
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
self.sessions[client_id] = session
|
|
929
|
+
|
|
930
|
+
def get_topic_hierarchy(self) -> dict[str, Any]:
|
|
931
|
+
"""Build hierarchical topic tree from observed topics.
|
|
932
|
+
|
|
933
|
+
Topics are split by '/' separator and organized into nested
|
|
934
|
+
dictionaries representing the topic hierarchy.
|
|
935
|
+
|
|
936
|
+
Returns:
|
|
937
|
+
Nested dictionary representing topic tree.
|
|
938
|
+
|
|
939
|
+
Example:
|
|
940
|
+
>>> analyzer = MQTTAnalyzer()
|
|
941
|
+
>>> analyzer.topics = {"home/sensor/temperature", "home/sensor/humidity"}
|
|
942
|
+
>>> tree = analyzer.get_topic_hierarchy()
|
|
943
|
+
>>> "home" in tree
|
|
944
|
+
True
|
|
945
|
+
>>> "sensor" in tree["home"]
|
|
946
|
+
True
|
|
947
|
+
"""
|
|
948
|
+
tree: dict[str, Any] = {}
|
|
949
|
+
|
|
950
|
+
for topic in self.topics:
|
|
951
|
+
parts = topic.split("/")
|
|
952
|
+
current = tree
|
|
953
|
+
|
|
954
|
+
for part in parts:
|
|
955
|
+
if part not in current:
|
|
956
|
+
current[part] = {}
|
|
957
|
+
current = current[part]
|
|
958
|
+
|
|
959
|
+
return tree
|
|
960
|
+
|
|
961
|
+
def export_topology(self, output_path: Path) -> None:
|
|
962
|
+
"""Export topic hierarchy and session information as JSON.
|
|
963
|
+
|
|
964
|
+
Args:
|
|
965
|
+
output_path: Path to output JSON file.
|
|
966
|
+
|
|
967
|
+
Example:
|
|
968
|
+
>>> analyzer = MQTTAnalyzer()
|
|
969
|
+
>>> analyzer.topics = {"test/topic"}
|
|
970
|
+
>>> analyzer.export_topology(Path("topology.json"))
|
|
971
|
+
"""
|
|
972
|
+
topology = {
|
|
973
|
+
"topic_hierarchy": self.get_topic_hierarchy(),
|
|
974
|
+
"topics": sorted(self.topics),
|
|
975
|
+
"sessions": {
|
|
976
|
+
client_id: {
|
|
977
|
+
"client_id": session.client_id,
|
|
978
|
+
"username": session.username,
|
|
979
|
+
"protocol_version": session.protocol_version,
|
|
980
|
+
"keep_alive": session.keep_alive,
|
|
981
|
+
"clean_session": session.clean_session,
|
|
982
|
+
"will_topic": session.will_topic,
|
|
983
|
+
"subscribed_topics": session.subscribed_topics,
|
|
984
|
+
"published_topics": session.published_topics,
|
|
985
|
+
}
|
|
986
|
+
for client_id, session in self.sessions.items()
|
|
987
|
+
},
|
|
988
|
+
"packet_count": len(self.packets),
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
with output_path.open("w", encoding="utf-8") as f:
|
|
992
|
+
json.dump(topology, f, indent=2)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
__all__ = [
|
|
996
|
+
"MQTTAnalyzer",
|
|
997
|
+
"MQTTPacket",
|
|
998
|
+
"MQTTSession",
|
|
999
|
+
]
|