Skip to content

Commit 14969c5

Browse files
authored
Merge pull request #247 from f-io/dev
Dev -> Main
2 parents f969908 + f849e01 commit 14969c5

8 files changed

Lines changed: 156 additions & 14 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "LIVI",
3-
"version": "6.1.0",
3+
"version": "6.1.1",
44
"description": "LIVI - Linux In-Vehicle Infotainment",
55
"main": "./out/main/main.js",
66
"author": "Lasse Heitgres",

src/main/services/projection/driver/aa/AaBtSockClient.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,19 @@ export class AaBtSockClient {
102102
return (await this.request(`remove ${mac}`, timeoutMs)) as ActionResponse
103103
}
104104

105+
/** Tell the BT reconnect worker to pause (true) or resume (false). */
106+
async setSessionActive(active: boolean, timeoutMs = 5000): Promise<ActionResponse> {
107+
return (await this.request(
108+
`session-active ${active ? 'true' : 'false'}`,
109+
timeoutMs
110+
)) as ActionResponse
111+
}
112+
113+
/** Kick every associated Wi-Fi station off the AP. */
114+
async deauthApClients(timeoutMs = 5000): Promise<ActionResponse> {
115+
return (await this.request('deauth-ap', timeoutMs)) as ActionResponse
116+
}
117+
105118
/** Open a event subscription. */
106119
subscribe(
107120
onEvent: (ev: { event: string; mac?: string; path?: string }) => void,

src/main/services/projection/driver/aa/bt/aa-bluetooth.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import dbus.mainloop.glib
3030
from gi.repository import GLib
3131

32-
from wifi_ap import setup_ap, teardown_ap, get_wlan_mac, AP_IP
32+
from wifi_ap import setup_ap, teardown_ap, get_wlan_mac, deauth_all_clients, AP_IP
3333
from config import SSID, PASSPHRASE, CHANNEL, PORT as AA_PORT, BTNAME, WIFI_IFACE, BT_ADAPTER
3434

3535
# ── Debug gate ────────────────────────────────────────────────────────────────
@@ -674,6 +674,10 @@ def _send_ok() -> None:
674674
# HFP Service Level Connection state
675675
hfp_slc_established: bool = False
676676

677+
# Set True while an AA session is running (wired or wireless).
678+
_session_active: bool = False
679+
_session_active_lock = threading.Lock()
680+
677681
# Set True only when HFP RegisterProfile succeeds.
678682
_hfp_profile_registered: bool = False
679683

@@ -1003,7 +1007,9 @@ def _start_event_server() -> None:
10031007
]}
10041008
connect <mac> → {"ok": true} or {"ok": false, "error": "..."}
10051009
disconnect <mac> → {"ok": true} or {"ok": false, "error": "..."}
1006-
remove <mac> → {"ok": true} or {"ok": false, "error": "..."}
1010+
remove <mac> → {"ok": true} or {"ok": false, "error": "..."}
1011+
session-active <bool> → {"ok": true, "active": true|false}
1012+
deauth-ap → {"ok": true, "count": N} (kicks Wi-Fi clients)
10071013
"""
10081014
try:
10091015
os.unlink(_AA_EVENT_SOCK)
@@ -1042,6 +1048,20 @@ def _dispatch_command(cmd: str, arg: str) -> dict:
10421048
return {"ok": False, "error": "remove requires a MAC argument"}
10431049
ok, err = _device_remove(arg)
10441050
return {"ok": ok} if ok else {"ok": False, "error": err}
1051+
if cmd == "session-active":
1052+
value = arg.strip().lower()
1053+
if value not in ("true", "false"):
1054+
return {"ok": False, "error": "session-active expects true|false"}
1055+
global _session_active
1056+
with _session_active_lock:
1057+
_session_active = (value == "true")
1058+
new_value = _session_active
1059+
dprint(f"[aa-bt] session active: {new_value}", flush=True)
1060+
return {"ok": True, "active": new_value}
1061+
if cmd == "deauth-ap":
1062+
count = deauth_all_clients()
1063+
dprint(f"[aa-bt] deauth-ap: kicked {count} client(s)", flush=True)
1064+
return {"ok": True, "count": count}
10451065
return {"ok": False, "error": f"unknown command: {cmd!r}"}
10461066
except Exception as e:
10471067
traceback.print_exc()
@@ -1128,6 +1148,12 @@ def _bt_reconnect_worker() -> None:
11281148
while True:
11291149
time.sleep(interval_s)
11301150

1151+
# Skip while an AA session is active — AA carries call audio itself,
1152+
# and BT probes interfere with Wi-Fi on shared-radio platforms.
1153+
with _session_active_lock:
1154+
if _session_active:
1155+
continue
1156+
11311157
mac = _last_phone_mac
11321158
path = _last_device_path
11331159
if not mac or not path:

src/main/services/projection/driver/aa/bt/wifi_ap.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,29 @@ def _is_dhcp_listening() -> bool:
230230
return False
231231

232232

233+
def deauth_all_clients() -> int:
234+
"""Force every associated station off the AP. Returns the number of
235+
stations deauthenticated (0 if hostapd isn't running)."""
236+
try:
237+
out = subprocess.run(
238+
["sudo", "hostapd_cli", "-p", "/var/run/hostapd", "-i", WIFI_IFACE, "list_sta"],
239+
capture_output=True, text=True, timeout=3,
240+
)
241+
except (FileNotFoundError, subprocess.TimeoutExpired):
242+
return 0
243+
macs = [m.strip() for m in (out.stdout or "").splitlines() if m.strip()]
244+
for mac in macs:
245+
try:
246+
subprocess.run(
247+
["sudo", "hostapd_cli", "-p", "/var/run/hostapd", "-i", WIFI_IFACE,
248+
"deauthenticate", mac],
249+
capture_output=True, text=True, timeout=3,
250+
)
251+
except Exception:
252+
pass
253+
return len(macs)
254+
255+
233256
def _hostapd_state() -> str:
234257
"""Query hostapd's actual state via ctrl-interface. Returns the `state=`
235258
value (DISABLED / COUNTRY_UPDATE / HT_SCAN / ENABLED / …) or "" on error."""

src/main/services/projection/driver/aa/stack/system/__tests__/hwaddr.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,24 @@ describe('detectBtMac', () => {
4545
expect(detectBtMac()).toBe('11:22:33:44:55:66')
4646
})
4747

48-
test('falls back to hciconfig when sysfs has nothing', () => {
48+
test('falls back to busctl when sysfs has nothing', () => {
4949
mockReaddirSync.mockReturnValueOnce([])
50+
mockExecSync.mockReturnValueOnce('s "AA:BB:CC:DD:EE:FF"\n')
51+
expect(detectBtMac()).toBe('AA:BB:CC:DD:EE:FF')
52+
})
53+
54+
test('falls back to hciconfig when sysfs and busctl have nothing', () => {
55+
mockReaddirSync.mockReturnValueOnce([])
56+
mockExecSync.mockImplementationOnce(() => {
57+
throw new Error('busctl missing')
58+
})
5059
mockExecSync.mockReturnValueOnce('BD Address: AA:BB:CC:DD:EE:FF ACL MTU: ...\n')
5160
expect(detectBtMac()).toBe('AA:BB:CC:DD:EE:FF')
5261
})
5362

5463
test('returns undefined when nothing is detected', () => {
5564
mockReaddirSync.mockReturnValueOnce([])
56-
mockExecSync.mockImplementationOnce(() => {
65+
mockExecSync.mockImplementation(() => {
5766
throw new Error('not found')
5867
})
5968
expect(detectBtMac()).toBeUndefined()

src/main/services/projection/driver/aa/stack/system/hwaddr.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ function readBtMacFromHciconfig(iface = 'hci0'): string | null {
3939
}
4040
}
4141

42+
function readBtMacFromBusctl(iface = 'hci0'): string | null {
43+
try {
44+
const out = execSync(
45+
`busctl --system get-property org.bluez /org/bluez/${iface} org.bluez.Adapter1 Address 2>/dev/null`,
46+
{ encoding: 'utf8', timeout: 2000 }
47+
)
48+
// Output format: s "AA:BB:CC:DD:EE:FF"
49+
const m = out.match(/"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})"/)
50+
return m ? m[1]!.toUpperCase() : null
51+
} catch {
52+
return null
53+
}
54+
}
55+
4256
export function detectBtMac(iface?: string): string | undefined {
4357
if (process.env['AA_BT_MAC']) return process.env['AA_BT_MAC']
4458

@@ -52,12 +66,19 @@ export function detectBtMac(iface?: string): string | undefined {
5266
}
5367
}
5468

55-
// Fallback: hciconfig (covers kernels that don't expose sysfs address)
69+
// Pi OS / newer kernels don't expose hci0/address in sysfs — go via BlueZ D-Bus.
5670
const hciFace = iface ?? 'hci0'
57-
const mac = readBtMacFromHciconfig(hciFace)
58-
if (mac) {
59-
console.log(`[hwaddr] BT MAC detected from hciconfig: ${mac} (${hciFace})`)
60-
return mac
71+
const macBusctl = readBtMacFromBusctl(hciFace)
72+
if (macBusctl) {
73+
console.log(`[hwaddr] BT MAC detected from busctl: ${macBusctl} (${hciFace})`)
74+
return macBusctl
75+
}
76+
77+
// Last resort: hciconfig (often not installed on modern distros).
78+
const macHci = readBtMacFromHciconfig(hciFace)
79+
if (macHci) {
80+
console.log(`[hwaddr] BT MAC detected from hciconfig: ${macHci} (${hciFace})`)
81+
return macHci
6182
}
6283

6384
console.warn('[hwaddr] Could not detect BT MAC. Set AA_BT_MAC env var if needed.')

src/main/services/projection/services/ProjectionService.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export class ProjectionService {
133133
private wirelessPhoneInRange = false
134134
private btInitialQueryDone = false
135135
private isSwitching = false
136+
private sessionActiveSent: boolean | null = null
136137

137138
private readonly onAaConnected = (): void => {
138139
this.refreshAaBtPairedList().catch(() => {})
@@ -241,11 +242,15 @@ export class ProjectionService {
241242
sup.on('stderr', (line) => console.warn(`[aa-bt!] ${line}`))
242243
sup.on('error', (err) => console.warn(`[aa-bt] supervisor error: ${err.message}`))
243244
this.aaBtSupervisor = sup
245+
this.sessionActiveSent = null
244246
console.log('[ProjectionService] starting AA BT/Wi-Fi supervisor')
245247
sup.start(this.config)
246248
this.openAaBtSubscription()
247249
this.populateAaBtPairedListInitial()
248-
.then(() => this.tryAutoConnect())
250+
.then(() => {
251+
this.emitTransportState()
252+
return this.tryAutoConnect()
253+
})
249254
.catch(() => {})
250255
return
251256
}
@@ -257,6 +262,7 @@ export class ProjectionService {
257262
this.closeAaBtSubscription()
258263
this.setWirelessPhoneInRange(false)
259264
this.btInitialQueryDone = false
265+
this.sessionActiveSent = null
260266
}
261267
}
262268

@@ -989,6 +995,22 @@ export class ProjectionService {
989995
type: 'transportState',
990996
payload: this.arbiter.getSnapshot()
991997
})
998+
if (this.aaBtSupervisor) {
999+
const aaActive = this.started && this.drivers.getAa() !== null
1000+
void this.setSessionActive(aaActive)
1001+
}
1002+
}
1003+
1004+
/** Tell the BT reconnect worker to pause while an AA session is active. */
1005+
private async setSessionActive(active: boolean): Promise<void> {
1006+
if (!this.aaBtSupervisor || this.sessionActiveSent === active) return
1007+
this.sessionActiveSent = active
1008+
try {
1009+
await this.aaBtSock.setSessionActive(active)
1010+
} catch (e) {
1011+
this.sessionActiveSent = null
1012+
console.warn(`[ProjectionService] setSessionActive(${active}) failed`, e)
1013+
}
9921014
}
9931015

9941016
public async switchTransport(): Promise<{ ok: boolean; active: Transport | null }> {
@@ -1005,6 +1027,11 @@ export class ProjectionService {
10051027
const desired = this.arbiter.getOverride()
10061028
if (!desired) break
10071029

1030+
const wasWireless =
1031+
this.started &&
1032+
this.drivers.getAa() !== null &&
1033+
this.drivers.getAa()?.isWiredMode() === false
1034+
10081035
if (this.started) {
10091036
try {
10101037
await this.stop()
@@ -1013,8 +1040,16 @@ export class ProjectionService {
10131040
}
10141041
}
10151042

1043+
if (wasWireless) {
1044+
// Leaving wireless: kick the phone off the AP
1045+
await this.aaBtSock.deauthApClients().catch(() => {})
1046+
}
1047+
10161048
if (desired.transport === 'aa' && desired.mode === 'wireless') {
10171049
await this.bounceAaBtConnections()
1050+
// Give BlueZ a moment to commit the disconnect before we re-wake.
1051+
await new Promise((r) => setTimeout(r, 500))
1052+
await this.tryAutoConnect()
10181053
}
10191054

10201055
await this.autoStartIfNeeded()
@@ -1064,7 +1099,11 @@ export class ProjectionService {
10641099
const connected = devices.find((d) => d.connected)?.mac ?? ''
10651100
const wasSettled = this.btInitialQueryDone
10661101
this.btInitialQueryDone = true
1067-
this.setWirelessPhoneInRange(connected !== '')
1102+
// During wired AA we deliberately don't auto-wake the phone, so it won't
1103+
// show as BT-connected. Treat any paired device as in-range.
1104+
const wiredAaActive = this.started && this.drivers.getAa()?.isWiredMode() === true
1105+
const offerable = connected !== '' || (wiredAaActive && devices.length > 0)
1106+
this.setWirelessPhoneInRange(offerable)
10681107
if (!wasSettled) this.autoStartIfNeeded().catch(console.error)
10691108

10701109
if (this.aaDriver) {
@@ -1126,6 +1165,11 @@ export class ProjectionService {
11261165
// Pick a target from the paired list and fire a single Connect
11271166
private async tryAutoConnect(): Promise<void> {
11281167
if (!this.aaBtSupervisor) return
1168+
// Don't poke the phone over BT while a wired session is already running
1169+
if (this.started && this.drivers.getAa()?.isWiredMode() === true) {
1170+
console.log('[ProjectionService] autoconnect: skipped (wired AA session active)')
1171+
return
1172+
}
11291173

11301174
let devices
11311175
try {

src/main/services/projection/transport/TransportArbiter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ export class TransportArbiter {
163163
const list: Candidate[] = []
164164
if (this.dongleConnected) list.push(DONGLE)
165165
if (this.phoneConnected) list.push(AA_WIRED)
166-
if (this.deps.isWirelessEnabled() && this.deps.isWirelessPhoneInRange()) list.push(AA_WIRELESS)
166+
const offerWireless =
167+
this.deps.isWirelessEnabled() &&
168+
(this.deps.isWirelessPhoneInRange() || this.deps.isWiredAaSessionActive())
169+
if (offerWireless) list.push(AA_WIRELESS)
167170
return list
168171
}
169172

@@ -271,7 +274,10 @@ export class TransportArbiter {
271274
dongleDetected: this.dongleConnected,
272275
wiredPhoneDetected: this.phoneConnected,
273276
wirelessPhoneDetected:
274-
this.deps.isWirelessEnabled() && (this.deps.isWirelessPhoneInRange() || wirelessActiveNow),
277+
this.deps.isWirelessEnabled() &&
278+
(this.deps.isWirelessPhoneInRange() ||
279+
wirelessActiveNow ||
280+
this.deps.isWiredAaSessionActive()),
275281
wiredPhoneActive: isPhoneActive && wired,
276282
wirelessPhoneActive: wirelessActiveNow,
277283
preference: this.deps.getPreference()

0 commit comments

Comments
 (0)