image parity: imsd 0.3.0 (pinned public commit) + callaudioshim + modem-daemon override

imsd turns out to be fully public already (0.2.7 snapshot + emergency
stage 1 + the make build path) - no tag needed, same git-archive dance as
the kernel. callaudioshim was a load-bearing unpackaged home-dir script on
the phone (callaudiod replacement, working in-call mic mute); now an aport
autostarted at phase 1. kde-telephony's modem daemon autostart is hidden
via /etc/skel from the imsd package, mirroring the phone's user override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-08 20:54:29 +02:00
commit 4e43e6d57b
7 changed files with 274 additions and 9 deletions

View file

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""callaudioshim.py — drop-in replacement for callaudiod on the FP6.
Stock callaudiod 0.1.99 is broken on this hardware: it never finds a
"voice-capable" PulseAudio card (our UCM exposes HiFi with Speaker+Mic, no
Earpiece port), so
- its MicState property stays at the D-Bus default 0 = MIC_OFF and
plasma-dialer's in-call page permanently shows the mic as MUTED;
- MuteMic() bails with "card has no usable source" the mute button does
nothing;
- SelectMode() never replies, blocking kde-telephony-daemon's main loop for
the full 25 s GDBus timeout on every call (journal/ims.md s41).
A second, independent upstream defect (plasma-dialer 6.7.3) makes the UI state
wrong even with a working callaudiod: the mute button binds to
kde-telephony-daemon's DialerUtils `m_mute` — an UNINITIALIZED bool that
nothing ever syncs with reality (it only changes on button presses, and the
daemon never re-reads MicState). So every call page opened showing "muted"
while the mic was live, and the first press was wasted re-syncing. This shim
papers over that too: whenever the REAL mic state changes (and whenever
kde-telephony-daemon [re]appears on the bus), it pushes the truth into
DialerUtils.setMute(b), which updates m_mute and fires muteChanged to the UI.
The loop is self-terminating (the daemon only re-emits on value change, and
the bounce-back MuteMic is a no-op when the state already matches).
This shim owns org.mobian_project.CallAudio instead and implements the same
interface (verified against callaudiod's org.mobian_project.CallAudio.xml):
- MuteMic(b) mutes/unmutes @DEFAULT_AUDIO_SOURCE@ via wpctl. The default
source is ec_source (the AEC'd mic, s43), which every capture stream —
including the call's pw-record — consumes, so mute = real uplink silence
(verified: tone capture RMS 94.5 -> 0.0 on mute).
- MicState mirrors the ACTUAL source mute (polled every 2 s too, so
out-of-band wpctl/pactl changes stay reflected), with PropertiesChanged
so kde-telephony-daemon's cached GDBusProxy tracks it.
- SelectMode / EnableSpeaker reply immediately (bookkeeping only: the
loudspeaker is the only output today, and UCM verb switching is not
dynamic in this setup journal/audio.md 2026-07-18). SpeakerState
defaults to ON. When an Earpiece UCM device exists these become real.
Stock callaudiod must not own the name: pkill it and mask its D-Bus
activation (see journal/ims.md s44 deploy notes). Runs as the session user
(autostart .desktop), no root needed.
"""
import subprocess
import dbus
import dbus.service
import dbus.mainloop.glib
from gi.repository import GLib
BUS_NAME = "org.mobian_project.CallAudio"
OBJ_PATH = "/org/mobian_project/CallAudio"
IFACE = "org.mobian_project.CallAudio"
PROPS_IFACE = "org.freedesktop.DBus.Properties"
SOURCE = "@DEFAULT_AUDIO_SOURCE@"
# kde-telephony-daemon's DialerUtils — the mute-button state lives there
DIALER_BUS = "org.kde.telephony.DialerUtils"
DIALER_PATH = "/org/kde/telephony/DialerUtils/tel/mm"
DIALER_IFACE = "org.kde.telephony.DialerUtils"
MODE_DEFAULT, MODE_CALL = 0, 1
OFF, ON, UNKNOWN = 0, 1, 255
def log(msg):
print(msg, flush=True)
def source_muted():
"""True/False from wpctl ('Volume: 1.00 [MUTED]'), None if unreadable."""
try:
out = subprocess.run(["wpctl", "get-volume", SOURCE],
capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.TimeoutExpired):
return None
if "Volume:" not in out:
return None
return "[MUTED]" in out
def set_source_muted(mute):
return subprocess.run(
["wpctl", "set-mute", SOURCE, "1" if mute else "0"],
capture_output=True, timeout=5).returncode == 0
class CallAudio(dbus.service.Object):
def __init__(self, bus):
super().__init__(bus, OBJ_PATH)
self.bus = bus
self.audio_mode = MODE_DEFAULT
self.speaker_state = ON # loudspeaker is the only output today
m = source_muted()
self.mic_state = UNKNOWN if m is None else (OFF if m else ON)
log(f"callaudioshim: up, MicState={self.mic_state}")
GLib.timeout_add_seconds(2, self._poll)
self._push_dialer()
# re-push whenever kde-telephony-daemon (re)appears: its m_mute is an
# uninitialized bool until someone calls setMute
bus.watch_name_owner(DIALER_BUS, self._dialer_owner_changed)
# -------------------------------------------- DialerUtils state push
def _dialer_owner_changed(self, owner):
if owner:
log(f"callaudioshim: DialerUtils owner {owner}; pushing mute state")
self._push_dialer()
def _push_dialer(self):
if self.mic_state == UNKNOWN:
return
try:
self.bus.call_async(DIALER_BUS, DIALER_PATH, DIALER_IFACE,
"setMute", "b", (self.mic_state == OFF,),
lambda *a: None, lambda *a: None)
except dbus.DBusException:
pass
# ------------------------------------------------ property plumbing
def _props(self):
return {
"AudioMode": dbus.UInt32(self.audio_mode),
"SpeakerState": dbus.UInt32(self.speaker_state),
"MicState": dbus.UInt32(self.mic_state),
}
def _set(self, attr, prop, value):
if getattr(self, attr) == value:
return
setattr(self, attr, value)
self.PropertiesChanged(IFACE, {prop: dbus.UInt32(value)}, [])
if prop == "MicState":
self._push_dialer()
def _poll(self):
# catch out-of-band mute changes (wpctl, pactl, other clients)
m = source_muted()
if m is not None:
self._set("mic_state", "MicState", OFF if m else ON)
return True
@dbus.service.method(PROPS_IFACE, in_signature="ss", out_signature="v")
def Get(self, interface, prop):
return self._props()[prop]
@dbus.service.method(PROPS_IFACE, in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
return self._props()
@dbus.service.signal(PROPS_IFACE, signature="sa{sv}as")
def PropertiesChanged(self, interface, changed, invalidated):
pass
# ------------------------------------------------ org.mobian_project.CallAudio
@dbus.service.method(IFACE, in_signature="u", out_signature="b")
def SelectMode(self, mode):
if mode not in (MODE_DEFAULT, MODE_CALL):
raise dbus.exceptions.DBusException(
"invalid mode", name="org.freedesktop.DBus.Error.InvalidArgs")
log(f"callaudioshim: SelectMode({int(mode)})")
self._set("audio_mode", "AudioMode", int(mode))
if mode == MODE_DEFAULT:
# leaving a call: never leave the mic muted behind
if source_muted():
set_source_muted(False)
self._set("mic_state", "MicState", ON)
return True
@dbus.service.method(IFACE, in_signature="b", out_signature="b")
def EnableSpeaker(self, enable):
log(f"callaudioshim: EnableSpeaker({bool(enable)})")
self._set("speaker_state", "SpeakerState", ON if enable else OFF)
return True
@dbus.service.method(IFACE, in_signature="b", out_signature="b")
def MuteMic(self, mute):
ok = set_source_muted(bool(mute))
log(f"callaudioshim: MuteMic({bool(mute)}) -> {ok}")
if not ok:
return False
self._set("mic_state", "MicState", OFF if mute else ON)
return True
def main():
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
session = dbus.SessionBus()
name = dbus.service.BusName(BUS_NAME, session, do_not_queue=True) # noqa: F841
CallAudio(session)
log(f"callaudioshim: owning {BUS_NAME}")
GLib.MainLoop().run()
if __name__ == "__main__":
main()