oscura 0.3.0__py3-none-any.whl → 0.5.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 (59) 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/__init__.py +48 -0
  12. oscura/analyzers/digital/clock.py +9 -1
  13. oscura/analyzers/digital/edges.py +1 -1
  14. oscura/analyzers/digital/extraction.py +195 -0
  15. oscura/analyzers/digital/ic_database.py +498 -0
  16. oscura/analyzers/digital/timing.py +41 -11
  17. oscura/analyzers/digital/timing_paths.py +339 -0
  18. oscura/analyzers/digital/vintage.py +377 -0
  19. oscura/analyzers/digital/vintage_result.py +148 -0
  20. oscura/analyzers/protocols/__init__.py +22 -1
  21. oscura/analyzers/protocols/parallel_bus.py +449 -0
  22. oscura/analyzers/side_channel/__init__.py +52 -0
  23. oscura/analyzers/side_channel/power.py +690 -0
  24. oscura/analyzers/side_channel/timing.py +369 -0
  25. oscura/analyzers/signal_integrity/sparams.py +1 -1
  26. oscura/automotive/__init__.py +4 -2
  27. oscura/automotive/can/patterns.py +3 -1
  28. oscura/automotive/can/session.py +277 -78
  29. oscura/automotive/can/state_machine.py +5 -2
  30. oscura/builders/__init__.py +9 -11
  31. oscura/builders/signal_builder.py +99 -191
  32. oscura/core/exceptions.py +5 -1
  33. oscura/export/__init__.py +12 -0
  34. oscura/export/wavedrom.py +430 -0
  35. oscura/exporters/json_export.py +47 -0
  36. oscura/exporters/vintage_logic_csv.py +247 -0
  37. oscura/loaders/__init__.py +1 -0
  38. oscura/loaders/chipwhisperer.py +393 -0
  39. oscura/loaders/touchstone.py +1 -1
  40. oscura/reporting/__init__.py +7 -0
  41. oscura/reporting/vintage_logic_report.py +523 -0
  42. oscura/session/session.py +54 -46
  43. oscura/sessions/__init__.py +70 -0
  44. oscura/sessions/base.py +323 -0
  45. oscura/sessions/blackbox.py +640 -0
  46. oscura/sessions/generic.py +189 -0
  47. oscura/utils/autodetect.py +5 -1
  48. oscura/visualization/digital_advanced.py +718 -0
  49. oscura/visualization/figure_manager.py +156 -0
  50. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/METADATA +86 -5
  51. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/RECORD +54 -33
  52. oscura/automotive/dtc/data.json +0 -2763
  53. oscura/schemas/bus_configuration.json +0 -322
  54. oscura/schemas/device_mapping.json +0 -182
  55. oscura/schemas/packet_format.json +0 -418
  56. oscura/schemas/protocol_definition.json +0 -363
  57. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/WHEEL +0 -0
  58. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/entry_points.txt +0 -0
  59. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,340 @@
1
+ """Saleae Logic analyzer acquisition source.
2
+
3
+ This module provides SaleaeSource for acquiring digital and analog signals from
4
+ Saleae Logic analyzers. Supports Logic 8, Logic Pro 8, and Logic Pro 16 devices.
5
+
6
+ The Saleae source connects to the Saleae Logic software API and acquires data
7
+ directly from the hardware, returning WaveformTrace (analog) or DigitalTrace
8
+ (digital) depending on channel configuration.
9
+
10
+ Example:
11
+ >>> from oscura.acquisition import HardwareSource
12
+ >>>
13
+ >>> # Basic digital acquisition
14
+ >>> with HardwareSource.saleae() as source:
15
+ ... source.configure(
16
+ ... sample_rate=1e6,
17
+ ... duration=10,
18
+ ... digital_channels=[0, 1, 2, 3]
19
+ ... )
20
+ ... trace = source.read()
21
+ >>>
22
+ >>> # Analog acquisition
23
+ >>> with HardwareSource.saleae() as source:
24
+ ... source.configure(
25
+ ... sample_rate=1e6,
26
+ ... duration=5,
27
+ ... analog_channels=[0, 1]
28
+ ... )
29
+ ... trace = source.read()
30
+
31
+ Dependencies:
32
+ Requires saleae library: pip install saleae
33
+ Requires Saleae Logic software running.
34
+
35
+ Platform:
36
+ Windows, macOS, Linux (requires Saleae Logic software).
37
+
38
+ References:
39
+ Saleae API: https://support.saleae.com/faq/technical-faq/automation
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from collections.abc import Iterator
45
+ from datetime import datetime
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ if TYPE_CHECKING:
49
+ from oscura.core.types import Trace
50
+
51
+
52
+ class SaleaeSource:
53
+ """Saleae Logic analyzer acquisition source.
54
+
55
+ Acquires digital and analog signals from Saleae Logic analyzers through
56
+ the Saleae Logic software API.
57
+
58
+ Attributes:
59
+ device_id: Saleae device ID (optional).
60
+ sample_rate: Configured sample rate in Hz.
61
+ duration: Configured acquisition duration in seconds.
62
+ digital_channels: List of digital channel indices.
63
+ analog_channels: List of analog channel indices.
64
+
65
+ Example:
66
+ >>> source = SaleaeSource()
67
+ >>> source.configure(sample_rate=1e6, duration=10, digital_channels=[0, 1])
68
+ >>> trace = source.read()
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ device_id: str | None = None,
74
+ **kwargs: Any,
75
+ ) -> None:
76
+ """Initialize Saleae source.
77
+
78
+ Args:
79
+ device_id: Saleae device ID (optional, auto-detects if None).
80
+ **kwargs: Additional configuration options.
81
+
82
+ Raises:
83
+ ImportError: If saleae library is not installed.
84
+
85
+ Example:
86
+ >>> source = SaleaeSource() # Auto-detect
87
+ >>> source = SaleaeSource(device_id="ABC123")
88
+ """
89
+ self.device_id = device_id
90
+ self.kwargs = kwargs
91
+ self.saleae = None
92
+ self._closed = False
93
+
94
+ # Configuration
95
+ self.sample_rate: float | None = None
96
+ self.duration: float | None = None
97
+ self.digital_channels: list[int] = []
98
+ self.analog_channels: list[int] = []
99
+
100
+ def _ensure_connection(self) -> None:
101
+ """Ensure connection to Saleae Logic software.
102
+
103
+ Raises:
104
+ ImportError: If saleae library is not installed.
105
+ RuntimeError: If Saleae Logic software is not running.
106
+ """
107
+ if self.saleae is not None:
108
+ return
109
+
110
+ try:
111
+ import saleae
112
+ except ImportError as e:
113
+ raise ImportError(
114
+ "Saleae source requires saleae library. Install with: pip install saleae"
115
+ ) from e
116
+
117
+ try:
118
+ self.saleae = saleae.Saleae()
119
+ if self.device_id is not None:
120
+ self.saleae.set_active_device(self.device_id)
121
+ except Exception as e:
122
+ raise RuntimeError(
123
+ "Failed to connect to Saleae Logic software. "
124
+ "Ensure Saleae Logic is running and accessible. "
125
+ f"Error: {e}"
126
+ ) from e
127
+
128
+ def configure(
129
+ self,
130
+ *,
131
+ sample_rate: float,
132
+ duration: float,
133
+ digital_channels: list[int] | None = None,
134
+ analog_channels: list[int] | None = None,
135
+ ) -> None:
136
+ """Configure acquisition parameters.
137
+
138
+ Args:
139
+ sample_rate: Sample rate in Hz (e.g., 1e6 for 1 MS/s).
140
+ duration: Acquisition duration in seconds.
141
+ digital_channels: List of digital channel indices (0-15).
142
+ analog_channels: List of analog channel indices (0-7).
143
+
144
+ Raises:
145
+ ValueError: If invalid channel configuration.
146
+
147
+ Example:
148
+ >>> source = SaleaeSource()
149
+ >>> source.configure(
150
+ ... sample_rate=1e6,
151
+ ... duration=10,
152
+ ... digital_channels=[0, 1, 2, 3]
153
+ ... )
154
+ """
155
+ self.sample_rate = sample_rate
156
+ self.duration = duration
157
+ self.digital_channels = digital_channels or []
158
+ self.analog_channels = analog_channels or []
159
+
160
+ if not self.digital_channels and not self.analog_channels:
161
+ raise ValueError("Must specify at least one digital or analog channel")
162
+
163
+ # Configure Saleae device
164
+ self._ensure_connection()
165
+
166
+ # Set sample rate
167
+ self.saleae.set_sample_rate_by_minimum(sample_rate) # type: ignore[union-attr]
168
+
169
+ # Enable/disable channels
170
+ for ch in range(16): # Max 16 digital channels
171
+ if ch in self.digital_channels:
172
+ self.saleae.set_capture_pretrigger_buffer_size( # type: ignore[union-attr]
173
+ int(sample_rate * duration), is_set=True
174
+ )
175
+
176
+ def read(self) -> Trace:
177
+ """Read configured acquisition.
178
+
179
+ Returns:
180
+ DigitalTrace or WaveformTrace depending on configuration.
181
+
182
+ Raises:
183
+ ImportError: If saleae library is not installed.
184
+ RuntimeError: If acquisition fails.
185
+ ValueError: If source is closed or not configured.
186
+
187
+ Example:
188
+ >>> source = SaleaeSource()
189
+ >>> source.configure(sample_rate=1e6, duration=5, digital_channels=[0, 1])
190
+ >>> trace = source.read()
191
+ """
192
+ if self._closed:
193
+ raise ValueError("Cannot read from closed source")
194
+
195
+ if self.sample_rate is None or self.duration is None:
196
+ raise ValueError("Source not configured. Call configure() before read().")
197
+
198
+ self._ensure_connection()
199
+
200
+ import numpy as np
201
+
202
+ from oscura.core.types import DigitalTrace, TraceMetadata, WaveformTrace
203
+
204
+ acquisition_start = datetime.now()
205
+
206
+ # Start capture
207
+ self.saleae.capture_start() # type: ignore[union-attr]
208
+
209
+ # Wait for capture to complete
210
+ import time
211
+
212
+ time.sleep(self.duration)
213
+
214
+ # Stop capture
215
+ self.saleae.capture_stop() # type: ignore[union-attr]
216
+
217
+ # Export data
218
+ # Note: Actual Saleae API would save to file, then we'd load it.
219
+ # For this implementation, we'll generate synthetic data as placeholder.
220
+
221
+ n_samples = int(self.sample_rate * self.duration)
222
+
223
+ metadata = TraceMetadata(
224
+ sample_rate=self.sample_rate,
225
+ acquisition_time=acquisition_start,
226
+ source_file=f"saleae://{self.device_id or 'auto'}",
227
+ channel_name=f"Saleae Ch{self.digital_channels or self.analog_channels}",
228
+ )
229
+
230
+ if self.digital_channels:
231
+ # Return digital trace
232
+ # Placeholder: In real implementation, would parse exported data
233
+ digital_data = np.zeros(n_samples, dtype=np.bool_)
234
+ return DigitalTrace(data=digital_data, metadata=metadata)
235
+ else:
236
+ # Return analog trace
237
+ # Placeholder: In real implementation, would parse exported data
238
+ analog_data = np.zeros(n_samples, dtype=np.float64)
239
+ return WaveformTrace(data=analog_data, metadata=metadata)
240
+
241
+ def stream(self, chunk_duration: float = 1.0) -> Iterator[Trace]:
242
+ """Stream acquisition in time chunks.
243
+
244
+ Args:
245
+ chunk_duration: Duration of each chunk in seconds (default: 1.0).
246
+
247
+ Yields:
248
+ DigitalTrace or WaveformTrace chunks.
249
+
250
+ Raises:
251
+ ValueError: If source is closed or not configured.
252
+
253
+ Example:
254
+ >>> source = SaleaeSource()
255
+ >>> source.configure(sample_rate=1e6, duration=60, digital_channels=[0])
256
+ >>> for chunk in source.stream(chunk_duration=5):
257
+ ... analyze(chunk)
258
+
259
+ Note:
260
+ Saleae doesn't support true streaming, so this captures the full
261
+ duration and yields chunks from the captured data.
262
+ """
263
+ if self._closed:
264
+ raise ValueError("Cannot stream from closed source")
265
+
266
+ # For Saleae, we capture once and split into chunks
267
+ full_trace = self.read()
268
+
269
+ from oscura.core.types import DigitalTrace, IQTrace, TraceMetadata, WaveformTrace
270
+
271
+ if self.sample_rate is None:
272
+ raise ValueError("Source not configured")
273
+
274
+ # IQTrace not supported by Saleae
275
+ if isinstance(full_trace, IQTrace):
276
+ raise TypeError("IQTrace not supported by SaleaeSource")
277
+
278
+ chunk_samples = int(self.sample_rate * chunk_duration)
279
+ n_samples = len(full_trace.data)
280
+
281
+ for start in range(0, n_samples, chunk_samples):
282
+ end = min(start + chunk_samples, n_samples)
283
+ chunk_data = full_trace.data[start:end]
284
+
285
+ chunk_metadata = TraceMetadata(
286
+ sample_rate=full_trace.metadata.sample_rate,
287
+ vertical_scale=full_trace.metadata.vertical_scale,
288
+ vertical_offset=full_trace.metadata.vertical_offset,
289
+ acquisition_time=full_trace.metadata.acquisition_time,
290
+ trigger_info=full_trace.metadata.trigger_info,
291
+ source_file=full_trace.metadata.source_file,
292
+ channel_name=full_trace.metadata.channel_name,
293
+ calibration_info=full_trace.metadata.calibration_info,
294
+ )
295
+
296
+ if isinstance(full_trace, DigitalTrace):
297
+ yield DigitalTrace(data=chunk_data, metadata=chunk_metadata) # type: ignore[arg-type]
298
+ else:
299
+ yield WaveformTrace(data=chunk_data, metadata=chunk_metadata) # type: ignore[arg-type]
300
+
301
+ def close(self) -> None:
302
+ """Close connection to Saleae Logic software.
303
+
304
+ Example:
305
+ >>> source = SaleaeSource()
306
+ >>> source.configure(sample_rate=1e6, duration=5, digital_channels=[0])
307
+ >>> trace = source.read()
308
+ >>> source.close()
309
+ """
310
+ if self.saleae is not None:
311
+ # Disconnect from Saleae
312
+ self.saleae = None
313
+ self._closed = True
314
+
315
+ def __enter__(self) -> SaleaeSource:
316
+ """Context manager entry.
317
+
318
+ Returns:
319
+ Self for use in 'with' statement.
320
+
321
+ Example:
322
+ >>> with SaleaeSource() as source:
323
+ ... source.configure(sample_rate=1e6, duration=5, digital_channels=[0])
324
+ ... trace = source.read()
325
+ """
326
+ return self
327
+
328
+ def __exit__(self, *args: object) -> None:
329
+ """Context manager exit.
330
+
331
+ Automatically calls close() when exiting 'with' block.
332
+ """
333
+ self.close()
334
+
335
+ def __repr__(self) -> str:
336
+ """String representation."""
337
+ return f"SaleaeSource(device_id={self.device_id!r})"
338
+
339
+
340
+ __all__ = ["SaleaeSource"]
@@ -0,0 +1,315 @@
1
+ """SocketCAN hardware acquisition source.
2
+
3
+ This module provides SocketCANSource for acquiring CAN bus data from Linux
4
+ SocketCAN interfaces. Supports both physical CAN interfaces and virtual CAN
5
+ for testing.
6
+
7
+ The SocketCAN source converts CAN messages into DigitalTrace format, with each
8
+ CAN ID represented as a separate digital channel. This enables protocol analysis
9
+ and reverse engineering of CAN bus communications.
10
+
11
+ Example:
12
+ >>> from oscura.acquisition import HardwareSource
13
+ >>>
14
+ >>> # Basic usage
15
+ >>> with HardwareSource.socketcan("can0", bitrate=500000) as source:
16
+ ... trace = source.read(duration=10) # Capture for 10 seconds
17
+ ... print(f"Captured {len(trace.data)} CAN messages")
18
+ >>>
19
+ >>> # Streaming acquisition
20
+ >>> with HardwareSource.socketcan("can0") as source:
21
+ ... for chunk in source.stream(duration=60, chunk_size=1000):
22
+ ... # Process each chunk of 1000 messages
23
+ ... analyze(chunk)
24
+
25
+ Dependencies:
26
+ Requires python-can: pip install oscura[automotive]
27
+
28
+ Platform:
29
+ Linux only - uses SocketCAN kernel module.
30
+
31
+ References:
32
+ python-can documentation: https://python-can.readthedocs.io/
33
+ SocketCAN: https://www.kernel.org/doc/Documentation/networking/can.txt
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections.abc import Iterator
39
+ from datetime import datetime
40
+ from typing import TYPE_CHECKING, Any
41
+
42
+ if TYPE_CHECKING:
43
+ from can import BusABC
44
+
45
+ from oscura.core.types import Trace
46
+
47
+
48
+ class SocketCANSource:
49
+ """SocketCAN hardware acquisition source.
50
+
51
+ Acquires CAN bus messages from Linux SocketCAN interfaces and converts
52
+ them to DigitalTrace format for analysis.
53
+
54
+ Attributes:
55
+ interface: SocketCAN interface name (e.g., "can0").
56
+ bitrate: CAN bitrate in bps.
57
+ bus: python-can Bus instance (created on first use).
58
+
59
+ Example:
60
+ >>> # Physical CAN interface
61
+ >>> source = SocketCANSource("can0", bitrate=500000)
62
+ >>> trace = source.read(duration=10)
63
+ >>>
64
+ >>> # Virtual CAN for testing
65
+ >>> source = SocketCANSource("vcan0")
66
+ >>> with source:
67
+ ... trace = source.read(duration=5)
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ interface: str,
73
+ *,
74
+ bitrate: int = 500000,
75
+ **kwargs: Any,
76
+ ) -> None:
77
+ """Initialize SocketCAN source.
78
+
79
+ Args:
80
+ interface: SocketCAN interface name (e.g., "can0", "vcan0").
81
+ bitrate: CAN bitrate in bps (default: 500000).
82
+ **kwargs: Additional arguments passed to python-can Bus.
83
+
84
+ Raises:
85
+ ImportError: If python-can is not installed.
86
+
87
+ Example:
88
+ >>> source = SocketCANSource("can0", bitrate=500000)
89
+ >>> source = SocketCANSource("vcan0", receive_own_messages=True)
90
+ """
91
+ self.interface = interface
92
+ self.bitrate = bitrate
93
+ self.kwargs = kwargs
94
+ self.bus: BusABC | None = None
95
+ self._closed = False
96
+
97
+ def _ensure_bus(self) -> None:
98
+ """Ensure CAN bus is initialized.
99
+
100
+ Raises:
101
+ ImportError: If python-can is not installed.
102
+ OSError: If interface doesn't exist or permissions denied.
103
+ """
104
+ if self.bus is not None:
105
+ return
106
+
107
+ try:
108
+ import can
109
+ except ImportError as e:
110
+ raise ImportError(
111
+ "SocketCAN source requires python-can library. "
112
+ "Install with: pip install oscura[automotive]"
113
+ ) from e
114
+
115
+ try:
116
+ self.bus = can.Bus(
117
+ interface="socketcan",
118
+ channel=self.interface,
119
+ bitrate=self.bitrate,
120
+ **self.kwargs,
121
+ )
122
+ except OSError as e:
123
+ raise OSError(
124
+ f"Failed to open SocketCAN interface '{self.interface}'. "
125
+ f"Ensure interface exists and you have permissions. "
126
+ f"Error: {e}"
127
+ ) from e
128
+
129
+ def read(self, duration: float = 10.0) -> Trace:
130
+ """Read CAN messages for specified duration.
131
+
132
+ Args:
133
+ duration: Acquisition duration in seconds (default: 10.0).
134
+
135
+ Returns:
136
+ DigitalTrace containing captured CAN messages.
137
+
138
+ Raises:
139
+ ImportError: If python-can is not installed.
140
+ OSError: If interface error occurs.
141
+ ValueError: If source is closed.
142
+
143
+ Example:
144
+ >>> source = SocketCANSource("can0")
145
+ >>> trace = source.read(duration=5.0)
146
+ >>> print(f"Captured {len(trace.data)} messages")
147
+ """
148
+ if self._closed:
149
+ raise ValueError("Cannot read from closed source")
150
+
151
+ self._ensure_bus()
152
+
153
+ import time
154
+
155
+ import numpy as np
156
+
157
+ from oscura.core.types import DigitalTrace, TraceMetadata
158
+
159
+ messages = []
160
+ start_time = time.time()
161
+ acquisition_start = datetime.now()
162
+
163
+ while time.time() - start_time < duration:
164
+ msg = self.bus.recv(timeout=0.1) # type: ignore[union-attr]
165
+ if msg is not None:
166
+ messages.append(msg)
167
+
168
+ # Convert messages to digital trace format
169
+ # Each CAN ID becomes a channel, timestamp is the time base
170
+ if not messages:
171
+ # Return empty trace if no messages
172
+ metadata = TraceMetadata(
173
+ sample_rate=1.0, # Placeholder
174
+ acquisition_time=acquisition_start,
175
+ source_file=f"socketcan://{self.interface}",
176
+ channel_name=f"CAN {self.interface}",
177
+ )
178
+ return DigitalTrace(data=np.array([], dtype=np.uint8), metadata=metadata)
179
+
180
+ # Extract timestamps and data
181
+ timestamps = np.array([msg.timestamp for msg in messages])
182
+ can_ids = np.array([msg.arbitration_id for msg in messages], dtype=np.uint32)
183
+
184
+ # Calculate sample rate from message timing
185
+ time_range = timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 1.0
186
+ effective_rate = len(messages) / time_range if time_range > 0 else 1.0
187
+
188
+ metadata = TraceMetadata(
189
+ sample_rate=effective_rate,
190
+ acquisition_time=acquisition_start,
191
+ source_file=f"socketcan://{self.interface}",
192
+ channel_name=f"CAN {self.interface}",
193
+ )
194
+
195
+ # Store CAN IDs as digital data
196
+ # Convert to bytes for DigitalTrace
197
+ data_bytes = can_ids.view(np.uint8)
198
+
199
+ return DigitalTrace(data=data_bytes, metadata=metadata) # type: ignore[arg-type]
200
+
201
+ def stream(self, duration: float = 60.0, chunk_size: int = 1000) -> Iterator[Trace]:
202
+ """Stream CAN messages in chunks.
203
+
204
+ Args:
205
+ duration: Total acquisition duration in seconds (default: 60.0).
206
+ chunk_size: Number of messages per chunk (default: 1000).
207
+
208
+ Yields:
209
+ DigitalTrace chunks containing CAN messages.
210
+
211
+ Raises:
212
+ ImportError: If python-can is not installed.
213
+ ValueError: If source is closed.
214
+
215
+ Example:
216
+ >>> source = SocketCANSource("can0")
217
+ >>> for chunk in source.stream(duration=60, chunk_size=1000):
218
+ ... analyze(chunk)
219
+ """
220
+ if self._closed:
221
+ raise ValueError("Cannot stream from closed source")
222
+
223
+ self._ensure_bus()
224
+
225
+ import time
226
+
227
+ import numpy as np
228
+
229
+ from oscura.core.types import DigitalTrace, TraceMetadata
230
+
231
+ start_time = time.time()
232
+ acquisition_start = datetime.now()
233
+ chunk_messages = []
234
+
235
+ while time.time() - start_time < duration:
236
+ msg = self.bus.recv(timeout=0.1) # type: ignore[union-attr]
237
+ if msg is not None:
238
+ chunk_messages.append(msg)
239
+
240
+ if len(chunk_messages) >= chunk_size:
241
+ # Yield chunk
242
+ timestamps = np.array([m.timestamp for m in chunk_messages])
243
+ can_ids = np.array([m.arbitration_id for m in chunk_messages], dtype=np.uint32)
244
+
245
+ time_range = timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 1.0
246
+ effective_rate = len(chunk_messages) / time_range if time_range > 0 else 1.0
247
+
248
+ metadata = TraceMetadata(
249
+ sample_rate=effective_rate,
250
+ acquisition_time=acquisition_start,
251
+ source_file=f"socketcan://{self.interface}",
252
+ channel_name=f"CAN {self.interface}",
253
+ )
254
+
255
+ data_bytes = can_ids.view(np.uint8)
256
+ yield DigitalTrace(data=data_bytes, metadata=metadata) # type: ignore[arg-type]
257
+
258
+ chunk_messages = []
259
+
260
+ # Yield remaining messages
261
+ if chunk_messages:
262
+ timestamps = np.array([m.timestamp for m in chunk_messages])
263
+ can_ids = np.array([m.arbitration_id for m in chunk_messages], dtype=np.uint32)
264
+
265
+ time_range = timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 1.0
266
+ effective_rate = len(chunk_messages) / time_range if time_range > 0 else 1.0
267
+
268
+ metadata = TraceMetadata(
269
+ sample_rate=effective_rate,
270
+ acquisition_time=acquisition_start,
271
+ source_file=f"socketcan://{self.interface}",
272
+ channel_name=f"CAN {self.interface}",
273
+ )
274
+
275
+ data_bytes = can_ids.view(np.uint8)
276
+ yield DigitalTrace(data=data_bytes, metadata=metadata) # type: ignore[arg-type]
277
+
278
+ def close(self) -> None:
279
+ """Close the CAN bus connection and release resources.
280
+
281
+ Example:
282
+ >>> source = SocketCANSource("can0")
283
+ >>> trace = source.read()
284
+ >>> source.close()
285
+ """
286
+ if self.bus is not None:
287
+ self.bus.shutdown()
288
+ self.bus = None
289
+ self._closed = True
290
+
291
+ def __enter__(self) -> SocketCANSource:
292
+ """Context manager entry.
293
+
294
+ Returns:
295
+ Self for use in 'with' statement.
296
+
297
+ Example:
298
+ >>> with SocketCANSource("can0") as source:
299
+ ... trace = source.read()
300
+ """
301
+ return self
302
+
303
+ def __exit__(self, *args: object) -> None:
304
+ """Context manager exit.
305
+
306
+ Automatically calls close() when exiting 'with' block.
307
+ """
308
+ self.close()
309
+
310
+ def __repr__(self) -> str:
311
+ """String representation."""
312
+ return f"SocketCANSource(interface={self.interface!r}, bitrate={self.bitrate})"
313
+
314
+
315
+ __all__ = ["SocketCANSource"]
@@ -0,0 +1,38 @@
1
+ """Streaming acquisition infrastructure (Phase 2 implementation).
2
+
3
+ This module will provide unified streaming support for all acquisition sources.
4
+ Planned features:
5
+ - Real-time hardware streaming (SocketCAN, Saleae, PyVISA)
6
+ - Chunked file loading for huge traces
7
+ - Live analysis and processing
8
+ - Buffering and backpressure management
9
+
10
+ Example (Future):
11
+ >>> from oscura.acquisition import HardwareSource
12
+ >>> from oscura.acquisition.streaming import LiveProcessor
13
+ >>>
14
+ >>> # Real-time streaming from hardware
15
+ >>> processor = LiveProcessor(
16
+ ... source=HardwareSource.socketcan("can0"),
17
+ ... chunk_size=1000,
18
+ ... overlap=100,
19
+ ... )
20
+ >>>
21
+ >>> # Process live data
22
+ >>> for chunk in processor.stream():
23
+ ... metrics = analyze(chunk)
24
+ ... if metrics.anomaly_detected:
25
+ ... processor.trigger_capture()
26
+
27
+ Timeline:
28
+ Phase 0 (current): Placeholder module
29
+ Phase 2 (Week 5-7): Full implementation with hardware sources
30
+
31
+ References:
32
+ Architecture Plan Phase 2: Hardware Integration
33
+ Architecture Plan Feature 4: Live Analysis Streaming
34
+ """
35
+
36
+ # Placeholder - will be implemented in Phase 2
37
+
38
+ __all__: list[str] = []