oscura 0.5.0__py3-none-any.whl → 0.6.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/__init__.py +0 -48
- oscura/analyzers/digital/edges.py +325 -65
- oscura/analyzers/digital/extraction.py +0 -195
- 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/__init__.py +1 -22
- 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 +2763 -0
- 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/core/schemas/bus_configuration.json +322 -0
- oscura/core/schemas/device_mapping.json +182 -0
- oscura/core/schemas/packet_format.json +418 -0
- oscura/core/schemas/protocol_definition.json +363 -0
- 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 -20
- oscura/export/kaitai_struct.py +513 -0
- oscura/export/scapy_layer.py +801 -0
- oscura/export/wireshark/README.md +15 -15
- 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/README.md +7 -7
- 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 +171 -63
- 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/vcd.py +215 -117
- oscura/loaders/wav.py +155 -68
- oscura/reporting/__init__.py +9 -7
- 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/reporting/templates/index.md +13 -13
- 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/autodetect.py +1 -5
- 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 +11 -3
- 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.6.0.dist-info/METADATA +643 -0
- oscura-0.6.0.dist-info/RECORD +590 -0
- oscura/analyzers/digital/ic_database.py +0 -498
- oscura/analyzers/digital/timing_paths.py +0 -339
- oscura/analyzers/digital/vintage.py +0 -377
- oscura/analyzers/digital/vintage_result.py +0 -148
- oscura/analyzers/protocols/parallel_bus.py +0 -449
- 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/export/wavedrom.py +0 -430
- 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 -338
- 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/exporters/vintage_logic_csv.py +0 -247
- oscura/reporting/vintage_logic_report.py +0 -523
- 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/visualization/digital_advanced.py +0 -718
- oscura/visualization/figure_manager.py +0 -156
- oscura/workflow/__init__.py +0 -13
- oscura-0.5.0.dist-info/METADATA +0 -407
- oscura-0.5.0.dist-info/RECORD +0 -486
- /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/{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.0.dist-info → oscura-0.6.0.dist-info}/WHEEL +0 -0
- {oscura-0.5.0.dist-info → oscura-0.6.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.5.0.dist-info → oscura-0.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Modbus RTU/TCP protocol analyzer.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive Modbus protocol analysis supporting both
|
|
4
|
+
RTU (serial) and TCP (Ethernet) variants. Decodes all standard Modbus function
|
|
5
|
+
codes, validates CRC for RTU, tracks device states, and exports register maps.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from oscura.analyzers.protocols.industrial.modbus.analyzer import ModbusAnalyzer
|
|
9
|
+
>>> analyzer = ModbusAnalyzer()
|
|
10
|
+
>>> # Parse RTU frame
|
|
11
|
+
>>> frame = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xCD, 0xC5])
|
|
12
|
+
>>> message = analyzer.parse_rtu(frame, timestamp=0.0)
|
|
13
|
+
>>> print(f"{message.function_name}: {message.parsed_data}")
|
|
14
|
+
>>> # Parse TCP frame
|
|
15
|
+
>>> tcp_frame = bytes([0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x01, 0x03, ...])
|
|
16
|
+
>>> message = analyzer.parse_tcp(tcp_frame, timestamp=0.0)
|
|
17
|
+
|
|
18
|
+
References:
|
|
19
|
+
Modbus Application Protocol V1.1b3:
|
|
20
|
+
https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
|
|
21
|
+
|
|
22
|
+
Modbus over Serial Line V1.02:
|
|
23
|
+
https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, ClassVar
|
|
32
|
+
|
|
33
|
+
from oscura.analyzers.protocols.industrial.modbus.crc import verify_crc
|
|
34
|
+
from oscura.analyzers.protocols.industrial.modbus.functions import (
|
|
35
|
+
parse_read_coils_request,
|
|
36
|
+
parse_read_coils_response,
|
|
37
|
+
parse_read_discrete_inputs_request,
|
|
38
|
+
parse_read_discrete_inputs_response,
|
|
39
|
+
parse_read_holding_registers_request,
|
|
40
|
+
parse_read_holding_registers_response,
|
|
41
|
+
parse_read_input_registers_request,
|
|
42
|
+
parse_read_input_registers_response,
|
|
43
|
+
parse_write_multiple_coils_request,
|
|
44
|
+
parse_write_multiple_coils_response,
|
|
45
|
+
parse_write_multiple_registers_request,
|
|
46
|
+
parse_write_multiple_registers_response,
|
|
47
|
+
parse_write_single_coil,
|
|
48
|
+
parse_write_single_register,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ModbusMessage:
|
|
54
|
+
"""Modbus message representation.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
timestamp: Message timestamp in seconds.
|
|
58
|
+
variant: Protocol variant ("RTU" or "TCP").
|
|
59
|
+
is_request: True if request, False if response.
|
|
60
|
+
transaction_id: TCP transaction ID (TCP only).
|
|
61
|
+
unit_id: Slave address (RTU) or unit identifier (TCP).
|
|
62
|
+
function_code: Modbus function code.
|
|
63
|
+
function_name: Human-readable function name.
|
|
64
|
+
data: Raw function data bytes.
|
|
65
|
+
exception_code: Exception code if error response.
|
|
66
|
+
parsed_data: Parsed function-specific data.
|
|
67
|
+
crc_valid: CRC validation result (RTU only).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
timestamp: float
|
|
71
|
+
variant: str # "RTU" or "TCP"
|
|
72
|
+
is_request: bool
|
|
73
|
+
transaction_id: int | None = None # TCP only
|
|
74
|
+
unit_id: int = 0
|
|
75
|
+
function_code: int = 0
|
|
76
|
+
function_name: str = ""
|
|
77
|
+
data: bytes = b""
|
|
78
|
+
exception_code: int | None = None
|
|
79
|
+
parsed_data: dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
crc_valid: bool | None = None # RTU only
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ModbusDevice:
|
|
85
|
+
"""Modbus device state information.
|
|
86
|
+
|
|
87
|
+
Tracks the state of a Modbus device including register and coil values
|
|
88
|
+
observed in communication.
|
|
89
|
+
|
|
90
|
+
Attributes:
|
|
91
|
+
unit_id: Device unit ID / slave address.
|
|
92
|
+
function_codes_seen: Set of function codes observed.
|
|
93
|
+
coils: Coil states (address -> value).
|
|
94
|
+
discrete_inputs: Discrete input states (address -> value).
|
|
95
|
+
holding_registers: Holding register values (address -> value).
|
|
96
|
+
input_registers: Input register values (address -> value).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
unit_id: int
|
|
100
|
+
function_codes_seen: set[int] = field(default_factory=set)
|
|
101
|
+
coils: dict[int, bool] = field(default_factory=dict)
|
|
102
|
+
discrete_inputs: dict[int, bool] = field(default_factory=dict)
|
|
103
|
+
holding_registers: dict[int, int] = field(default_factory=dict)
|
|
104
|
+
input_registers: dict[int, int] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ModbusAnalyzer:
|
|
108
|
+
"""Modbus protocol analyzer for RTU and TCP variants.
|
|
109
|
+
|
|
110
|
+
Provides comprehensive Modbus protocol analysis including frame parsing,
|
|
111
|
+
function code decoding, CRC validation, and device state tracking.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
messages: List of parsed Modbus messages.
|
|
115
|
+
devices: Dictionary of device states by unit ID.
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
>>> analyzer = ModbusAnalyzer()
|
|
119
|
+
>>> # Parse RTU frame
|
|
120
|
+
>>> rtu_frame = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xCD, 0xC5])
|
|
121
|
+
>>> msg = analyzer.parse_rtu(rtu_frame)
|
|
122
|
+
>>> print(f"Function: {msg.function_name}, CRC Valid: {msg.crc_valid}")
|
|
123
|
+
>>> # Export device register map
|
|
124
|
+
>>> analyzer.export_register_map(Path("registers.json"))
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
# Standard Modbus function codes
|
|
128
|
+
FUNCTION_CODES: ClassVar[dict[int, str]] = {
|
|
129
|
+
1: "Read Coils",
|
|
130
|
+
2: "Read Discrete Inputs",
|
|
131
|
+
3: "Read Holding Registers",
|
|
132
|
+
4: "Read Input Registers",
|
|
133
|
+
5: "Write Single Coil",
|
|
134
|
+
6: "Write Single Register",
|
|
135
|
+
15: "Write Multiple Coils",
|
|
136
|
+
16: "Write Multiple Registers",
|
|
137
|
+
23: "Read/Write Multiple Registers",
|
|
138
|
+
# Diagnostic and maintenance functions
|
|
139
|
+
8: "Diagnostics",
|
|
140
|
+
11: "Get Comm Event Counter",
|
|
141
|
+
12: "Get Comm Event Log",
|
|
142
|
+
17: "Report Server ID",
|
|
143
|
+
# File and queue operations
|
|
144
|
+
20: "Read File Record",
|
|
145
|
+
21: "Write File Record",
|
|
146
|
+
22: "Mask Write Register",
|
|
147
|
+
24: "Read FIFO Queue",
|
|
148
|
+
43: "Encapsulated Interface Transport",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Exception codes
|
|
152
|
+
EXCEPTION_CODES: ClassVar[dict[int, str]] = {
|
|
153
|
+
1: "Illegal Function",
|
|
154
|
+
2: "Illegal Data Address",
|
|
155
|
+
3: "Illegal Data Value",
|
|
156
|
+
4: "Server Device Failure",
|
|
157
|
+
5: "Acknowledge",
|
|
158
|
+
6: "Server Device Busy",
|
|
159
|
+
8: "Memory Parity Error",
|
|
160
|
+
10: "Gateway Path Unavailable",
|
|
161
|
+
11: "Gateway Target Device Failed to Respond",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def __init__(self) -> None:
|
|
165
|
+
"""Initialize Modbus analyzer."""
|
|
166
|
+
self.messages: list[ModbusMessage] = []
|
|
167
|
+
self.devices: dict[int, ModbusDevice] = {}
|
|
168
|
+
|
|
169
|
+
def parse_rtu(self, data: bytes, timestamp: float = 0.0) -> ModbusMessage:
|
|
170
|
+
"""Parse Modbus RTU frame.
|
|
171
|
+
|
|
172
|
+
RTU Frame Format:
|
|
173
|
+
- Slave Address (1 byte)
|
|
174
|
+
- Function Code (1 byte)
|
|
175
|
+
- Data (N bytes, function-specific)
|
|
176
|
+
- CRC-16 (2 bytes, little-endian)
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
data: Complete RTU frame including CRC.
|
|
180
|
+
timestamp: Message timestamp in seconds.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Parsed Modbus message.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If frame is invalid.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
>>> analyzer = ModbusAnalyzer()
|
|
190
|
+
>>> frame = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xCD, 0xC5])
|
|
191
|
+
>>> msg = analyzer.parse_rtu(frame)
|
|
192
|
+
>>> assert msg.unit_id == 1
|
|
193
|
+
>>> assert msg.function_code == 3
|
|
194
|
+
>>> assert msg.crc_valid is True
|
|
195
|
+
"""
|
|
196
|
+
if len(data) < 4: # Minimum: Address + FC + CRC
|
|
197
|
+
raise ValueError(f"RTU frame too short: {len(data)} bytes (minimum 4)")
|
|
198
|
+
|
|
199
|
+
# Verify CRC
|
|
200
|
+
crc_valid = verify_crc(data)
|
|
201
|
+
|
|
202
|
+
unit_id = data[0]
|
|
203
|
+
function_code = data[1]
|
|
204
|
+
function_data = data[2:-2]
|
|
205
|
+
|
|
206
|
+
# Check for exception response (high bit set in function code)
|
|
207
|
+
is_exception = bool(function_code & 0x80)
|
|
208
|
+
exception_code = None
|
|
209
|
+
parsed_data: dict[str, Any] = {}
|
|
210
|
+
|
|
211
|
+
if is_exception:
|
|
212
|
+
actual_fc = function_code & 0x7F
|
|
213
|
+
if len(function_data) > 0:
|
|
214
|
+
exception_code = function_data[0]
|
|
215
|
+
parsed_data = {
|
|
216
|
+
"exception": self.EXCEPTION_CODES.get(exception_code, "Unknown Exception")
|
|
217
|
+
}
|
|
218
|
+
else:
|
|
219
|
+
actual_fc = function_code
|
|
220
|
+
try:
|
|
221
|
+
# Parse function-specific data
|
|
222
|
+
parsed_data = self._parse_function(actual_fc, function_data, is_request=True)
|
|
223
|
+
except ValueError as e:
|
|
224
|
+
parsed_data = {"parse_error": str(e)}
|
|
225
|
+
|
|
226
|
+
message = ModbusMessage(
|
|
227
|
+
timestamp=timestamp,
|
|
228
|
+
variant="RTU",
|
|
229
|
+
is_request=True, # Determined by context in real usage
|
|
230
|
+
unit_id=unit_id,
|
|
231
|
+
function_code=actual_fc,
|
|
232
|
+
function_name=self.FUNCTION_CODES.get(actual_fc, f"Unknown (0x{actual_fc:02X})"),
|
|
233
|
+
data=function_data,
|
|
234
|
+
exception_code=exception_code,
|
|
235
|
+
parsed_data=parsed_data,
|
|
236
|
+
crc_valid=crc_valid,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
self.messages.append(message)
|
|
240
|
+
self.update_device_state(message)
|
|
241
|
+
|
|
242
|
+
return message
|
|
243
|
+
|
|
244
|
+
def parse_tcp(self, data: bytes, timestamp: float = 0.0) -> ModbusMessage:
|
|
245
|
+
"""Parse Modbus TCP frame.
|
|
246
|
+
|
|
247
|
+
TCP Frame Format (MBAP Header + PDU):
|
|
248
|
+
- Transaction ID (2 bytes, big-endian)
|
|
249
|
+
- Protocol ID (2 bytes, always 0x0000)
|
|
250
|
+
- Length (2 bytes, big-endian, remaining bytes)
|
|
251
|
+
- Unit ID (1 byte)
|
|
252
|
+
- Function Code (1 byte)
|
|
253
|
+
- Data (N bytes, function-specific)
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
data: Complete TCP frame including MBAP header.
|
|
257
|
+
timestamp: Message timestamp in seconds.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Parsed Modbus message.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
ValueError: If frame is invalid.
|
|
264
|
+
|
|
265
|
+
Example:
|
|
266
|
+
>>> analyzer = ModbusAnalyzer()
|
|
267
|
+
>>> # Read Holding Registers request
|
|
268
|
+
>>> frame = bytes([0x00, 0x01, 0x00, 0x00, 0x00, 0x06,
|
|
269
|
+
... 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A])
|
|
270
|
+
>>> msg = analyzer.parse_tcp(frame)
|
|
271
|
+
>>> assert msg.transaction_id == 1
|
|
272
|
+
>>> assert msg.function_code == 3
|
|
273
|
+
"""
|
|
274
|
+
if len(data) < 8: # Minimum: MBAP (7) + FC (1)
|
|
275
|
+
raise ValueError(f"TCP frame too short: {len(data)} bytes (minimum 8)")
|
|
276
|
+
|
|
277
|
+
# Parse MBAP header
|
|
278
|
+
transaction_id = int.from_bytes(data[0:2], "big")
|
|
279
|
+
protocol_id = int.from_bytes(data[2:4], "big")
|
|
280
|
+
length = int.from_bytes(data[4:6], "big")
|
|
281
|
+
unit_id = data[6]
|
|
282
|
+
function_code = data[7]
|
|
283
|
+
function_data = data[8:]
|
|
284
|
+
|
|
285
|
+
if protocol_id != 0:
|
|
286
|
+
raise ValueError(f"Invalid Modbus TCP protocol ID: {protocol_id} (expected 0)")
|
|
287
|
+
|
|
288
|
+
# Verify length field
|
|
289
|
+
expected_length = len(data) - 6 # Everything after protocol ID and length
|
|
290
|
+
if length != expected_length:
|
|
291
|
+
raise ValueError(f"Length mismatch: {length} != {expected_length}")
|
|
292
|
+
|
|
293
|
+
# Check for exception response
|
|
294
|
+
is_exception = bool(function_code & 0x80)
|
|
295
|
+
exception_code = None
|
|
296
|
+
parsed_data: dict[str, Any] = {}
|
|
297
|
+
|
|
298
|
+
if is_exception:
|
|
299
|
+
actual_fc = function_code & 0x7F
|
|
300
|
+
if len(function_data) > 0:
|
|
301
|
+
exception_code = function_data[0]
|
|
302
|
+
parsed_data = {
|
|
303
|
+
"exception": self.EXCEPTION_CODES.get(exception_code, "Unknown Exception")
|
|
304
|
+
}
|
|
305
|
+
else:
|
|
306
|
+
actual_fc = function_code
|
|
307
|
+
try:
|
|
308
|
+
parsed_data = self._parse_function(actual_fc, function_data, is_request=True)
|
|
309
|
+
except ValueError as e:
|
|
310
|
+
parsed_data = {"parse_error": str(e)}
|
|
311
|
+
|
|
312
|
+
message = ModbusMessage(
|
|
313
|
+
timestamp=timestamp,
|
|
314
|
+
variant="TCP",
|
|
315
|
+
is_request=True,
|
|
316
|
+
transaction_id=transaction_id,
|
|
317
|
+
unit_id=unit_id,
|
|
318
|
+
function_code=actual_fc,
|
|
319
|
+
function_name=self.FUNCTION_CODES.get(actual_fc, f"Unknown (0x{actual_fc:02X})"),
|
|
320
|
+
data=function_data,
|
|
321
|
+
exception_code=exception_code,
|
|
322
|
+
parsed_data=parsed_data,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
self.messages.append(message)
|
|
326
|
+
self.update_device_state(message)
|
|
327
|
+
|
|
328
|
+
return message
|
|
329
|
+
|
|
330
|
+
def _parse_function(self, function_code: int, data: bytes, is_request: bool) -> dict[str, Any]:
|
|
331
|
+
"""Parse function-specific data.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
function_code: Modbus function code.
|
|
335
|
+
data: Function data bytes.
|
|
336
|
+
is_request: True if request, False if response.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Parsed function data.
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
ValueError: If parsing fails.
|
|
343
|
+
"""
|
|
344
|
+
# Read operations (1-4)
|
|
345
|
+
if function_code in (1, 2, 3, 4):
|
|
346
|
+
return self._parse_read_function(function_code, data, is_request)
|
|
347
|
+
|
|
348
|
+
# Write operations (5, 6, 15, 16)
|
|
349
|
+
if function_code in (5, 6, 15, 16):
|
|
350
|
+
return self._parse_write_function(function_code, data, is_request)
|
|
351
|
+
|
|
352
|
+
# Unsupported function codes
|
|
353
|
+
return {"raw_data": data.hex()}
|
|
354
|
+
|
|
355
|
+
def _parse_read_function(
|
|
356
|
+
self, function_code: int, data: bytes, is_request: bool
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Parse read function codes (1-4).
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
function_code: Function code (1-4).
|
|
362
|
+
data: Function data bytes.
|
|
363
|
+
is_request: True if request, False if response.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Parsed function data.
|
|
367
|
+
"""
|
|
368
|
+
parsers = {
|
|
369
|
+
1: (parse_read_coils_request, parse_read_coils_response),
|
|
370
|
+
2: (parse_read_discrete_inputs_request, parse_read_discrete_inputs_response),
|
|
371
|
+
3: (parse_read_holding_registers_request, parse_read_holding_registers_response),
|
|
372
|
+
4: (parse_read_input_registers_request, parse_read_input_registers_response),
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
request_parser, response_parser = parsers[function_code]
|
|
376
|
+
return request_parser(data) if is_request else response_parser(data)
|
|
377
|
+
|
|
378
|
+
def _parse_write_function(
|
|
379
|
+
self, function_code: int, data: bytes, is_request: bool
|
|
380
|
+
) -> dict[str, Any]:
|
|
381
|
+
"""Parse write function codes (5, 6, 15, 16).
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
function_code: Function code (5, 6, 15, or 16).
|
|
385
|
+
data: Function data bytes.
|
|
386
|
+
is_request: True if request, False if response.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Parsed function data.
|
|
390
|
+
"""
|
|
391
|
+
# Single write operations (no request/response distinction)
|
|
392
|
+
if function_code == 5:
|
|
393
|
+
return parse_write_single_coil(data)
|
|
394
|
+
if function_code == 6:
|
|
395
|
+
return parse_write_single_register(data)
|
|
396
|
+
|
|
397
|
+
# Multiple write operations
|
|
398
|
+
parsers = {
|
|
399
|
+
15: (parse_write_multiple_coils_request, parse_write_multiple_coils_response),
|
|
400
|
+
16: (parse_write_multiple_registers_request, parse_write_multiple_registers_response),
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
request_parser, response_parser = parsers[function_code]
|
|
404
|
+
return request_parser(data) if is_request else response_parser(data)
|
|
405
|
+
|
|
406
|
+
def update_device_state(self, message: ModbusMessage) -> None:
|
|
407
|
+
"""Update device state based on message.
|
|
408
|
+
|
|
409
|
+
Tracks coil and register values observed in Modbus communication.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
message: Parsed Modbus message.
|
|
413
|
+
"""
|
|
414
|
+
device = self._ensure_device_exists(message.unit_id)
|
|
415
|
+
device.function_codes_seen.add(message.function_code)
|
|
416
|
+
|
|
417
|
+
# Don't update state for exceptions
|
|
418
|
+
if message.exception_code is not None:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
# Dispatch to function-specific handlers
|
|
422
|
+
self._update_state_by_function_code(device, message)
|
|
423
|
+
|
|
424
|
+
def _ensure_device_exists(self, unit_id: int) -> ModbusDevice:
|
|
425
|
+
"""Ensure device exists in registry.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
unit_id: Modbus unit ID.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
ModbusDevice instance.
|
|
432
|
+
"""
|
|
433
|
+
if unit_id not in self.devices:
|
|
434
|
+
self.devices[unit_id] = ModbusDevice(unit_id=unit_id)
|
|
435
|
+
return self.devices[unit_id]
|
|
436
|
+
|
|
437
|
+
def _update_state_by_function_code(self, device: ModbusDevice, message: ModbusMessage) -> None:
|
|
438
|
+
"""Dispatch state update based on function code.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
device: Device to update.
|
|
442
|
+
message: Modbus message.
|
|
443
|
+
"""
|
|
444
|
+
parsed = message.parsed_data
|
|
445
|
+
fc = message.function_code
|
|
446
|
+
|
|
447
|
+
# Coil operations
|
|
448
|
+
if fc == 5:
|
|
449
|
+
self._update_single_coil(device, parsed)
|
|
450
|
+
|
|
451
|
+
# Register operations
|
|
452
|
+
elif fc == 6:
|
|
453
|
+
self._update_single_register(device, parsed)
|
|
454
|
+
elif fc == 16:
|
|
455
|
+
self._update_multiple_registers(device, parsed)
|
|
456
|
+
|
|
457
|
+
def _update_single_coil(self, device: ModbusDevice, parsed: dict[str, Any]) -> None:
|
|
458
|
+
"""Update single coil value.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
device: Device to update.
|
|
462
|
+
parsed: Parsed message data.
|
|
463
|
+
"""
|
|
464
|
+
if "output_address" in parsed and "coil_state" in parsed:
|
|
465
|
+
device.coils[parsed["output_address"]] = parsed["coil_state"]
|
|
466
|
+
|
|
467
|
+
def _update_single_register(self, device: ModbusDevice, parsed: dict[str, Any]) -> None:
|
|
468
|
+
"""Update single register value.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
device: Device to update.
|
|
472
|
+
parsed: Parsed message data.
|
|
473
|
+
"""
|
|
474
|
+
if "register_address" in parsed and "register_value" in parsed:
|
|
475
|
+
device.holding_registers[parsed["register_address"]] = parsed["register_value"]
|
|
476
|
+
|
|
477
|
+
def _update_multiple_registers(self, device: ModbusDevice, parsed: dict[str, Any]) -> None:
|
|
478
|
+
"""Update multiple register values.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
device: Device to update.
|
|
482
|
+
parsed: Parsed message data.
|
|
483
|
+
"""
|
|
484
|
+
if "starting_address" in parsed and "registers" in parsed:
|
|
485
|
+
start_addr = parsed["starting_address"]
|
|
486
|
+
for i, value in enumerate(parsed["registers"]):
|
|
487
|
+
device.holding_registers[start_addr + i] = value
|
|
488
|
+
|
|
489
|
+
def export_register_map(self, output_path: Path) -> None:
|
|
490
|
+
"""Export register map for all devices as JSON.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
output_path: Path to output JSON file.
|
|
494
|
+
|
|
495
|
+
Example:
|
|
496
|
+
>>> analyzer = ModbusAnalyzer()
|
|
497
|
+
>>> # ... parse messages ...
|
|
498
|
+
>>> analyzer.export_register_map(Path("modbus_registers.json"))
|
|
499
|
+
"""
|
|
500
|
+
export_data = {
|
|
501
|
+
"devices": [
|
|
502
|
+
{
|
|
503
|
+
"unit_id": device.unit_id,
|
|
504
|
+
"function_codes": sorted(device.function_codes_seen),
|
|
505
|
+
"coils": {str(k): v for k, v in sorted(device.coils.items())},
|
|
506
|
+
"discrete_inputs": {
|
|
507
|
+
str(k): v for k, v in sorted(device.discrete_inputs.items())
|
|
508
|
+
},
|
|
509
|
+
"holding_registers": {
|
|
510
|
+
str(k): v for k, v in sorted(device.holding_registers.items())
|
|
511
|
+
},
|
|
512
|
+
"input_registers": {
|
|
513
|
+
str(k): v for k, v in sorted(device.input_registers.items())
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
for device in self.devices.values()
|
|
517
|
+
],
|
|
518
|
+
"message_count": len(self.messages),
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
with output_path.open("w") as f:
|
|
522
|
+
json.dump(export_data, f, indent=2)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
__all__ = ["ModbusAnalyzer", "ModbusDevice", "ModbusMessage"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Modbus RTU CRC-16 calculation.
|
|
2
|
+
|
|
3
|
+
This module provides CRC-16 calculation and validation for Modbus RTU frames.
|
|
4
|
+
|
|
5
|
+
The Modbus RTU CRC-16 uses polynomial 0xA001 (reversed 0x8005).
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from oscura.analyzers.protocols.industrial.modbus.crc import calculate_crc
|
|
9
|
+
>>> data = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A])
|
|
10
|
+
>>> crc = calculate_crc(data)
|
|
11
|
+
>>> print(f"CRC: 0x{crc:04X}")
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
Modbus over Serial Line Specification V1.02 (Section 6.2.2)
|
|
15
|
+
https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def calculate_crc(data: bytes) -> int:
|
|
22
|
+
"""Calculate Modbus RTU CRC-16.
|
|
23
|
+
|
|
24
|
+
Uses the polynomial 0xA001 (bit-reversed representation of 0x8005).
|
|
25
|
+
This is the standard CRC-16-IBM/CRC-16-ANSI with reflected input/output.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
data: Data bytes to calculate CRC for (excludes CRC itself).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
16-bit CRC value as integer.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> data = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A])
|
|
35
|
+
>>> crc = calculate_crc(data)
|
|
36
|
+
>>> assert crc == 0xC5CD # Expected CRC for this data
|
|
37
|
+
"""
|
|
38
|
+
crc = 0xFFFF
|
|
39
|
+
|
|
40
|
+
for byte in data:
|
|
41
|
+
crc ^= byte
|
|
42
|
+
for _ in range(8):
|
|
43
|
+
if crc & 0x0001:
|
|
44
|
+
crc = (crc >> 1) ^ 0xA001
|
|
45
|
+
else:
|
|
46
|
+
crc >>= 1
|
|
47
|
+
|
|
48
|
+
return crc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_crc(data: bytes) -> bool:
|
|
52
|
+
"""Verify Modbus RTU CRC-16.
|
|
53
|
+
|
|
54
|
+
Checks if the last 2 bytes of data contain the correct CRC for the
|
|
55
|
+
preceding bytes.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
data: Complete frame including CRC (last 2 bytes, little-endian).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if CRC is valid, False otherwise.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> frame = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xCD, 0xC5])
|
|
65
|
+
>>> assert verify_crc(frame) is True
|
|
66
|
+
"""
|
|
67
|
+
if len(data) < 4: # Minimum: Address + FC + CRC
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Calculate CRC for all bytes except last 2
|
|
71
|
+
calculated = calculate_crc(data[:-2])
|
|
72
|
+
|
|
73
|
+
# Extract CRC from last 2 bytes (little-endian)
|
|
74
|
+
received = int.from_bytes(data[-2:], "little")
|
|
75
|
+
|
|
76
|
+
return calculated == received
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["calculate_crc", "verify_crc"]
|