forked from auracaster/bumble_mirror
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e4948d9ef | |||
| 32d448edf3 | |||
| 3d615b13ce | |||
| 1ad92dc759 | |||
| aacfd4328c | |||
| 6aa1f5211c | |||
| df8e454ee5 | |||
| aec50ac616 | |||
| 6a3eaa457f | |||
| 6e6b4cd4b2 | |||
| aa1d7933da | |||
| 34e0f293c2 | |||
| 85215df2c3 | |||
| 8a5f6a61d5 |
+3
-1
@@ -41,6 +41,7 @@ import bumble.transport
|
|||||||
import bumble.utils
|
import bumble.utils
|
||||||
from bumble import company_ids, core, data_types, gatt, hci
|
from bumble import company_ids, core, data_types, gatt, hci
|
||||||
from bumble.audio import io as audio_io
|
from bumble.audio import io as audio_io
|
||||||
|
from bumble.audio import io_asrc as audio_io_asrc
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.profiles import bap, bass, le_audio, pbp
|
from bumble.profiles import bap, bass, le_audio, pbp
|
||||||
|
|
||||||
@@ -891,7 +892,8 @@ async def run_transmit(
|
|||||||
print('Start Periodic Advertising')
|
print('Start Periodic Advertising')
|
||||||
await advertising_set.start_periodic()
|
await advertising_set.start_periodic()
|
||||||
|
|
||||||
audio_input = await audio_io.create_audio_input(input, input_format)
|
#audio_input = await audio_io.create_audio_input(input, input_format)
|
||||||
|
audio_input = audio_io_asrc.SoundDeviceAudioInputAsrc(input[7:], input_format)
|
||||||
pcm_format = await audio_input.open()
|
pcm_format = await audio_input.open()
|
||||||
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
|
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
|
||||||
# longer needed.
|
# longer needed.
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Copyright 2025
|
||||||
|
#
|
||||||
|
# Drop-in replacement for `SoundDeviceAudioInput` that adds a tiny ASRC stage.
|
||||||
|
#
|
||||||
|
# Constraints per request:
|
||||||
|
# - Only import io_bumble.py at module level.
|
||||||
|
# - Reuse the ASRC functionality from asrc.py conceptually (PI control + FIFO +
|
||||||
|
# linear/sinc resampling behavior). We implement a minimal, dependency-free
|
||||||
|
# variant (linear interpolation with a small PI loop) so this module does not
|
||||||
|
# import anything else at top-level.
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - Input stream is captured via sounddevice (imported lazily inside methods).
|
||||||
|
# - Input is mono float32 for simplicity; output matches the original class
|
||||||
|
# signature: INT16, stereo, at the same nominal sample rate as requested.
|
||||||
|
|
||||||
|
from .io import PcmFormat, ThreadedAudioInput, logger # only top-level import
|
||||||
|
|
||||||
|
|
||||||
|
class SoundDeviceAudioInputAsrc(ThreadedAudioInput):
|
||||||
|
"""Sound device audio input with a simple ASRC stage.
|
||||||
|
|
||||||
|
Interface-compatible with `io_bumble.SoundDeviceAudioInput`:
|
||||||
|
- __init__(device_name: str, pcm_format: PcmFormat)
|
||||||
|
- _open() -> PcmFormat
|
||||||
|
- _read(frame_size: int) -> bytes
|
||||||
|
- _close() -> None
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Captures mono float32 frames from the device.
|
||||||
|
- Buffers into an internal ring buffer.
|
||||||
|
- Produces stereo INT16 frames using a linear-interp resampler whose
|
||||||
|
ratio is adjusted by a tiny PI loop to hold FIFO depth near a target.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device_name: str, pcm_format: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
# Device & format
|
||||||
|
self._device = int(device_name) if device_name else None
|
||||||
|
pcm_format: PcmFormat | None
|
||||||
|
if pcm_format == 'auto':
|
||||||
|
pcm_format = None
|
||||||
|
else:
|
||||||
|
pcm_format = PcmFormat.from_str(pcm_format)
|
||||||
|
self._pcm_format_in = pcm_format
|
||||||
|
# We always output stereo INT16 at the same nominal sample rate.
|
||||||
|
self._pcm_format_out = PcmFormat(
|
||||||
|
PcmFormat.Endianness.LITTLE,
|
||||||
|
PcmFormat.SampleType.INT16,
|
||||||
|
pcm_format.sample_rate,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# sounddevice stream (created in _open)
|
||||||
|
self._stream = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
# --- ASRC state (inspired by asrc.py) ---
|
||||||
|
# Nominal input/output rate ratio
|
||||||
|
self._r = 1.0
|
||||||
|
self._integral = 0.0
|
||||||
|
self._phi = 0.0 # fractional read position within current chunk
|
||||||
|
|
||||||
|
# PI gains (tiny to avoid warble)
|
||||||
|
self._Kp = 2e-6
|
||||||
|
self._Ki = 5e-8
|
||||||
|
self._R0 = 1.0
|
||||||
|
|
||||||
|
# Target FIFO level and deadband (≈10 ms target, 0.5 ms deadband)
|
||||||
|
fs = float(self._pcm_format_in.sample_rate)
|
||||||
|
self._target_samples = max(1, int(0.010 * fs))
|
||||||
|
self._deadband = max(1, int(0.0005 * fs))
|
||||||
|
|
||||||
|
# Ring buffer for mono float32 samples
|
||||||
|
# Capacity ~2 seconds for headroom
|
||||||
|
self._rb_cap = max(self._target_samples * 32, int(2 * fs))
|
||||||
|
self._rb = None # created in _init_rb()
|
||||||
|
self._ridx = 0
|
||||||
|
self._size = 0
|
||||||
|
self._lock = None # created in _init_rb()
|
||||||
|
self._init_rb()
|
||||||
|
|
||||||
|
# Light logging timer
|
||||||
|
self._last_log = 0.0
|
||||||
|
|
||||||
|
# Streaming resampler and internal output buffer (lazy init)
|
||||||
|
self._rs = None # samplerate.Resampler
|
||||||
|
self._out_buf = None # numpy.ndarray float32
|
||||||
|
|
||||||
|
# ---------------- Internal helpers -----------------
|
||||||
|
def _init_rb(self) -> None:
|
||||||
|
# Lazy import standard libs to keep only io_bumble imported at top level
|
||||||
|
import threading
|
||||||
|
from array import array
|
||||||
|
|
||||||
|
self._rb = array('f', [0.0] * self._rb_cap) # float32 ring buffer
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._ridx = 0
|
||||||
|
self._size = 0
|
||||||
|
|
||||||
|
def _fifo_len(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return self._size
|
||||||
|
|
||||||
|
def _fifo_write(self, x_f32) -> None:
|
||||||
|
# x_f32: 1-D float32-like iterable
|
||||||
|
k = len(x_f32)
|
||||||
|
if k <= 0:
|
||||||
|
return
|
||||||
|
rb = self._rb
|
||||||
|
if rb is None:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
# Trim if larger than capacity: keep last N
|
||||||
|
if k >= self._rb_cap:
|
||||||
|
x_f32 = x_f32[-self._rb_cap:]
|
||||||
|
k = self._rb_cap
|
||||||
|
# Make room on overflow (drop oldest)
|
||||||
|
excess = max(0, self._size + k - self._rb_cap)
|
||||||
|
if excess:
|
||||||
|
self._ridx = (self._ridx + excess) % self._rb_cap
|
||||||
|
self._size -= excess
|
||||||
|
# Write at tail position
|
||||||
|
wpos = (self._ridx + self._size) % self._rb_cap
|
||||||
|
first = min(k, self._rb_cap - wpos)
|
||||||
|
# Write first chunk
|
||||||
|
from array import array as _array # lazy import
|
||||||
|
rb[wpos:wpos + first] = _array('f', x_f32[:first])
|
||||||
|
# Wrap if needed
|
||||||
|
second = k - first
|
||||||
|
if second:
|
||||||
|
rb[0:second] = _array('f', x_f32[first:])
|
||||||
|
self._size += k
|
||||||
|
|
||||||
|
def _fifo_peek_array(self, n: int):
|
||||||
|
# Returns a Python list[float] copy of up to n samples
|
||||||
|
rb = self._rb
|
||||||
|
if rb is None:
|
||||||
|
return []
|
||||||
|
m = max(0, min(n, self._fifo_len()))
|
||||||
|
if m <= 0:
|
||||||
|
return []
|
||||||
|
pos = self._ridx
|
||||||
|
first = min(m, self._rb_cap - pos)
|
||||||
|
# Copy out
|
||||||
|
out = [0.0] * m
|
||||||
|
# First chunk
|
||||||
|
out[:first] = rb[pos:pos + first]
|
||||||
|
# Second chunk if wrap
|
||||||
|
second = m - first
|
||||||
|
if second > 0:
|
||||||
|
out[first:] = rb[0:second]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _fifo_discard(self, n: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
d = max(0, min(n, self._size))
|
||||||
|
self._ridx = (self._ridx + d) % self._rb_cap
|
||||||
|
self._size -= d
|
||||||
|
|
||||||
|
def _update_ratio(self) -> None:
|
||||||
|
# PI loop to hold buffer near target
|
||||||
|
e = self._target_samples - self._fifo_len()
|
||||||
|
if -self._deadband <= e <= self._deadband:
|
||||||
|
e = 0.0
|
||||||
|
cand_integral = self._integral + e
|
||||||
|
r_unclamped = self._R0 * (1.0 + self._Kp * e + self._Ki * cand_integral)
|
||||||
|
# Limit to ±1000 ppm vs nominal
|
||||||
|
ppm_unclamped = 1e6 * (r_unclamped / self._R0 - 1.0)
|
||||||
|
saturated_high = ppm_unclamped > 1000.0
|
||||||
|
saturated_low = ppm_unclamped < -1000.0
|
||||||
|
if saturated_high:
|
||||||
|
self._r = self._R0 * (1 + 1000e-6)
|
||||||
|
if e <= 0:
|
||||||
|
self._integral = cand_integral
|
||||||
|
self._integral *= 0.99
|
||||||
|
elif saturated_low:
|
||||||
|
self._r = self._R0 * (1 - 1000e-6)
|
||||||
|
if e >= 0:
|
||||||
|
self._integral = cand_integral
|
||||||
|
self._integral *= 0.99
|
||||||
|
else:
|
||||||
|
self._integral = cand_integral
|
||||||
|
self._r = r_unclamped
|
||||||
|
|
||||||
|
# Occasional log
|
||||||
|
try:
|
||||||
|
import time as _time
|
||||||
|
now = _time.time()
|
||||||
|
if now - self._last_log > 1.0:
|
||||||
|
buf_ms = 1000.0 * self._fifo_len() / float(self._pcm_format_in.sample_rate)
|
||||||
|
print(
|
||||||
|
f"\nASRC buf={buf_ms:5.1f} ms r={self._r:.9f} corr={1e6 * (self._r / self._R0 - 1.0):+7.1f} ppm"
|
||||||
|
)
|
||||||
|
self._last_log = now
|
||||||
|
except Exception:
|
||||||
|
# Logging must never break audio
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _process(self, n_out: int) -> list[float]:
|
||||||
|
# Accumulate at least n_out samples using samplerate.Resampler
|
||||||
|
if n_out <= 0:
|
||||||
|
return []
|
||||||
|
# Lazy imports
|
||||||
|
import numpy as np # type: ignore
|
||||||
|
|
||||||
|
# Lazy init output buffer
|
||||||
|
if self._out_buf is None:
|
||||||
|
self._out_buf = np.zeros(0, dtype=np.float32)
|
||||||
|
|
||||||
|
# Choose chunk so we don't take too much from FIFO each time
|
||||||
|
max_chunk = max(256, int(np.ceil(n_out / max(1e-9, self._r))))
|
||||||
|
safety_iters = 0
|
||||||
|
while self._out_buf.size < n_out and safety_iters < 16:
|
||||||
|
safety_iters += 1
|
||||||
|
available = self._fifo_len()
|
||||||
|
if available <= 0:
|
||||||
|
break
|
||||||
|
take = min(available, max_chunk)
|
||||||
|
x = self._fifo_peek_array(take)
|
||||||
|
self._fifo_discard(take)
|
||||||
|
if not x:
|
||||||
|
break
|
||||||
|
x_arr = np.asarray(x, dtype=np.float32)
|
||||||
|
if self._rs is not None:
|
||||||
|
try:
|
||||||
|
y = self._rs.process(x_arr, ratio=float(self._r), end_of_input=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("ASRC resampler error")
|
||||||
|
y = None
|
||||||
|
else:
|
||||||
|
y = None
|
||||||
|
if y is not None and getattr(y, 'size', 0):
|
||||||
|
y = y.astype(np.float32, copy=False)
|
||||||
|
if self._out_buf.size == 0:
|
||||||
|
self._out_buf = y
|
||||||
|
else:
|
||||||
|
self._out_buf = np.concatenate((self._out_buf, y))
|
||||||
|
|
||||||
|
if self._out_buf.size >= n_out:
|
||||||
|
out = self._out_buf[:n_out]
|
||||||
|
self._out_buf = self._out_buf[n_out:]
|
||||||
|
return out.tolist()
|
||||||
|
else:
|
||||||
|
# Not enough data produced; pad with zeros
|
||||||
|
out = np.zeros(n_out, dtype=np.float32)
|
||||||
|
if self._out_buf.size:
|
||||||
|
out[: self._out_buf.size] = self._out_buf
|
||||||
|
self._out_buf = np.zeros(0, dtype=np.float32)
|
||||||
|
return out.tolist()
|
||||||
|
|
||||||
|
def _mono_to_stereo_int16_bytes(self, mono_f32: list[float]) -> bytes:
|
||||||
|
# Convert [-1,1] float list to stereo int16 little-endian bytes
|
||||||
|
import struct
|
||||||
|
ba = bytearray()
|
||||||
|
for v in mono_f32:
|
||||||
|
# clip
|
||||||
|
if v > 1.0:
|
||||||
|
v = 1.0
|
||||||
|
elif v < -1.0:
|
||||||
|
v = -1.0
|
||||||
|
i16 = int(v * 32767.0)
|
||||||
|
ba += struct.pack('<hh', i16, i16)
|
||||||
|
return bytes(ba)
|
||||||
|
|
||||||
|
# ---------------- ThreadedAudioInput hooks -----------------
|
||||||
|
def _open(self) -> PcmFormat:
|
||||||
|
# Set up sounddevice RawInputStream (int16) and start callback producer
|
||||||
|
import sounddevice # pylint: disable=import-error
|
||||||
|
import math
|
||||||
|
import samplerate as sr # type: ignore
|
||||||
|
|
||||||
|
# We capture mono regardless of requested channels, then output stereo.
|
||||||
|
channels = 1
|
||||||
|
samplerate = int(self._pcm_format_in.sample_rate)
|
||||||
|
|
||||||
|
def _callback(indata, frames, time_info, status): # noqa: ARG001 (signature is fixed)
|
||||||
|
# indata: raw int16 bytes-like buffer of shape (frames, channels)
|
||||||
|
try:
|
||||||
|
if status:
|
||||||
|
logger.warning("Input status: %s", status)
|
||||||
|
if frames <= 0:
|
||||||
|
return
|
||||||
|
# Interpret raw bytes as little-endian int16 mono
|
||||||
|
mv = memoryview(indata).cast('h') # len == frames * channels
|
||||||
|
# Convert to float in [-1, 1]
|
||||||
|
# Avoid division errors; protect NaN/Inf
|
||||||
|
mono = []
|
||||||
|
for i in range(frames):
|
||||||
|
v = mv[i]
|
||||||
|
f = float(v) / 32768.0
|
||||||
|
if not (f == f) or math.isinf(f):
|
||||||
|
f = 0.0
|
||||||
|
mono.append(f)
|
||||||
|
self._fifo_write(mono)
|
||||||
|
except Exception: # never let callback raise
|
||||||
|
logger.exception("Audio input callback error")
|
||||||
|
|
||||||
|
# Create streaming resampler (mono)
|
||||||
|
try:
|
||||||
|
self._rs = sr.Resampler(converter_type="sinc_fastest", channels=1)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to create samplerate.Resampler; audio may be silent")
|
||||||
|
self._rs = None
|
||||||
|
|
||||||
|
self._stream = sounddevice.RawInputStream(
|
||||||
|
samplerate=samplerate,
|
||||||
|
device=self._device,
|
||||||
|
channels=channels,
|
||||||
|
dtype='int16',
|
||||||
|
callback=_callback,
|
||||||
|
)
|
||||||
|
self._stream.start()
|
||||||
|
|
||||||
|
return self._pcm_format_out
|
||||||
|
|
||||||
|
def _read(self, frame_size: int) -> bytes:
|
||||||
|
# Produce 'frame_size' output frames (stereo INT16)
|
||||||
|
if frame_size <= 0:
|
||||||
|
return b''
|
||||||
|
# Update resampling ratio based on FIFO level
|
||||||
|
try:
|
||||||
|
self._update_ratio()
|
||||||
|
except Exception:
|
||||||
|
# keep going even if update failed
|
||||||
|
pass
|
||||||
|
# Process mono float32
|
||||||
|
mono = self._process(frame_size)
|
||||||
|
# Convert to stereo int16 LE bytes
|
||||||
|
return self._mono_to_stereo_int16_bytes(mono)
|
||||||
|
|
||||||
|
def _close(self) -> None:
|
||||||
|
try:
|
||||||
|
if self._stream is not None:
|
||||||
|
self._stream.stop()
|
||||||
|
self._stream.close()
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Error closing input stream')
|
||||||
|
finally:
|
||||||
|
self._stream = None
|
||||||
+7
-10
@@ -2171,7 +2171,7 @@ def with_connection_from_address(function):
|
|||||||
@functools.wraps(function)
|
@functools.wraps(function)
|
||||||
def wrapper(device: Device, address: hci.Address, *args, **kwargs):
|
def wrapper(device: Device, address: hci.Address, *args, **kwargs):
|
||||||
if connection := device.pending_connections.get(address):
|
if connection := device.pending_connections.get(address):
|
||||||
return function(device, connection, address, *args, **kwargs)
|
return function(device, connection, *args, **kwargs)
|
||||||
for connection in device.connections.values():
|
for connection in device.connections.values():
|
||||||
if connection.peer_address == address:
|
if connection.peer_address == address:
|
||||||
return function(device, connection, *args, **kwargs)
|
return function(device, connection, *args, **kwargs)
|
||||||
@@ -4727,7 +4727,7 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
self, cis_acl_pairs: Sequence[tuple[int, Connection]]
|
self, cis_acl_pairs: Sequence[tuple[int, Connection]]
|
||||||
) -> list[CisLink]:
|
) -> list[CisLink]:
|
||||||
for cis_handle, acl_connection in cis_acl_pairs:
|
for cis_handle, acl_connection in cis_acl_pairs:
|
||||||
cis_id, cig_id = self._pending_cis.pop(cis_handle)
|
cis_id, cig_id = self._pending_cis[cis_handle]
|
||||||
self.cis_links[cis_handle] = CisLink(
|
self.cis_links[cis_handle] = CisLink(
|
||||||
device=self,
|
device=self,
|
||||||
acl_connection=acl_connection,
|
acl_connection=acl_connection,
|
||||||
@@ -4743,6 +4743,7 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def on_cis_establishment(cis_link: CisLink) -> None:
|
def on_cis_establishment(cis_link: CisLink) -> None:
|
||||||
|
self._pending_cis.pop(cis_link.handle)
|
||||||
if pending_future := pending_cis_establishments.get(cis_link.handle):
|
if pending_future := pending_cis_establishments.get(cis_link.handle):
|
||||||
pending_future.set_result(cis_link)
|
pending_future.set_result(cis_link)
|
||||||
|
|
||||||
@@ -6443,18 +6444,14 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@try_with_connection_from_address
|
@with_connection_from_address
|
||||||
def on_role_change(
|
def on_role_change(
|
||||||
self,
|
self,
|
||||||
connection: Optional[Connection],
|
connection: Connection,
|
||||||
peer_address: hci.Address,
|
|
||||||
new_role: hci.Role,
|
new_role: hci.Role,
|
||||||
):
|
):
|
||||||
if connection:
|
connection.role = new_role
|
||||||
connection.role = new_role
|
connection.emit(connection.EVENT_ROLE_CHANGE, new_role)
|
||||||
connection.emit(connection.EVENT_ROLE_CHANGE, new_role)
|
|
||||||
else:
|
|
||||||
logger.warning("Role change to unknown connection %s", peer_address)
|
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
|
|||||||
+1
-1
@@ -550,7 +550,7 @@ class Host(utils.EventEmitter):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI LE flow control: '
|
'HCI LE flow control: '
|
||||||
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
||||||
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
|
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets},'
|
||||||
f'iso_data_packet_length={iso_data_packet_length},'
|
f'iso_data_packet_length={iso_data_packet_length},'
|
||||||
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
||||||
)
|
)
|
||||||
|
|||||||
+33
-4
@@ -273,12 +273,19 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
def on_disconnection(_reason) -> None:
|
def on_disconnection(_reason) -> None:
|
||||||
self.currently_connected_clients.discard(connection)
|
self.currently_connected_clients.discard(connection)
|
||||||
|
|
||||||
|
@connection.on(connection.EVENT_CONNECTION_ATT_MTU_UPDATE)
|
||||||
|
def on_mtu_update(*_: Any) -> None:
|
||||||
|
self.on_incoming_connection(connection)
|
||||||
|
|
||||||
|
@connection.on(connection.EVENT_CONNECTION_ENCRYPTION_CHANGE)
|
||||||
|
def on_encryption_change(*_: Any) -> None:
|
||||||
|
self.on_incoming_connection(connection)
|
||||||
|
|
||||||
@connection.on(connection.EVENT_PAIRING)
|
@connection.on(connection.EVENT_PAIRING)
|
||||||
def on_pairing(*_: Any) -> None:
|
def on_pairing(*_: Any) -> None:
|
||||||
self.on_incoming_paired_connection(connection)
|
self.on_incoming_connection(connection)
|
||||||
|
|
||||||
if connection.peer_resolvable_address:
|
self.on_incoming_connection(connection)
|
||||||
self.on_incoming_paired_connection(connection)
|
|
||||||
|
|
||||||
self.hearing_aid_features_characteristic = gatt.Characteristic(
|
self.hearing_aid_features_characteristic = gatt.Characteristic(
|
||||||
uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
|
uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
|
||||||
@@ -315,9 +322,30 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_incoming_paired_connection(self, connection: Connection):
|
def on_incoming_connection(self, connection: Connection):
|
||||||
'''Setup initial operations to handle a remote bonded HAP device'''
|
'''Setup initial operations to handle a remote bonded HAP device'''
|
||||||
# TODO Should we filter on HAP device only ?
|
# TODO Should we filter on HAP device only ?
|
||||||
|
|
||||||
|
if not connection.is_encrypted:
|
||||||
|
logging.debug(f'HAS: {connection.peer_address} is not encrypted')
|
||||||
|
return
|
||||||
|
|
||||||
|
if not connection.peer_resolvable_address:
|
||||||
|
logging.debug(f'HAS: {connection.peer_address} is not paired')
|
||||||
|
return
|
||||||
|
|
||||||
|
if connection.att_mtu < 49:
|
||||||
|
logging.debug(
|
||||||
|
f'HAS: {connection.peer_address} invalid MTU={connection.att_mtu}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if connection.peer_address in self.currently_connected_clients:
|
||||||
|
logging.debug(
|
||||||
|
f'HAS: Already connected to {connection.peer_address} nothing to do'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self.currently_connected_clients.add(connection)
|
self.currently_connected_clients.add(connection)
|
||||||
if (
|
if (
|
||||||
connection.peer_address
|
connection.peer_address
|
||||||
@@ -457,6 +485,7 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
connection,
|
connection,
|
||||||
self.hearing_aid_preset_control_point,
|
self.hearing_aid_preset_control_point,
|
||||||
value=op_list[0].to_bytes(len(op_list) == 1),
|
value=op_list[0].to_bytes(len(op_list) == 1),
|
||||||
|
force=True, # TODO GATT notification subscription should be persistent
|
||||||
)
|
)
|
||||||
# Remove item once sent, and keep the non sent item in the list
|
# Remove item once sent, and keep the non sent item in the list
|
||||||
op_list.pop(0)
|
op_list.pop(0)
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
|
|||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
logger.debug("removing .ini file")
|
logger.debug("removing .ini file")
|
||||||
ini_file.unlink()
|
try:
|
||||||
|
ini_file.unlink()
|
||||||
|
except OSError as error:
|
||||||
|
# Don't log at exception level, since this may happen normally.
|
||||||
|
logger.debug(f'failed to remove .ini file ({error})')
|
||||||
|
|
||||||
atexit.register(cleanup)
|
atexit.register(cleanup)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import serial_asyncio
|
import serial_asyncio
|
||||||
|
|
||||||
@@ -28,25 +29,56 @@ from bumble.transport.common import StreamPacketSink, StreamPacketSource, Transp
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DEFAULT_POST_OPEN_DELAY = 0.5 # in seconds
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes and Functions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class SerialPacketSource(StreamPacketSource):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._ready = asyncio.Event()
|
||||||
|
|
||||||
|
async def wait_until_ready(self) -> None:
|
||||||
|
await self._ready.wait()
|
||||||
|
|
||||||
|
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
||||||
|
logger.debug('connection made')
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
def connection_lost(self, exc: Optional[Exception]) -> None:
|
||||||
|
logger.debug('connection lost')
|
||||||
|
self.on_transport_lost()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_serial_transport(spec: str) -> Transport:
|
async def open_serial_transport(spec: str) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a serial port transport.
|
Open a serial port transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
<device-path>[,<speed>][,rtscts][,dsrdtr]
|
<device-path>[,<speed>][,rtscts][,dsrdtr][,delay]
|
||||||
When <speed> is omitted, the default value of 1000000 is used
|
When <speed> is omitted, the default value of 1000000 is used
|
||||||
When "rtscts" is specified, RTS/CTS hardware flow control is enabled
|
When "rtscts" is specified, RTS/CTS hardware flow control is enabled
|
||||||
When "dsrdtr" is specified, DSR/DTR hardware flow control is enabled
|
When "dsrdtr" is specified, DSR/DTR hardware flow control is enabled
|
||||||
|
When "delay" is specified, a short delay is added after opening the port
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
/dev/tty.usbmodem0006839912172
|
/dev/tty.usbmodem0006839912172
|
||||||
/dev/tty.usbmodem0006839912172,1000000
|
/dev/tty.usbmodem0006839912172,1000000
|
||||||
/dev/tty.usbmodem0006839912172,rtscts
|
/dev/tty.usbmodem0006839912172,rtscts
|
||||||
|
/dev/tty.usbmodem0006839912172,rtscts,delay
|
||||||
'''
|
'''
|
||||||
|
|
||||||
speed = 1000000
|
speed = 1000000
|
||||||
rtscts = False
|
rtscts = False
|
||||||
dsrdtr = False
|
dsrdtr = False
|
||||||
|
delay = 0.0
|
||||||
if ',' in spec:
|
if ',' in spec:
|
||||||
parts = spec.split(',')
|
parts = spec.split(',')
|
||||||
device = parts[0]
|
device = parts[0]
|
||||||
@@ -55,13 +87,16 @@ async def open_serial_transport(spec: str) -> Transport:
|
|||||||
rtscts = True
|
rtscts = True
|
||||||
elif part == 'dsrdtr':
|
elif part == 'dsrdtr':
|
||||||
dsrdtr = True
|
dsrdtr = True
|
||||||
|
elif part == 'delay':
|
||||||
|
delay = DEFAULT_POST_OPEN_DELAY
|
||||||
elif part.isnumeric():
|
elif part.isnumeric():
|
||||||
speed = int(part)
|
speed = int(part)
|
||||||
else:
|
else:
|
||||||
device = spec
|
device = spec
|
||||||
|
|
||||||
serial_transport, packet_source = await serial_asyncio.create_serial_connection(
|
serial_transport, packet_source = await serial_asyncio.create_serial_connection(
|
||||||
asyncio.get_running_loop(),
|
asyncio.get_running_loop(),
|
||||||
StreamPacketSource,
|
SerialPacketSource,
|
||||||
device,
|
device,
|
||||||
baudrate=speed,
|
baudrate=speed,
|
||||||
rtscts=rtscts,
|
rtscts=rtscts,
|
||||||
@@ -69,4 +104,23 @@ async def open_serial_transport(spec: str) -> Transport:
|
|||||||
)
|
)
|
||||||
packet_sink = StreamPacketSink(serial_transport)
|
packet_sink = StreamPacketSink(serial_transport)
|
||||||
|
|
||||||
|
logger.debug('waiting for the port to be ready')
|
||||||
|
await packet_source.wait_until_ready()
|
||||||
|
logger.debug('port is ready')
|
||||||
|
|
||||||
|
# Try to assert DTR
|
||||||
|
assert serial_transport.serial is not None
|
||||||
|
try:
|
||||||
|
serial_transport.serial.dtr = True
|
||||||
|
logger.debug(
|
||||||
|
f"DSR={serial_transport.serial.dsr}, DTR={serial_transport.serial.dtr}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'could not assert DTR: {e}')
|
||||||
|
|
||||||
|
# Wait a bit after opening the port, if requested
|
||||||
|
if delay > 0.0:
|
||||||
|
logger.debug(f'waiting {delay} seconds after opening the port')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
return Transport(packet_source, packet_sink)
|
return Transport(packet_source, packet_sink)
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ SERIAL TRANSPORT
|
|||||||
The serial transport implements sending/receiving HCI packets over a UART (a.k.a serial port).
|
The serial transport implements sending/receiving HCI packets over a UART (a.k.a serial port).
|
||||||
|
|
||||||
## Moniker
|
## Moniker
|
||||||
The moniker syntax for a serial transport is: `serial:<device-path>[,<speed>]`
|
The moniker syntax for a serial transport is:
|
||||||
When `<speed>` is omitted, the default value of 1000000 is used
|
`<device-path>[,<speed>][,rtscts][,dsrdtr][,delay]`
|
||||||
|
|
||||||
|
When `<speed>` is omitted, the default value of 1000000 is used.
|
||||||
|
When `rtscts` is specified, RTS/CTS hardware flow control is enabled.
|
||||||
|
When `dsrdtr` is specified, DSR/DTR hardware flow control is enabled.
|
||||||
|
When `delay` is specified, a short delay is added after opening the port.
|
||||||
|
|
||||||
!!! example
|
!!! example
|
||||||
`serial:/dev/tty.usbmodem0006839912172,1000000`
|
```
|
||||||
Opens the serial port `/dev/tty.usbmodem0006839912172` at `1000000`bps
|
/dev/tty.usbmodem0006839912172
|
||||||
|
/dev/tty.usbmodem0006839912172,1000000
|
||||||
|
/dev/tty.usbmodem0006839912172,rtscts
|
||||||
|
/dev/tty.usbmodem0006839912172,rtscts,delay
|
||||||
|
```
|
||||||
@@ -82,3 +82,7 @@ services and characteristics.
|
|||||||
|
|
||||||
# `run_scanner.py`
|
# `run_scanner.py`
|
||||||
Run a host application connected to a 'real' BLE controller over a UART HCI to a dev board running Zephyr in HCI mode (could be any other UART BLE controller, or BlueZ over a virtual UART), that starts scanning and prints out the scan results.
|
Run a host application connected to a 'real' BLE controller over a UART HCI to a dev board running Zephyr in HCI mode (could be any other UART BLE controller, or BlueZ over a virtual UART), that starts scanning and prints out the scan results.
|
||||||
|
|
||||||
|
|
||||||
|
# run auracast with usb stick
|
||||||
|
bumble-auracast transmit 'serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_CC69A2912F84AE5E-if00,1000000,rtscts' --input device
|
||||||
|
|||||||
@@ -761,6 +761,34 @@ async def test_inquiry_result_with_rssi():
|
|||||||
m.assert_called_with(hci.Address("00:11:22:33:44:55/P"), 3, mock.ANY, 5)
|
m.assert_called_with(hci.Address("00:11:22:33:44:55/P"), 3, mock.ANY, 5)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roles",
|
||||||
|
(
|
||||||
|
(hci.Role.PERIPHERAL, hci.Role.CENTRAL),
|
||||||
|
(hci.Role.CENTRAL, hci.Role.PERIPHERAL),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accept_classic_connection(roles: tuple[hci.Role, hci.Role]):
|
||||||
|
devices = TwoDevices()
|
||||||
|
devices[0].classic_enabled = True
|
||||||
|
devices[1].classic_enabled = True
|
||||||
|
await devices[0].power_on()
|
||||||
|
await devices[1].power_on()
|
||||||
|
|
||||||
|
accept_task = asyncio.create_task(devices[1].accept(role=roles[1]))
|
||||||
|
await devices[0].connect(
|
||||||
|
devices[1].public_address, transport=PhysicalTransport.BR_EDR
|
||||||
|
)
|
||||||
|
await accept_task
|
||||||
|
|
||||||
|
assert devices.connections[0]
|
||||||
|
assert devices.connections[0].role == roles[0]
|
||||||
|
assert devices.connections[1]
|
||||||
|
assert devices.connections[1].role == roles[1]
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def run_test_device():
|
async def run_test_device():
|
||||||
await test_device_connect_parallel()
|
await test_device_connect_parallel()
|
||||||
|
|||||||
+3
-1
@@ -82,7 +82,6 @@ async def hap_client():
|
|||||||
)
|
)
|
||||||
|
|
||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
# TODO negotiate MTU > 49 to not truncate preset names
|
|
||||||
|
|
||||||
# Mock encryption.
|
# Mock encryption.
|
||||||
devices.connections[0].encryption = 1 # type: ignore
|
devices.connections[0].encryption = 1 # type: ignore
|
||||||
@@ -93,6 +92,9 @@ async def hap_client():
|
|||||||
)
|
)
|
||||||
|
|
||||||
peer = device.Peer(devices.connections[1]) # type: ignore
|
peer = device.Peer(devices.connections[1]) # type: ignore
|
||||||
|
await peer.request_mtu(49)
|
||||||
|
peer2 = device.Peer(devices.connections[0]) # type: ignore
|
||||||
|
await peer2.request_mtu(49)
|
||||||
hap_client = await peer.discover_service_and_create_proxy(
|
hap_client = await peer.discover_service_and_create_proxy(
|
||||||
hap.HearingAccessServiceProxy
|
hap.HearingAccessServiceProxy
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user