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,708 @@
|
|
|
1
|
+
"""BACnet protocol analyzer for IP and MSTP variants.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive BACnet (Building Automation and Control Networks)
|
|
4
|
+
protocol analysis supporting both BACnet/IP (UDP port 47808) and BACnet/MSTP
|
|
5
|
+
(Master-Slave/Token-Passing serial) variants. Decodes NPDU/APDU layers, all service
|
|
6
|
+
types, and discovers devices and objects for HVAC and building automation systems.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
>>> from oscura.analyzers.protocols.industrial.bacnet import BACnetAnalyzer
|
|
10
|
+
>>> analyzer = BACnetAnalyzer()
|
|
11
|
+
>>> # Parse BACnet/IP message from UDP packet
|
|
12
|
+
>>> udp_payload = bytes([0x81, 0x0A, 0x00, 0x11, 0x01, 0x20, 0x00, 0x08, ...])
|
|
13
|
+
>>> message = analyzer.parse_bacnet_ip(udp_payload, timestamp=0.0)
|
|
14
|
+
>>> print(f"{message.service_name}: {message.decoded_service}")
|
|
15
|
+
>>> # Export discovered devices
|
|
16
|
+
>>> analyzer.export_devices(Path("bacnet_devices.json"))
|
|
17
|
+
|
|
18
|
+
References:
|
|
19
|
+
ANSI/ASHRAE Standard 135-2020 (BACnet):
|
|
20
|
+
https://www.ashrae.org/technical-resources/bookstore/bacnet
|
|
21
|
+
|
|
22
|
+
BACnet/IP (Annex J):
|
|
23
|
+
http://www.bacnet.org/Addenda/Add-135-2016bj-1_chair_approved.pdf
|
|
24
|
+
|
|
25
|
+
BACnet MS/TP (Annex G):
|
|
26
|
+
http://www.bacnet.org/Addenda/Add-135-2012g-5_PPR2-redline.pdf
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, ClassVar
|
|
35
|
+
|
|
36
|
+
from oscura.analyzers.protocols.industrial.bacnet.services import (
|
|
37
|
+
decode_i_am,
|
|
38
|
+
decode_i_have,
|
|
39
|
+
decode_read_property_ack,
|
|
40
|
+
decode_read_property_request,
|
|
41
|
+
decode_who_has,
|
|
42
|
+
decode_who_is,
|
|
43
|
+
decode_write_property_request,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class BACnetMessage:
|
|
49
|
+
"""BACnet message representation.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
timestamp: Message timestamp in seconds.
|
|
53
|
+
protocol: Protocol variant ("BACnet/IP" or "BACnet/MSTP").
|
|
54
|
+
npdu: Network Protocol Data Unit parsed fields.
|
|
55
|
+
apdu_type: APDU type name (Confirmed-REQ, Unconfirmed-REQ, etc.).
|
|
56
|
+
service_choice: Service choice number.
|
|
57
|
+
service_name: Human-readable service name.
|
|
58
|
+
invoke_id: Invoke ID for confirmed services.
|
|
59
|
+
payload: Raw APDU payload bytes.
|
|
60
|
+
decoded_service: Service-specific decoded data.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
timestamp: float
|
|
64
|
+
protocol: str # "BACnet/IP" or "BACnet/MSTP"
|
|
65
|
+
npdu: dict[str, Any]
|
|
66
|
+
apdu_type: str
|
|
67
|
+
service_choice: int | None = None
|
|
68
|
+
service_name: str | None = None
|
|
69
|
+
invoke_id: int | None = None
|
|
70
|
+
payload: bytes = b""
|
|
71
|
+
decoded_service: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class BACnetObject:
|
|
76
|
+
"""BACnet object representation.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
object_type: Object type name (analog-input, binary-output, device, etc.).
|
|
80
|
+
instance_number: Object instance number.
|
|
81
|
+
properties: Observed property values (property_id -> value).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
object_type: str
|
|
85
|
+
instance_number: int
|
|
86
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class BACnetDevice:
|
|
91
|
+
"""BACnet device information.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
device_instance: Device instance number.
|
|
95
|
+
device_name: Device name (if discovered).
|
|
96
|
+
vendor_id: Vendor identifier (if discovered).
|
|
97
|
+
model_name: Model name (if discovered).
|
|
98
|
+
objects: List of discovered objects on this device.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
device_instance: int
|
|
102
|
+
device_name: str | None = None
|
|
103
|
+
vendor_id: int | None = None
|
|
104
|
+
model_name: str | None = None
|
|
105
|
+
objects: list[BACnetObject] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BACnetAnalyzer:
|
|
109
|
+
"""BACnet protocol analyzer for IP and MSTP variants.
|
|
110
|
+
|
|
111
|
+
Supports parsing BACnet/IP (UDP) and BACnet/MSTP (serial) messages,
|
|
112
|
+
decoding NPDU/APDU layers, all service types, and discovering devices
|
|
113
|
+
and objects on the network.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
messages: List of all parsed BACnet messages.
|
|
117
|
+
devices: Dictionary of discovered devices (device_instance -> BACnetDevice).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
# APDU types (ASHRAE 135-2020, Clause 20.1.2)
|
|
121
|
+
APDU_TYPES: ClassVar[dict[int, str]] = {
|
|
122
|
+
0: "Confirmed-REQ",
|
|
123
|
+
1: "Unconfirmed-REQ",
|
|
124
|
+
2: "SimpleACK",
|
|
125
|
+
3: "ComplexACK",
|
|
126
|
+
4: "SegmentACK",
|
|
127
|
+
5: "Error",
|
|
128
|
+
6: "Reject",
|
|
129
|
+
7: "Abort",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Confirmed services (ASHRAE 135-2020, Clause 21.1)
|
|
133
|
+
CONFIRMED_SERVICES: ClassVar[dict[int, str]] = {
|
|
134
|
+
0: "acknowledgeAlarm",
|
|
135
|
+
1: "confirmedCOVNotification",
|
|
136
|
+
2: "confirmedEventNotification",
|
|
137
|
+
3: "getAlarmSummary",
|
|
138
|
+
4: "getEnrollmentSummary",
|
|
139
|
+
5: "subscribeCOV",
|
|
140
|
+
6: "atomicReadFile",
|
|
141
|
+
7: "atomicWriteFile",
|
|
142
|
+
8: "addListElement",
|
|
143
|
+
9: "removeListElement",
|
|
144
|
+
10: "createObject",
|
|
145
|
+
11: "deleteObject",
|
|
146
|
+
12: "readProperty",
|
|
147
|
+
13: "readPropertyConditional",
|
|
148
|
+
14: "readPropertyMultiple",
|
|
149
|
+
15: "writeProperty",
|
|
150
|
+
16: "writePropertyMultiple",
|
|
151
|
+
17: "deviceCommunicationControl",
|
|
152
|
+
18: "confirmedPrivateTransfer",
|
|
153
|
+
19: "confirmedTextMessage",
|
|
154
|
+
20: "reinitializeDevice",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Unconfirmed services (ASHRAE 135-2020, Clause 21.2)
|
|
158
|
+
UNCONFIRMED_SERVICES: ClassVar[dict[int, str]] = {
|
|
159
|
+
0: "i-Am",
|
|
160
|
+
1: "i-Have",
|
|
161
|
+
2: "unconfirmedCOVNotification",
|
|
162
|
+
3: "unconfirmedEventNotification",
|
|
163
|
+
4: "unconfirmedPrivateTransfer",
|
|
164
|
+
5: "unconfirmedTextMessage",
|
|
165
|
+
6: "timeSynchronization",
|
|
166
|
+
7: "who-Has",
|
|
167
|
+
8: "who-Is",
|
|
168
|
+
9: "utcTimeSynchronization",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def __init__(self) -> None:
|
|
172
|
+
"""Initialize BACnet analyzer."""
|
|
173
|
+
self.messages: list[BACnetMessage] = []
|
|
174
|
+
self.devices: dict[int, BACnetDevice] = {}
|
|
175
|
+
|
|
176
|
+
def parse_bacnet_ip(self, udp_payload: bytes, timestamp: float = 0.0) -> BACnetMessage:
|
|
177
|
+
"""Parse BACnet/IP message from UDP payload (port 47808).
|
|
178
|
+
|
|
179
|
+
BACnet/IP messages use the BACnet Virtual Link Control (BVLC) layer
|
|
180
|
+
before the NPDU/APDU layers.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
udp_payload: Raw UDP payload bytes.
|
|
184
|
+
timestamp: Message timestamp in seconds.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Parsed BACnet message.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ValueError: If message is too short or has invalid BVLC header.
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
>>> analyzer = BACnetAnalyzer()
|
|
194
|
+
>>> udp_data = bytes([0x81, 0x0A, 0x00, 0x11, ...])
|
|
195
|
+
>>> msg = analyzer.parse_bacnet_ip(udp_data, timestamp=1.23)
|
|
196
|
+
>>> print(f"{msg.service_name}: {msg.decoded_service}")
|
|
197
|
+
"""
|
|
198
|
+
if len(udp_payload) < 4:
|
|
199
|
+
raise ValueError("BACnet/IP message too short")
|
|
200
|
+
|
|
201
|
+
# Parse BVLC header (Annex J)
|
|
202
|
+
bvlc_type = udp_payload[0]
|
|
203
|
+
bvlc_function = udp_payload[1]
|
|
204
|
+
# bvlc_length = int.from_bytes(udp_payload[2:4], "big") # Not used in current implementation
|
|
205
|
+
|
|
206
|
+
if bvlc_type != 0x81:
|
|
207
|
+
raise ValueError(f"Invalid BACnet/IP type: 0x{bvlc_type:02X} (expected 0x81)")
|
|
208
|
+
|
|
209
|
+
# BVLC function 0x0A = Original-Unicast-NPDU
|
|
210
|
+
# BVLC function 0x0B = Original-Broadcast-NPDU
|
|
211
|
+
if bvlc_function not in (0x0A, 0x0B, 0x04):
|
|
212
|
+
raise ValueError(f"Unsupported BVLC function: 0x{bvlc_function:02X}")
|
|
213
|
+
|
|
214
|
+
# Parse NPDU starting at offset 4
|
|
215
|
+
npdu_data = udp_payload[4:]
|
|
216
|
+
npdu, npdu_len = self._parse_npdu(npdu_data)
|
|
217
|
+
|
|
218
|
+
# Parse APDU
|
|
219
|
+
apdu_data = npdu_data[npdu_len:]
|
|
220
|
+
apdu_dict = self._parse_apdu(apdu_data)
|
|
221
|
+
|
|
222
|
+
# Decode service-specific payload
|
|
223
|
+
decoded_service = self._decode_service(
|
|
224
|
+
apdu_dict["apdu_type"],
|
|
225
|
+
apdu_dict.get("service_choice"),
|
|
226
|
+
apdu_dict.get("service_data", b""),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
message = BACnetMessage(
|
|
230
|
+
timestamp=timestamp,
|
|
231
|
+
protocol="BACnet/IP",
|
|
232
|
+
npdu=npdu,
|
|
233
|
+
apdu_type=self.APDU_TYPES.get(apdu_dict["apdu_type"], "Unknown"),
|
|
234
|
+
service_choice=apdu_dict.get("service_choice"),
|
|
235
|
+
service_name=apdu_dict.get("service_name"),
|
|
236
|
+
invoke_id=apdu_dict.get("invoke_id"),
|
|
237
|
+
payload=apdu_data,
|
|
238
|
+
decoded_service=decoded_service,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
self.messages.append(message)
|
|
242
|
+
self._update_device_info(message)
|
|
243
|
+
|
|
244
|
+
return message
|
|
245
|
+
|
|
246
|
+
def parse_bacnet_mstp(self, serial_data: bytes, timestamp: float = 0.0) -> BACnetMessage:
|
|
247
|
+
"""Parse BACnet MS/TP (Master-Slave/Token-Passing) frame from serial data.
|
|
248
|
+
|
|
249
|
+
BACnet MS/TP is a token-passing protocol for serial (RS-485) links.
|
|
250
|
+
Frame format: Preamble (0x55 0xFF) + Header (6 bytes) + Data + CRC.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
serial_data: Raw serial data bytes.
|
|
254
|
+
timestamp: Message timestamp in seconds.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Parsed BACnet message.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If frame is too short or has invalid preamble/CRC.
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
>>> analyzer = BACnetAnalyzer()
|
|
264
|
+
>>> serial_frame = bytes([0x55, 0xFF, 0x05, 0x01, 0x00, ...])
|
|
265
|
+
>>> msg = analyzer.parse_bacnet_mstp(serial_frame, timestamp=2.34)
|
|
266
|
+
"""
|
|
267
|
+
if len(serial_data) < 8:
|
|
268
|
+
raise ValueError("BACnet MSTP frame too short")
|
|
269
|
+
|
|
270
|
+
# Check preamble (Annex G)
|
|
271
|
+
if serial_data[0] != 0x55 or serial_data[1] != 0xFF:
|
|
272
|
+
raise ValueError("Invalid MSTP preamble (expected 0x55 0xFF)")
|
|
273
|
+
|
|
274
|
+
# Parse MSTP header (6 bytes after preamble)
|
|
275
|
+
frame_type = serial_data[2]
|
|
276
|
+
# destination_address = serial_data[3] # Not used in current implementation
|
|
277
|
+
# source_address = serial_data[4] # Not used in current implementation
|
|
278
|
+
data_length = int.from_bytes(serial_data[5:7], "big")
|
|
279
|
+
header_crc = serial_data[7]
|
|
280
|
+
|
|
281
|
+
# Verify header CRC (simple XOR for demonstration; real MSTP uses proper CRC)
|
|
282
|
+
calculated_header_crc = self._mstp_header_crc(serial_data[2:7])
|
|
283
|
+
if calculated_header_crc != header_crc:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"MSTP header CRC mismatch: {header_crc:02X} != {calculated_header_crc:02X}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Extract data and verify data CRC if present
|
|
289
|
+
if data_length > 0:
|
|
290
|
+
if len(serial_data) < 8 + data_length + 2:
|
|
291
|
+
raise ValueError("MSTP frame truncated")
|
|
292
|
+
data_payload = serial_data[8 : 8 + data_length]
|
|
293
|
+
data_crc = int.from_bytes(serial_data[8 + data_length : 8 + data_length + 2], "big")
|
|
294
|
+
|
|
295
|
+
calculated_data_crc = self._mstp_data_crc(data_payload)
|
|
296
|
+
if calculated_data_crc != data_crc:
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"MSTP data CRC mismatch: {data_crc:04X} != {calculated_data_crc:04X}"
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
data_payload = b""
|
|
302
|
+
|
|
303
|
+
# Parse NPDU from data payload (frame type 0x05 = BACnet Data Expecting Reply)
|
|
304
|
+
if frame_type in (0x00, 0x01, 0x05): # Data frames
|
|
305
|
+
npdu, npdu_len = self._parse_npdu(data_payload)
|
|
306
|
+
apdu_data = data_payload[npdu_len:]
|
|
307
|
+
apdu_dict = self._parse_apdu(apdu_data)
|
|
308
|
+
|
|
309
|
+
decoded_service = self._decode_service(
|
|
310
|
+
apdu_dict["apdu_type"],
|
|
311
|
+
apdu_dict.get("service_choice"),
|
|
312
|
+
apdu_dict.get("service_data", b""),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
message = BACnetMessage(
|
|
316
|
+
timestamp=timestamp,
|
|
317
|
+
protocol="BACnet/MSTP",
|
|
318
|
+
npdu=npdu,
|
|
319
|
+
apdu_type=self.APDU_TYPES.get(apdu_dict["apdu_type"], "Unknown"),
|
|
320
|
+
service_choice=apdu_dict.get("service_choice"),
|
|
321
|
+
service_name=apdu_dict.get("service_name"),
|
|
322
|
+
invoke_id=apdu_dict.get("invoke_id"),
|
|
323
|
+
payload=apdu_data,
|
|
324
|
+
decoded_service=decoded_service,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
self.messages.append(message)
|
|
328
|
+
self._update_device_info(message)
|
|
329
|
+
|
|
330
|
+
return message
|
|
331
|
+
else:
|
|
332
|
+
# Non-data frame (token, poll, etc.)
|
|
333
|
+
raise ValueError(f"MSTP frame type {frame_type:02X} not supported")
|
|
334
|
+
|
|
335
|
+
def _parse_npdu(self, data: bytes) -> tuple[dict[str, Any], int]:
|
|
336
|
+
"""Parse NPDU (Network Protocol Data Unit).
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
data: Raw NPDU bytes.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Tuple of (npdu_dict, bytes_consumed).
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
ValueError: If NPDU is too short or has invalid version.
|
|
346
|
+
"""
|
|
347
|
+
if len(data) < 2:
|
|
348
|
+
raise ValueError("NPDU too short")
|
|
349
|
+
|
|
350
|
+
version = data[0]
|
|
351
|
+
control = data[1]
|
|
352
|
+
offset = 2
|
|
353
|
+
|
|
354
|
+
if version != 0x01:
|
|
355
|
+
raise ValueError(f"Invalid NPDU version: {version} (expected 0x01)")
|
|
356
|
+
|
|
357
|
+
npdu_dict: dict[str, Any] = {
|
|
358
|
+
"version": version,
|
|
359
|
+
"control": control,
|
|
360
|
+
"network_priority": (control >> 0) & 0x03,
|
|
361
|
+
"dest_specifier": bool(control & 0x20),
|
|
362
|
+
"source_specifier": bool(control & 0x08),
|
|
363
|
+
"expects_reply": bool(control & 0x04),
|
|
364
|
+
"is_network_message": bool(control & 0x80),
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
# Parse destination network address if present
|
|
368
|
+
if npdu_dict["dest_specifier"]:
|
|
369
|
+
if offset + 3 > len(data):
|
|
370
|
+
return npdu_dict, offset
|
|
371
|
+
dest_network = int.from_bytes(data[offset : offset + 2], "big")
|
|
372
|
+
dest_mac_len = data[offset + 2]
|
|
373
|
+
offset += 3
|
|
374
|
+
|
|
375
|
+
if offset + dest_mac_len > len(data):
|
|
376
|
+
return npdu_dict, offset
|
|
377
|
+
dest_mac = data[offset : offset + dest_mac_len]
|
|
378
|
+
offset += dest_mac_len
|
|
379
|
+
|
|
380
|
+
npdu_dict["dest_network"] = dest_network
|
|
381
|
+
npdu_dict["dest_mac"] = dest_mac.hex()
|
|
382
|
+
|
|
383
|
+
# Parse source network address if present
|
|
384
|
+
if npdu_dict["source_specifier"]:
|
|
385
|
+
if offset + 3 > len(data):
|
|
386
|
+
return npdu_dict, offset
|
|
387
|
+
source_network = int.from_bytes(data[offset : offset + 2], "big")
|
|
388
|
+
source_mac_len = data[offset + 2]
|
|
389
|
+
offset += 3
|
|
390
|
+
|
|
391
|
+
if offset + source_mac_len > len(data):
|
|
392
|
+
return npdu_dict, offset
|
|
393
|
+
source_mac = data[offset : offset + source_mac_len]
|
|
394
|
+
offset += source_mac_len
|
|
395
|
+
|
|
396
|
+
npdu_dict["source_network"] = source_network
|
|
397
|
+
npdu_dict["source_mac"] = source_mac.hex()
|
|
398
|
+
|
|
399
|
+
# Parse hop count if destination specified
|
|
400
|
+
if npdu_dict["dest_specifier"]:
|
|
401
|
+
if offset < len(data):
|
|
402
|
+
npdu_dict["hop_count"] = data[offset]
|
|
403
|
+
offset += 1
|
|
404
|
+
|
|
405
|
+
# Parse network message type if network message
|
|
406
|
+
if npdu_dict["is_network_message"]:
|
|
407
|
+
if offset < len(data):
|
|
408
|
+
npdu_dict["network_message_type"] = data[offset]
|
|
409
|
+
offset += 1
|
|
410
|
+
|
|
411
|
+
return npdu_dict, offset
|
|
412
|
+
|
|
413
|
+
def _parse_apdu(self, data: bytes) -> dict[str, Any]:
|
|
414
|
+
"""Parse APDU (Application Protocol Data Unit).
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
data: Raw APDU bytes.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Dictionary with apdu_type, service_choice, invoke_id, and service_data.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
ValueError: If APDU is too short.
|
|
424
|
+
"""
|
|
425
|
+
if len(data) < 1:
|
|
426
|
+
raise ValueError("APDU too short")
|
|
427
|
+
|
|
428
|
+
apdu_type = (data[0] >> 4) & 0x0F
|
|
429
|
+
apdu_dict: dict[str, Any] = {"apdu_type": apdu_type}
|
|
430
|
+
|
|
431
|
+
if apdu_type == 0: # Confirmed-REQ
|
|
432
|
+
self._parse_confirmed_req(data, apdu_dict)
|
|
433
|
+
elif apdu_type == 1: # Unconfirmed-REQ
|
|
434
|
+
self._parse_unconfirmed_req(data, apdu_dict)
|
|
435
|
+
elif apdu_type == 2: # SimpleACK
|
|
436
|
+
self._parse_simple_ack(data, apdu_dict)
|
|
437
|
+
elif apdu_type == 3: # ComplexACK
|
|
438
|
+
self._parse_complex_ack(data, apdu_dict)
|
|
439
|
+
elif apdu_type == 5: # Error
|
|
440
|
+
self._parse_error(data, apdu_dict)
|
|
441
|
+
elif apdu_type == 6: # Reject
|
|
442
|
+
self._parse_reject(data, apdu_dict)
|
|
443
|
+
elif apdu_type == 7: # Abort
|
|
444
|
+
self._parse_abort(data, apdu_dict)
|
|
445
|
+
|
|
446
|
+
return apdu_dict
|
|
447
|
+
|
|
448
|
+
def _parse_confirmed_req(self, data: bytes, apdu_dict: dict[str, Any]) -> None:
|
|
449
|
+
"""Parse Confirmed-REQ APDU type."""
|
|
450
|
+
if len(data) < 3:
|
|
451
|
+
raise ValueError("Confirmed-REQ APDU too short")
|
|
452
|
+
|
|
453
|
+
segmented = bool(data[0] & 0x08)
|
|
454
|
+
more_follows = bool(data[0] & 0x04)
|
|
455
|
+
segmented_response_accepted = bool(data[0] & 0x02)
|
|
456
|
+
max_segments = data[1] >> 4
|
|
457
|
+
max_apdu = data[1] & 0x0F
|
|
458
|
+
invoke_id = data[2]
|
|
459
|
+
service_choice = data[3] if len(data) > 3 else 0
|
|
460
|
+
|
|
461
|
+
apdu_dict.update(
|
|
462
|
+
{
|
|
463
|
+
"segmented": segmented,
|
|
464
|
+
"more_follows": more_follows,
|
|
465
|
+
"segmented_response_accepted": segmented_response_accepted,
|
|
466
|
+
"max_segments": max_segments,
|
|
467
|
+
"max_apdu": max_apdu,
|
|
468
|
+
"invoke_id": invoke_id,
|
|
469
|
+
"service_choice": service_choice,
|
|
470
|
+
"service_name": self.CONFIRMED_SERVICES.get(
|
|
471
|
+
service_choice, f"service-{service_choice}"
|
|
472
|
+
),
|
|
473
|
+
"service_data": data[4:] if len(data) > 4 else b"",
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _parse_unconfirmed_req(self, data: bytes, apdu_dict: dict[str, Any]) -> None:
|
|
478
|
+
"""Parse Unconfirmed-REQ APDU type."""
|
|
479
|
+
if len(data) < 2:
|
|
480
|
+
raise ValueError("Unconfirmed-REQ APDU too short")
|
|
481
|
+
|
|
482
|
+
service_choice = data[1]
|
|
483
|
+
apdu_dict.update(
|
|
484
|
+
{
|
|
485
|
+
"service_choice": service_choice,
|
|
486
|
+
"service_name": self.UNCONFIRMED_SERVICES.get(
|
|
487
|
+
service_choice, f"service-{service_choice}"
|
|
488
|
+
),
|
|
489
|
+
"service_data": data[2:] if len(data) > 2 else b"",
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def _parse_simple_ack(self, data: bytes, apdu_dict: dict[str, Any]) -> None:
|
|
494
|
+
"""Parse SimpleACK APDU type."""
|
|
495
|
+
if len(data) < 3:
|
|
496
|
+
raise ValueError("SimpleACK APDU too short")
|
|
497
|
+
|
|
498
|
+
invoke_id = data[1]
|
|
499
|
+
service_choice = data[2]
|
|
500
|
+
apdu_dict.update(
|
|
501
|
+
{
|
|
502
|
+
"invoke_id": invoke_id,
|
|
503
|
+
"service_choice": service_choice,
|
|
504
|
+
"service_name": self.CONFIRMED_SERVICES.get(
|
|
505
|
+
service_choice, f"service-{service_choice}"
|
|
506
|
+
),
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _parse_complex_ack(self, data: bytes, apdu_dict: dict[str, Any]) -> None:
|
|
511
|
+
"""Parse ComplexACK APDU type."""
|
|
512
|
+
if len(data) < 3:
|
|
513
|
+
raise ValueError("ComplexACK APDU too short")
|
|
514
|
+
|
|
515
|
+
segmented = bool(data[0] & 0x08)
|
|
516
|
+
more_follows = bool(data[0] & 0x04)
|
|
517
|
+
invoke_id = data[1]
|
|
518
|
+
service_choice = data[2]
|
|
519
|
+
|
|
520
|
+
apdu_dict.update(
|
|
521
|
+
{
|
|
522
|
+
"segmented": segmented,
|
|
523
|
+
"more_follows": more_follows,
|
|
524
|
+
"invoke_id": invoke_id,
|
|
525
|
+
"service_choice": service_choice,
|
|
526
|
+
"service_name": self.CONFIRMED_SERVICES.get(
|
|
527
|
+
service_choice, f"service-{service_choice}"
|
|
528
|
+
),
|
|
529
|
+
"service_data": data[3:] if len(data) > 3 else b"",
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def _parse_error(self, data: bytes, apdu_dict: dict[str, Any]) -> None:
|
|
534
|
+
"""Parse Error APDU type."""
|
|
535
|
+
if len(data) < 3:
|
|
536
|
+
raise ValueError("Error APDU too short")
|
|
537
|
+
|
|
538
|
+
invoke_id = data[1]
|
|
539
|
+
service_choice = data[2]
|
|
540
|
+
apdu_dict.update(
|
|
541
|
+
{
|
|
542
|
+
"invoke_id": invoke_id,
|
|
543
|
+
"service_choice": service_choice,
|
|
544
|
+
"service_name": self.CONFIRMED_SERVICES.get(
|
|
545
|
+
service_choice, f"service-{service_choice}"
|
|
546
|
+
),
|
|
547
|
+
"service_data": data[3:] if len(data) > 3 else b"",
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _parse_reject(self, data: bytes, apdu_dict: dict[str, Any]) -> None:
|
|
552
|
+
"""Parse Reject APDU type."""
|
|
553
|
+
if len(data) < 3:
|
|
554
|
+
raise ValueError("Reject APDU too short")
|
|
555
|
+
|
|
556
|
+
invoke_id = data[1]
|
|
557
|
+
reject_reason = data[2]
|
|
558
|
+
apdu_dict.update({"invoke_id": invoke_id, "reject_reason": reject_reason})
|
|
559
|
+
|
|
560
|
+
def _parse_abort(self, data: bytes, apdu_dict: dict[str, Any]) -> None:
|
|
561
|
+
"""Parse Abort APDU type."""
|
|
562
|
+
if len(data) < 3:
|
|
563
|
+
raise ValueError("Abort APDU too short")
|
|
564
|
+
|
|
565
|
+
invoke_id = data[1]
|
|
566
|
+
abort_reason = data[2]
|
|
567
|
+
apdu_dict.update({"invoke_id": invoke_id, "abort_reason": abort_reason})
|
|
568
|
+
|
|
569
|
+
def _decode_service(
|
|
570
|
+
self, apdu_type: int, service_choice: int | None, data: bytes
|
|
571
|
+
) -> dict[str, Any]:
|
|
572
|
+
"""Decode service-specific payload based on APDU type and service choice.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
apdu_type: APDU type number.
|
|
576
|
+
service_choice: Service choice number.
|
|
577
|
+
data: Service payload bytes.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
Decoded service data dictionary.
|
|
581
|
+
"""
|
|
582
|
+
if service_choice is None:
|
|
583
|
+
return {}
|
|
584
|
+
|
|
585
|
+
decoders = {
|
|
586
|
+
1: self._decode_unconfirmed_service,
|
|
587
|
+
0: self._decode_confirmed_service,
|
|
588
|
+
3: self._decode_complex_ack,
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
decoder = decoders.get(apdu_type)
|
|
592
|
+
return decoder(service_choice, data) if decoder else {}
|
|
593
|
+
|
|
594
|
+
def _decode_unconfirmed_service(self, service_choice: int, data: bytes) -> dict[str, Any]:
|
|
595
|
+
"""Decode unconfirmed service payloads."""
|
|
596
|
+
unconfirmed_decoders = {
|
|
597
|
+
0: decode_i_am,
|
|
598
|
+
1: decode_i_have,
|
|
599
|
+
7: decode_who_has,
|
|
600
|
+
8: decode_who_is,
|
|
601
|
+
}
|
|
602
|
+
decoder = unconfirmed_decoders.get(service_choice)
|
|
603
|
+
return decoder(data) if decoder else {}
|
|
604
|
+
|
|
605
|
+
def _decode_confirmed_service(self, service_choice: int, data: bytes) -> dict[str, Any]:
|
|
606
|
+
"""Decode confirmed service request payloads."""
|
|
607
|
+
confirmed_decoders = {
|
|
608
|
+
12: decode_read_property_request,
|
|
609
|
+
15: decode_write_property_request,
|
|
610
|
+
}
|
|
611
|
+
decoder = confirmed_decoders.get(service_choice)
|
|
612
|
+
return decoder(data) if decoder else {}
|
|
613
|
+
|
|
614
|
+
def _decode_complex_ack(self, service_choice: int, data: bytes) -> dict[str, Any]:
|
|
615
|
+
"""Decode ComplexACK response payloads."""
|
|
616
|
+
if service_choice == 12:
|
|
617
|
+
return decode_read_property_ack(data)
|
|
618
|
+
return {}
|
|
619
|
+
|
|
620
|
+
def _update_device_info(self, message: BACnetMessage) -> None:
|
|
621
|
+
"""Update device information from parsed message.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
message: Parsed BACnet message.
|
|
625
|
+
"""
|
|
626
|
+
# Extract device info from I-Am messages
|
|
627
|
+
if message.service_name == "i-Am" and "device_instance" in message.decoded_service:
|
|
628
|
+
device_instance = message.decoded_service["device_instance"]
|
|
629
|
+
|
|
630
|
+
if device_instance not in self.devices:
|
|
631
|
+
self.devices[device_instance] = BACnetDevice(device_instance=device_instance)
|
|
632
|
+
|
|
633
|
+
device = self.devices[device_instance]
|
|
634
|
+
|
|
635
|
+
# Update device properties from I-Am
|
|
636
|
+
if "vendor_id" in message.decoded_service:
|
|
637
|
+
device.vendor_id = message.decoded_service["vendor_id"]
|
|
638
|
+
|
|
639
|
+
def _mstp_header_crc(self, header: bytes) -> int:
|
|
640
|
+
"""Calculate MSTP header CRC (simplified XOR for demonstration).
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
header: Header bytes (5 bytes).
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
CRC value.
|
|
647
|
+
"""
|
|
648
|
+
# Real implementation should use proper CRC-8 (CCITT)
|
|
649
|
+
# This is a simplified version for demonstration
|
|
650
|
+
crc = 0xFF
|
|
651
|
+
for byte in header:
|
|
652
|
+
crc ^= byte
|
|
653
|
+
return crc
|
|
654
|
+
|
|
655
|
+
def _mstp_data_crc(self, data: bytes) -> int:
|
|
656
|
+
"""Calculate MSTP data CRC (simplified for demonstration).
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
data: Data bytes.
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
CRC value (16-bit).
|
|
663
|
+
"""
|
|
664
|
+
# Real implementation should use proper CRC-16 (CCITT)
|
|
665
|
+
# This is a simplified version for demonstration
|
|
666
|
+
crc = 0xFFFF
|
|
667
|
+
for byte in data:
|
|
668
|
+
crc ^= byte << 8
|
|
669
|
+
for _ in range(8):
|
|
670
|
+
if crc & 0x8000:
|
|
671
|
+
crc = (crc << 1) ^ 0x1021
|
|
672
|
+
else:
|
|
673
|
+
crc <<= 1
|
|
674
|
+
crc &= 0xFFFF
|
|
675
|
+
return crc
|
|
676
|
+
|
|
677
|
+
def export_devices(self, output_path: Path) -> None:
|
|
678
|
+
"""Export discovered devices and object lists as JSON.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
output_path: Output file path for JSON export.
|
|
682
|
+
|
|
683
|
+
Example:
|
|
684
|
+
>>> analyzer = BACnetAnalyzer()
|
|
685
|
+
>>> # ... parse messages ...
|
|
686
|
+
>>> analyzer.export_devices(Path("bacnet_devices.json"))
|
|
687
|
+
"""
|
|
688
|
+
devices_data = []
|
|
689
|
+
|
|
690
|
+
for device in self.devices.values():
|
|
691
|
+
device_dict = {
|
|
692
|
+
"device_instance": device.device_instance,
|
|
693
|
+
"device_name": device.device_name,
|
|
694
|
+
"vendor_id": device.vendor_id,
|
|
695
|
+
"model_name": device.model_name,
|
|
696
|
+
"objects": [
|
|
697
|
+
{
|
|
698
|
+
"object_type": obj.object_type,
|
|
699
|
+
"instance_number": obj.instance_number,
|
|
700
|
+
"properties": obj.properties,
|
|
701
|
+
}
|
|
702
|
+
for obj in device.objects
|
|
703
|
+
],
|
|
704
|
+
}
|
|
705
|
+
devices_data.append(device_dict)
|
|
706
|
+
|
|
707
|
+
with output_path.open("w") as f:
|
|
708
|
+
json.dump(devices_data, f, indent=2)
|