oscura 0.1.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. oscura/__init__.py +1 -7
  2. oscura/acquisition/__init__.py +147 -0
  3. oscura/acquisition/file.py +255 -0
  4. oscura/acquisition/hardware.py +186 -0
  5. oscura/acquisition/saleae.py +340 -0
  6. oscura/acquisition/socketcan.py +315 -0
  7. oscura/acquisition/streaming.py +38 -0
  8. oscura/acquisition/synthetic.py +229 -0
  9. oscura/acquisition/visa.py +376 -0
  10. oscura/analyzers/__init__.py +3 -0
  11. oscura/analyzers/digital/clock.py +9 -1
  12. oscura/analyzers/digital/edges.py +1 -1
  13. oscura/analyzers/digital/timing.py +41 -11
  14. oscura/analyzers/packet/payload_extraction.py +2 -4
  15. oscura/analyzers/packet/stream.py +5 -5
  16. oscura/analyzers/patterns/__init__.py +4 -3
  17. oscura/analyzers/patterns/clustering.py +3 -1
  18. oscura/analyzers/power/ac_power.py +0 -2
  19. oscura/analyzers/power/basic.py +0 -2
  20. oscura/analyzers/power/ripple.py +0 -2
  21. oscura/analyzers/side_channel/__init__.py +52 -0
  22. oscura/analyzers/side_channel/power.py +690 -0
  23. oscura/analyzers/side_channel/timing.py +369 -0
  24. oscura/analyzers/signal_integrity/embedding.py +0 -2
  25. oscura/analyzers/signal_integrity/sparams.py +28 -206
  26. oscura/analyzers/spectral/fft.py +0 -2
  27. oscura/analyzers/statistical/__init__.py +3 -3
  28. oscura/analyzers/statistical/checksum.py +2 -0
  29. oscura/analyzers/statistical/classification.py +2 -0
  30. oscura/analyzers/statistical/entropy.py +11 -9
  31. oscura/analyzers/statistical/ngrams.py +4 -2
  32. oscura/api/fluent.py +2 -2
  33. oscura/automotive/__init__.py +4 -4
  34. oscura/automotive/can/__init__.py +0 -2
  35. oscura/automotive/can/patterns.py +3 -1
  36. oscura/automotive/can/session.py +277 -78
  37. oscura/automotive/can/state_machine.py +5 -2
  38. oscura/automotive/dbc/__init__.py +0 -2
  39. oscura/automotive/dtc/__init__.py +0 -2
  40. oscura/automotive/dtc/data.json +2763 -0
  41. oscura/automotive/dtc/database.py +37 -2769
  42. oscura/automotive/j1939/__init__.py +0 -2
  43. oscura/automotive/loaders/__init__.py +0 -2
  44. oscura/automotive/loaders/asc.py +0 -2
  45. oscura/automotive/loaders/blf.py +0 -2
  46. oscura/automotive/loaders/csv_can.py +0 -2
  47. oscura/automotive/obd/__init__.py +0 -2
  48. oscura/automotive/uds/__init__.py +0 -2
  49. oscura/automotive/uds/models.py +0 -2
  50. oscura/builders/__init__.py +9 -11
  51. oscura/builders/signal_builder.py +99 -191
  52. oscura/cli/main.py +0 -2
  53. oscura/cli/shell.py +0 -2
  54. oscura/config/loader.py +0 -2
  55. oscura/core/backend_selector.py +1 -1
  56. oscura/core/correlation.py +0 -2
  57. oscura/core/exceptions.py +61 -3
  58. oscura/core/lazy.py +5 -3
  59. oscura/core/memory_limits.py +0 -2
  60. oscura/core/numba_backend.py +5 -7
  61. oscura/core/uncertainty.py +3 -3
  62. oscura/dsl/interpreter.py +2 -0
  63. oscura/dsl/parser.py +8 -6
  64. oscura/exploratory/error_recovery.py +3 -3
  65. oscura/exploratory/parse.py +2 -0
  66. oscura/exploratory/recovery.py +2 -0
  67. oscura/exploratory/sync.py +2 -0
  68. oscura/export/wireshark/generator.py +1 -1
  69. oscura/export/wireshark/type_mapping.py +2 -0
  70. oscura/exporters/hdf5.py +1 -3
  71. oscura/extensibility/templates.py +0 -8
  72. oscura/inference/active_learning/lstar.py +2 -4
  73. oscura/inference/active_learning/observation_table.py +0 -2
  74. oscura/inference/active_learning/oracle.py +3 -1
  75. oscura/inference/active_learning/teachers/simulator.py +1 -3
  76. oscura/inference/alignment.py +2 -0
  77. oscura/inference/message_format.py +2 -0
  78. oscura/inference/protocol_dsl.py +7 -5
  79. oscura/inference/sequences.py +12 -14
  80. oscura/inference/state_machine.py +2 -0
  81. oscura/integrations/llm.py +3 -1
  82. oscura/jupyter/display.py +0 -2
  83. oscura/loaders/__init__.py +68 -51
  84. oscura/loaders/chipwhisperer.py +393 -0
  85. oscura/loaders/pcap.py +1 -1
  86. oscura/loaders/touchstone.py +221 -0
  87. oscura/math/arithmetic.py +0 -2
  88. oscura/optimization/parallel.py +9 -6
  89. oscura/pipeline/composition.py +0 -2
  90. oscura/plugins/cli.py +0 -2
  91. oscura/reporting/comparison.py +0 -2
  92. oscura/reporting/config.py +1 -1
  93. oscura/reporting/formatting/emphasis.py +2 -0
  94. oscura/reporting/formatting/numbers.py +0 -2
  95. oscura/reporting/output.py +1 -3
  96. oscura/reporting/sections.py +0 -2
  97. oscura/search/anomaly.py +2 -0
  98. oscura/session/session.py +91 -16
  99. oscura/sessions/__init__.py +70 -0
  100. oscura/sessions/base.py +323 -0
  101. oscura/sessions/blackbox.py +640 -0
  102. oscura/sessions/generic.py +189 -0
  103. oscura/testing/synthetic.py +2 -0
  104. oscura/ui/formatters.py +4 -2
  105. oscura/utils/buffer.py +2 -2
  106. oscura/utils/lazy.py +5 -5
  107. oscura/utils/memory_advanced.py +2 -2
  108. oscura/utils/memory_extensions.py +2 -2
  109. oscura/visualization/colors.py +0 -2
  110. oscura/visualization/power.py +2 -0
  111. oscura/workflows/multi_trace.py +2 -0
  112. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/METADATA +122 -20
  113. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/RECORD +116 -98
  114. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/WHEEL +0 -0
  115. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/entry_points.txt +0 -0
  116. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/licenses/LICENSE +0 -0
oscura/__init__.py CHANGED
@@ -46,7 +46,7 @@ Example:
46
46
  For more information, see https://github.com/oscura-re/oscura
47
47
  """
48
48
 
49
- __version__ = "0.1.2"
49
+ __version__ = "0.4.0"
50
50
  __author__ = "Oscura Contributors"
51
51
 
52
52
  # Core types
@@ -157,9 +157,7 @@ from oscura.analyzers.waveform.spectral import (
157
157
 
158
158
  # Signal builders (top-level convenience access)
159
159
  from oscura.builders import (
160
- GeneratedSignal,
161
160
  SignalBuilder,
162
- SignalMetadata,
163
161
  )
164
162
 
165
163
  # Comparison and limit testing
@@ -518,8 +516,6 @@ __all__ = [
518
516
  "FieldSpec",
519
517
  "FilterResult",
520
518
  "FormatError",
521
- # Signal builders
522
- "GeneratedSignal",
523
519
  # Signal quality (QUAL-005)
524
520
  "Glitch",
525
521
  "GoldenReference",
@@ -571,8 +567,6 @@ __all__ = [
571
567
  "SignalBuilder",
572
568
  # Discovery
573
569
  "SignalCharacterization",
574
- # Signal builders
575
- "SignalMetadata",
576
570
  "SmartDefaults",
577
571
  # Convenience functions
578
572
  "SpectralMetrics",
@@ -0,0 +1,147 @@
1
+ """Unified acquisition layer for Oscura.
2
+
3
+ This module provides the Source protocol - a unified interface for acquiring
4
+ signal data from any source (files, hardware, synthetic generation).
5
+
6
+ The Source protocol enables polymorphic data acquisition:
7
+ - FileSource: Load from oscilloscope file formats
8
+ - HardwareSource: Acquire from live hardware (SocketCAN, Saleae, PyVISA)
9
+ - SyntheticSource: Generate synthetic test signals
10
+
11
+ All sources implement the same interface, making them interchangeable:
12
+
13
+ Example:
14
+ >>> from oscura.acquisition import FileSource, HardwareSource, SyntheticSource
15
+ >>> from oscura import SignalBuilder
16
+ >>>
17
+ >>> # All sources use the same interface
18
+ >>> file_src = FileSource("capture.wfm")
19
+ >>> hw_src = HardwareSource.socketcan("can0", bitrate=500000) # Future
20
+ >>> synth_src = SyntheticSource(SignalBuilder().sine(freq=1000))
21
+ >>>
22
+ >>> # Polymorphic consumption
23
+ >>> def analyze_from_source(source: Source):
24
+ ... trace = source.read()
25
+ ... return analyze(trace)
26
+ >>>
27
+ >>> # Works with any source
28
+ >>> analyze_from_source(file_src)
29
+ >>> analyze_from_source(synth_src)
30
+
31
+ Pattern Decision:
32
+ - Use Source.read() for one-shot acquisition (complete trace)
33
+ - Use Source.stream() for chunked/streaming acquisition (large files or hardware)
34
+ - All sources are context managers (use 'with' for resource cleanup)
35
+
36
+ References:
37
+ Architecture Plan Phase 0.1: Unified Acquisition Layer
38
+ docs/architecture/api-patterns.md: When to use Source vs load()
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from collections.abc import Iterator
44
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
45
+
46
+ if TYPE_CHECKING:
47
+ from oscura.core.types import Trace
48
+
49
+
50
+ @runtime_checkable
51
+ class Source(Protocol):
52
+ """Unified acquisition interface for all data sources.
53
+
54
+ This protocol defines the contract that all acquisition sources must implement.
55
+ Sources can be files, hardware devices, or synthetic signal generators.
56
+
57
+ Methods:
58
+ read: Read complete trace (one-shot acquisition)
59
+ stream: Stream trace in chunks (for large files or continuous acquisition)
60
+ close: Release resources (e.g., file handles, device connections)
61
+
62
+ Example:
63
+ >>> class CustomSource:
64
+ ... def read(self) -> Trace:
65
+ ... # Load/acquire complete trace
66
+ ... return trace
67
+ ...
68
+ ... def stream(self, chunk_size: int) -> Iterator[Trace]:
69
+ ... # Yield trace chunks
70
+ ... while has_data:
71
+ ... yield chunk
72
+ ...
73
+ ... def close(self) -> None:
74
+ ... # Clean up resources
75
+ ... pass
76
+ ...
77
+ ... def __enter__(self):
78
+ ... return self
79
+ ...
80
+ ... def __exit__(self, *args):
81
+ ... self.close()
82
+
83
+ Protocol Compliance:
84
+ Any class implementing these methods can be used as a Source, even
85
+ without explicit inheritance. This enables duck typing and flexibility.
86
+ """
87
+
88
+ def read(self) -> Trace:
89
+ """Read complete trace (one-shot acquisition).
90
+
91
+ Returns:
92
+ Complete trace from source (WaveformTrace, DigitalTrace, or IQTrace).
93
+
94
+ Raises:
95
+ LoaderError: If acquisition fails.
96
+ FileNotFoundError: If source file doesn't exist (FileSource).
97
+
98
+ Example:
99
+ >>> source = FileSource("capture.wfm")
100
+ >>> trace = source.read()
101
+ >>> print(f"Loaded {len(trace.data)} samples")
102
+ """
103
+
104
+ def stream(self, chunk_size: int) -> Iterator[Trace]:
105
+ """Stream trace in chunks (for large files or continuous acquisition).
106
+
107
+ Args:
108
+ chunk_size: Number of samples per chunk.
109
+
110
+ Yields:
111
+ Trace chunks (each chunk is a complete Trace object).
112
+
113
+ Example:
114
+ >>> source = FileSource("huge_capture.wfm")
115
+ >>> for chunk in source.stream(chunk_size=10000):
116
+ ... process_chunk(chunk)
117
+ """
118
+
119
+ def close(self) -> None:
120
+ """Release resources (file handles, device connections, etc.).
121
+
122
+ Called automatically when using source as context manager.
123
+
124
+ Example:
125
+ >>> with FileSource("capture.wfm") as source:
126
+ ... trace = source.read()
127
+ ... # close() called automatically
128
+ """
129
+
130
+ def __enter__(self) -> Source:
131
+ """Context manager entry."""
132
+
133
+ def __exit__(self, *args: object) -> None:
134
+ """Context manager exit."""
135
+
136
+
137
+ # Import concrete implementations
138
+ from oscura.acquisition.file import FileSource
139
+ from oscura.acquisition.hardware import HardwareSource
140
+ from oscura.acquisition.synthetic import SyntheticSource
141
+
142
+ __all__ = [
143
+ "FileSource",
144
+ "HardwareSource",
145
+ "Source",
146
+ "SyntheticSource",
147
+ ]
@@ -0,0 +1,255 @@
1
+ """File-based signal acquisition.
2
+
3
+ This module provides FileSource, which wraps existing file loaders to implement
4
+ the unified Source protocol. FileSource makes file loading consistent with all
5
+ other acquisition methods (hardware, synthetic).
6
+
7
+ Example:
8
+ >>> from oscura.acquisition import FileSource
9
+ >>>
10
+ >>> # Basic usage
11
+ >>> source = FileSource("capture.wfm")
12
+ >>> trace = source.read()
13
+ >>>
14
+ >>> # Context manager (recommended)
15
+ >>> with FileSource("capture.wfm") as source:
16
+ ... trace = source.read()
17
+ ... # Process trace
18
+ ... # Automatic cleanup
19
+ >>>
20
+ >>> # Streaming for large files
21
+ >>> with FileSource("huge_capture.wfm") as source:
22
+ ... for chunk in source.stream(chunk_size=10000):
23
+ ... process_chunk(chunk)
24
+ >>>
25
+ >>> # Format override
26
+ >>> source = FileSource("data.bin", format="tektronix")
27
+ >>> trace = source.read()
28
+
29
+ Pattern:
30
+ FileSource is a thin wrapper around the existing load() function.
31
+ It provides the Source interface for consistency and composition.
32
+
33
+ Backward Compatibility:
34
+ The existing oscura.load() function continues to work unchanged.
35
+ FileSource is the new preferred pattern for explicit acquisition.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from collections.abc import Iterator
41
+ from pathlib import Path
42
+ from typing import TYPE_CHECKING, Any
43
+
44
+ if TYPE_CHECKING:
45
+ from os import PathLike
46
+
47
+ from oscura.core.types import Trace
48
+
49
+
50
+ class FileSource:
51
+ """File-based signal source implementing Source protocol.
52
+
53
+ Wraps existing file loaders to provide unified acquisition interface.
54
+ Supports all file formats that oscura.load() supports:
55
+ - Tektronix WFM
56
+ - Rigol WFM
57
+ - CSV, HDF5, NumPy
58
+ - Sigrok, VCD, PCAP
59
+ - WAV, TDMS, Touchstone
60
+
61
+ Attributes:
62
+ path: Path to the file.
63
+ format: Optional format override (auto-detected if None).
64
+ kwargs: Additional loader arguments.
65
+
66
+ Example:
67
+ >>> # Auto-detect format
68
+ >>> source = FileSource("capture.wfm")
69
+ >>> trace = source.read()
70
+ >>>
71
+ >>> # Override format
72
+ >>> source = FileSource("data.bin", format="tektronix")
73
+ >>> trace = source.read()
74
+ >>>
75
+ >>> # Specify channel for multi-channel files
76
+ >>> source = FileSource("multi.wfm", channel=1)
77
+ >>> trace = source.read()
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ path: str | PathLike[str],
83
+ *,
84
+ format: str | None = None,
85
+ **kwargs: Any,
86
+ ) -> None:
87
+ """Initialize file source.
88
+
89
+ Args:
90
+ path: Path to file to load.
91
+ format: Optional format override (e.g., "tektronix", "rigol").
92
+ **kwargs: Additional arguments passed to loader.
93
+
94
+ Example:
95
+ >>> source = FileSource("capture.wfm")
96
+ >>> source = FileSource("data.bin", format="tektronix", channel=1)
97
+ """
98
+ self.path = Path(path)
99
+ self.format = format
100
+ self.kwargs = kwargs
101
+ self._closed = False
102
+
103
+ def read(self) -> Trace:
104
+ """Read complete trace from file.
105
+
106
+ Returns:
107
+ Complete trace (WaveformTrace, DigitalTrace, or IQTrace).
108
+
109
+ Raises:
110
+ FileNotFoundError: If file doesn't exist.
111
+ UnsupportedFormatError: If file format not recognized.
112
+ LoaderError: If file cannot be loaded.
113
+
114
+ Example:
115
+ >>> source = FileSource("capture.wfm")
116
+ >>> trace = source.read()
117
+ >>> print(f"Loaded {len(trace.data)} samples")
118
+ """
119
+ if self._closed:
120
+ raise ValueError("Cannot read from closed source")
121
+
122
+ # Import here to avoid circular dependency
123
+ from oscura.loaders import load
124
+
125
+ return load(self.path, format=self.format, **self.kwargs)
126
+
127
+ def stream(self, chunk_size: int) -> Iterator[Trace]:
128
+ """Stream trace in chunks for large files.
129
+
130
+ Args:
131
+ chunk_size: Number of samples per chunk.
132
+
133
+ Yields:
134
+ Trace chunks.
135
+
136
+ Raises:
137
+ FileNotFoundError: If file doesn't exist.
138
+ LoaderError: If file cannot be loaded.
139
+
140
+ Example:
141
+ >>> source = FileSource("huge_capture.wfm")
142
+ >>> for chunk in source.stream(chunk_size=10000):
143
+ ... metrics = analyze(chunk)
144
+ ... print(f"Chunk: {metrics}")
145
+
146
+ Note:
147
+ Currently uses load_trace_chunks for chunked loading.
148
+ For formats without native chunking support, loads full file
149
+ and yields slices.
150
+ """
151
+ if self._closed:
152
+ raise ValueError("Cannot stream from closed source")
153
+
154
+ # Try lazy/chunked loading if available
155
+ try:
156
+ from oscura.streaming import load_trace_chunks
157
+
158
+ # load_trace_chunks expects Path-like and chunk_size
159
+ yield from load_trace_chunks(self.path, chunk_size=chunk_size)
160
+ except ImportError:
161
+ # Fallback: load full trace and yield chunks
162
+ trace = self.read()
163
+
164
+ # Import here to avoid circular dependency
165
+ from oscura.core.types import IQTrace
166
+
167
+ # Get sample count based on trace type
168
+ if isinstance(trace, IQTrace):
169
+ n_samples = len(trace.i_data)
170
+ else:
171
+ n_samples = len(trace.data)
172
+
173
+ for start in range(0, n_samples, chunk_size):
174
+ end = min(start + chunk_size, n_samples)
175
+ # Create chunk trace with sliced data
176
+ if isinstance(trace, IQTrace):
177
+ # IQTrace doesn't have .data attribute
178
+ chunk_data = None # Will be handled separately below
179
+ else:
180
+ chunk_data = trace.data[start:end]
181
+
182
+ # Import here to avoid circular dependency
183
+ from oscura.core.types import (
184
+ DigitalTrace,
185
+ TraceMetadata,
186
+ WaveformTrace,
187
+ )
188
+
189
+ # Create appropriate trace type
190
+ chunk_metadata = TraceMetadata(
191
+ sample_rate=trace.metadata.sample_rate,
192
+ vertical_scale=trace.metadata.vertical_scale,
193
+ vertical_offset=trace.metadata.vertical_offset,
194
+ acquisition_time=trace.metadata.acquisition_time,
195
+ trigger_info=trace.metadata.trigger_info,
196
+ source_file=str(self.path),
197
+ channel_name=trace.metadata.channel_name,
198
+ calibration_info=trace.metadata.calibration_info,
199
+ )
200
+
201
+ if isinstance(trace, WaveformTrace):
202
+ yield WaveformTrace(data=chunk_data, metadata=chunk_metadata) # type: ignore[arg-type]
203
+ elif isinstance(trace, DigitalTrace):
204
+ yield DigitalTrace(
205
+ data=chunk_data, # type: ignore[arg-type]
206
+ metadata=chunk_metadata,
207
+ )
208
+ elif isinstance(trace, IQTrace):
209
+ # Handle I/Q separately
210
+ chunk_i = trace.i_data[start:end]
211
+ chunk_q = trace.q_data[start:end]
212
+ yield IQTrace(
213
+ i_data=chunk_i,
214
+ q_data=chunk_q,
215
+ metadata=chunk_metadata,
216
+ )
217
+
218
+ def close(self) -> None:
219
+ """Close the source and release resources.
220
+
221
+ For file sources, this is mostly a no-op since Python handles
222
+ file cleanup. Included for protocol compliance.
223
+
224
+ Example:
225
+ >>> source = FileSource("capture.wfm")
226
+ >>> trace = source.read()
227
+ >>> source.close()
228
+ """
229
+ self._closed = True
230
+
231
+ def __enter__(self) -> FileSource:
232
+ """Context manager entry.
233
+
234
+ Returns:
235
+ Self for use in 'with' statement.
236
+
237
+ Example:
238
+ >>> with FileSource("capture.wfm") as source:
239
+ ... trace = source.read()
240
+ """
241
+ return self
242
+
243
+ def __exit__(self, *args: object) -> None:
244
+ """Context manager exit.
245
+
246
+ Automatically calls close() when exiting 'with' block.
247
+ """
248
+ self.close()
249
+
250
+ def __repr__(self) -> str:
251
+ """String representation."""
252
+ return f"FileSource({self.path!r}, format={self.format!r})"
253
+
254
+
255
+ __all__ = ["FileSource"]
@@ -0,0 +1,186 @@
1
+ """Hardware-based signal acquisition sources.
2
+
3
+ This module provides the base HardwareSource class and factory methods for
4
+ creating hardware-based acquisition sources. All hardware sources implement
5
+ the unified Source protocol, making them interchangeable with FileSource and
6
+ SyntheticSource.
7
+
8
+ Supported Hardware:
9
+ - SocketCAN: Linux CAN bus interface (requires python-can)
10
+ - Saleae Logic: Logic analyzer (requires saleae library)
11
+ - PyVISA: Oscilloscopes and instruments (requires pyvisa)
12
+
13
+ Example:
14
+ >>> from oscura.acquisition import HardwareSource
15
+ >>>
16
+ >>> # SocketCAN source
17
+ >>> with HardwareSource.socketcan("can0", bitrate=500000) as source:
18
+ ... trace = source.read()
19
+ >>>
20
+ >>> # Saleae Logic source
21
+ >>> with HardwareSource.saleae() as source:
22
+ ... source.configure(sample_rate=1e6, duration=10)
23
+ ... trace = source.read()
24
+ >>>
25
+ >>> # PyVISA oscilloscope
26
+ >>> with HardwareSource.visa("USB0::0x0699::0x0401::INSTR") as scope:
27
+ ... scope.configure(channels=[1, 2], timebase=1e-6)
28
+ ... trace = scope.read()
29
+
30
+ Pattern:
31
+ Each hardware type has its own module (socketcan.py, saleae.py, visa.py)
32
+ containing implementation classes. HardwareSource provides factory methods
33
+ for convenient creation.
34
+
35
+ Dependencies:
36
+ Hardware sources require optional dependencies. Install with:
37
+ - SocketCAN: pip install oscura[automotive] (includes python-can)
38
+ - Saleae: pip install saleae
39
+ - PyVISA: pip install pyvisa pyvisa-py
40
+
41
+ References:
42
+ Architecture Plan Phase 2: Hardware Integration
43
+ docs/architecture/api-patterns.md: Source Protocol
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from typing import TYPE_CHECKING, Any
49
+
50
+ if TYPE_CHECKING:
51
+ from oscura.acquisition.saleae import SaleaeSource
52
+ from oscura.acquisition.socketcan import SocketCANSource
53
+ from oscura.acquisition.visa import VISASource
54
+
55
+
56
+ class HardwareSource:
57
+ """Factory for creating hardware acquisition sources.
58
+
59
+ This class provides static methods for creating hardware-based acquisition
60
+ sources. Each method returns a specific hardware source implementation that
61
+ follows the Source protocol.
62
+
63
+ Methods:
64
+ socketcan: Create Linux SocketCAN interface source
65
+ saleae: Create Saleae Logic analyzer source
66
+ visa: Create PyVISA instrument source
67
+
68
+ Example:
69
+ >>> # Create SocketCAN source
70
+ >>> can = HardwareSource.socketcan("can0", bitrate=500000)
71
+ >>> trace = can.read()
72
+ >>>
73
+ >>> # Create Saleae source
74
+ >>> logic = HardwareSource.saleae()
75
+ >>> logic.configure(sample_rate=1e6, duration=10)
76
+ >>> trace = logic.read()
77
+ """
78
+
79
+ @staticmethod
80
+ def socketcan(interface: str, *, bitrate: int = 500000, **kwargs: Any) -> SocketCANSource:
81
+ """Create SocketCAN hardware source for Linux CAN interfaces.
82
+
83
+ Args:
84
+ interface: SocketCAN interface name (e.g., "can0", "vcan0").
85
+ bitrate: CAN bitrate in bps (default: 500000).
86
+ **kwargs: Additional arguments passed to python-can Bus.
87
+
88
+ Returns:
89
+ SocketCANSource instance ready for acquisition.
90
+
91
+ Raises:
92
+ ImportError: If python-can is not installed.
93
+ OSError: If interface doesn't exist or permissions denied.
94
+
95
+ Example:
96
+ >>> # Physical CAN interface
97
+ >>> can = HardwareSource.socketcan("can0", bitrate=500000)
98
+ >>> trace = can.read()
99
+ >>>
100
+ >>> # Virtual CAN for testing
101
+ >>> vcan = HardwareSource.socketcan("vcan0")
102
+ >>> with vcan:
103
+ ... for chunk in vcan.stream(duration=60):
104
+ ... process(chunk)
105
+
106
+ Note:
107
+ Requires python-can library: pip install oscura[automotive]
108
+ Linux only - uses SocketCAN kernel module.
109
+ """
110
+ from oscura.acquisition.socketcan import SocketCANSource
111
+
112
+ return SocketCANSource(interface=interface, bitrate=bitrate, **kwargs)
113
+
114
+ @staticmethod
115
+ def saleae(device_id: str | None = None, **kwargs: Any) -> SaleaeSource:
116
+ """Create Saleae Logic analyzer source.
117
+
118
+ Args:
119
+ device_id: Saleae device ID (optional, auto-detects if None).
120
+ **kwargs: Additional configuration options.
121
+
122
+ Returns:
123
+ SaleaeSource instance ready for acquisition.
124
+
125
+ Raises:
126
+ ImportError: If saleae library is not installed.
127
+ RuntimeError: If no Saleae device found.
128
+
129
+ Example:
130
+ >>> # Auto-detect device
131
+ >>> logic = HardwareSource.saleae()
132
+ >>> logic.configure(sample_rate=1e6, duration=10)
133
+ >>> trace = logic.read()
134
+ >>>
135
+ >>> # Specify device
136
+ >>> logic = HardwareSource.saleae(device_id="ABC123")
137
+ >>> logic.configure(digital_channels=[0, 1, 2, 3])
138
+ >>> with logic:
139
+ ... trace = logic.read()
140
+
141
+ Note:
142
+ Requires saleae library: pip install saleae
143
+ Supports Logic 8, Logic Pro 8, Logic Pro 16.
144
+ """
145
+ from oscura.acquisition.saleae import SaleaeSource
146
+
147
+ return SaleaeSource(device_id=device_id, **kwargs)
148
+
149
+ @staticmethod
150
+ def visa(resource: str | None = None, **kwargs: Any) -> VISASource:
151
+ """Create PyVISA instrument source (oscilloscopes, etc.).
152
+
153
+ Args:
154
+ resource: VISA resource string (optional, auto-detects if None).
155
+ Examples: "USB0::0x0699::0x0401::INSTR", "TCPIP::192.168.1.100::INSTR"
156
+ **kwargs: Additional PyVISA configuration options.
157
+
158
+ Returns:
159
+ VISASource instance ready for acquisition.
160
+
161
+ Raises:
162
+ ImportError: If pyvisa is not installed.
163
+ RuntimeError: If no VISA resource found.
164
+
165
+ Example:
166
+ >>> # Auto-detect instrument
167
+ >>> scope = HardwareSource.visa()
168
+ >>> scope.configure(channels=[1, 2], timebase=1e-6)
169
+ >>> trace = scope.read()
170
+ >>>
171
+ >>> # Specific instrument
172
+ >>> scope = HardwareSource.visa("USB0::0x0699::0x0401::INSTR")
173
+ >>> scope.configure(channels=[1], vertical_scale=0.5)
174
+ >>> with scope:
175
+ ... trace = scope.read()
176
+
177
+ Note:
178
+ Requires pyvisa and pyvisa-py: pip install pyvisa pyvisa-py
179
+ Supports Tektronix, Keysight, Rigol, and other SCPI oscilloscopes.
180
+ """
181
+ from oscura.acquisition.visa import VISASource
182
+
183
+ return VISASource(resource=resource, **kwargs)
184
+
185
+
186
+ __all__ = ["HardwareSource"]