PEER ROOT")
+ try:
+ return run(sys.argv[1], sys.argv[2], Path(sys.argv[3]))
+ except (AdapterError, OSError, zipfile.BadZipFile) as error:
+ print(f"Bluetooth adapter failed status=6 diagnostic={str(error)[:1024]}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/trainlog_syncd.py b/tools/trainlog_syncd.py
index 59239bd..7826fbb 100755
--- a/tools/trainlog_syncd.py
+++ b/tools/trainlog_syncd.py
@@ -87,9 +87,9 @@ def load_generation_config(path: Path) -> dict:
not isinstance(value, dict)
or value.get("format") != "trainlog-sync-orchestrator-config"
or value.get("enabled") is not True
- or value.get("mode") not in ("mtp", "auto")
+ or value.get("mode") not in ("bt", "mtp", "auto")
):
- raise RuntimeError("trusted full-generation MTP configuration is invalid")
+ raise RuntimeError("trusted full-generation transport configuration is invalid")
return value
@@ -164,38 +164,47 @@ def select_request(root: Path, seen: list[str]) -> tuple[str, str, list[str]] |
return None
-def run_full_generation(args: argparse.Namespace, config_path: Path) -> int:
- config = load_generation_config(config_path)
- root = Path(config["transport_root"])
- expected_peer = config["expected_peer_id"]
- adapter = args.mtp_adapter
- # WHY: the daemon must probe MTP to discover Android-origin requests, but
- # Web/TUI orchestration can use the same libmtp device concurrently. A
- # business sync.lock is too broad here: holding it during discovery makes
- # a user Web click fail with sync_in_progress. CONTRACT: discovery instead
- # shares a dedicated physical-transport lock with every worker MTP adapter
- # call and skips this poll when that transport is busy. INVARIANT: no MTP
- # adapter processes overlap, while business-sync admission stays independent.
- lock_path = root.parent / "mtp.lock"
+def pull_transport(
+ adapter: Path,
+ lock_name: str,
+ expected_peer: str,
+ root: Path,
+) -> bool:
+ lock_path = root.parent / lock_name
lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try:
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
- return 3
- pull = subprocess.run(
- [str(adapter), "pull", expected_peer, str(root)],
- capture_output=True,
- text=True,
- check=False,
- )
+ return False
+ try:
+ pull = subprocess.run(
+ [str(adapter), "pull", expected_peer, str(root)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ except OSError:
+ return False
+ return pull.returncode == 0
finally:
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
finally:
os.close(lock_fd)
- if pull.returncode != 0:
+
+
+def run_full_generation(args: argparse.Namespace, config_path: Path) -> int:
+ config = load_generation_config(config_path)
+ root = Path(config["transport_root"])
+ expected_peer = config["expected_peer_id"]
+ pulled = False
+ if config.get("bluetooth_enabled") is True and args.bt_adapter is not None:
+ pulled = pull_transport(args.bt_adapter, "bt.lock", expected_peer, root)
+ if not pulled and config.get("mode") in ("mtp", "auto"):
+ pulled = pull_transport(args.mtp_adapter, "mtp.lock", expected_peer, root)
+ if not pulled:
return 3
_, seen = load_request_state(args.state)
selected = select_request(root, seen)
@@ -270,6 +279,11 @@ def main() -> int:
)
parser.add_argument("--database", type=Path, default=default_database())
parser.add_argument("--state", type=Path)
+ parser.add_argument(
+ "--bt-adapter",
+ type=Path,
+ default=Path(os.environ.get("TRAINLOG_SYNC_BT_ADAPTER", "")),
+ )
parser.add_argument(
"--mtp-adapter",
type=Path,
@@ -295,8 +309,13 @@ def main() -> int:
)
if args.generation_config is not None:
- if not args.orchestrator.is_file() or not args.mtp_adapter.is_file():
+ config = load_generation_config(args.generation_config)
+ if not args.orchestrator.is_file():
raise SystemExit("full-generation runtime is incomplete")
+ if config.get("mode") in ("mtp", "auto") and not args.mtp_adapter.is_file():
+ raise SystemExit("full-generation MTP runtime is incomplete")
+ if config.get("bluetooth_enabled") is True and not args.bt_adapter.is_file():
+ raise SystemExit("full-generation Bluetooth runtime is incomplete")
signal.signal(
signal.SIGTERM,
diff --git a/tui/meson.build b/tui/meson.build
index 1da1d54..b457eb8 100644
--- a/tui/meson.build
+++ b/tui/meson.build
@@ -928,6 +928,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sync_drive_transport.py'],
)
+test(
+ 'sync_bluetooth_transport',
+ find_program('python3'),
+ args: [meson.project_source_root() / 'tests/test_sync_bluetooth_transport.py'],
+)
test(
'sync_deployment_tools',
find_program('python3'),
diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts
index 8d16b83..f77280d 100644
--- a/web/src/api/sync.ts
+++ b/web/src/api/sync.ts
@@ -37,7 +37,8 @@ export interface SyncStatus {
drafts?: SyncDraftSummary[]
started_at?: string | null
finished_at?: string | null
- transport?: 'directory' | 'mtp' | 'drive'
+ transport?: 'directory' | 'bt' | 'mtp' | 'drive'
+ bluetooth_state?: 'unavailable' | 'skipped' | 'running' | 'success' | 'failed'
usb_state?: 'unavailable' | 'skipped' | 'running' | 'success' | 'failed'
drive_state?: 'disabled' | 'unavailable' | 'running' | 'mirrored' | 'success' | 'failed'
drive_diagnostic?: string
@@ -90,7 +91,9 @@ function parse(value: unknown): SyncStatus {
!optionalString(status.started_at) ||
!optionalString(status.finished_at) ||
!optionalString(status.drive_diagnostic) ||
- (status.transport !== undefined && !['directory', 'mtp', 'drive'].includes(status.transport)) ||
+ (status.transport !== undefined && !['directory', 'bt', 'mtp', 'drive'].includes(status.transport)) ||
+ (status.bluetooth_state !== undefined &&
+ !['unavailable', 'skipped', 'running', 'success', 'failed'].includes(status.bluetooth_state)) ||
(status.usb_state !== undefined && !['unavailable', 'skipped', 'running', 'success', 'failed'].includes(status.usb_state)) ||
(status.drive_state !== undefined && !['disabled', 'unavailable', 'running', 'mirrored', 'success', 'failed'].includes(status.drive_state)) ||
(status.progress_revision !== undefined &&
diff --git a/web/src/components/SyncControl.tsx b/web/src/components/SyncControl.tsx
index e96de29..108878a 100644
--- a/web/src/components/SyncControl.tsx
+++ b/web/src/components/SyncControl.tsx
@@ -135,6 +135,10 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
{action && {action}
}
{status?.error_code && Code : {status.error_code}
}
{status?.diagnostic && {status.diagnostic}
}
+ {status?.bluetooth_state && Android Bluetooth : {{
+ unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
+ success: '✓ synchronisé', failed: '⚠ non traité',
+ }[status.bluetooth_state]}
}
{status?.usb_state && Android USB : {{
unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
success: '✓ synchronisé', failed: '⚠ non traité',