aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Timber <dxdt@dev.snart.me>2025-05-29 13:24:07 +0900
committerDavid Timber <dxdt@dev.snart.me>2025-05-29 13:24:07 +0900
commita959d91b1eb8b89b27eeb9c544280b7b52067380 (patch)
treed0138e07f22c0795321005b4e87e164fe9734ec9
parentf953db685c356f99f61f5121e52610c1a1d612a5 (diff)
Add mmfwd-callam (call answering machine)
-rw-r--r--doc/mmfwd.sample.yaml3
-rw-r--r--src/mmfwd-callam.cpp348
-rw-r--r--src/mmfwd/__init__.py128
3 files changed, 460 insertions, 19 deletions
diff --git a/doc/mmfwd.sample.yaml b/doc/mmfwd.sample.yaml
index 7079708..0d4396a 100644
--- a/doc/mmfwd.sample.yaml
+++ b/doc/mmfwd.sample.yaml
@@ -9,3 +9,6 @@ mmfwd:
- /usr/bin/mailx
- jd@example.com
- -s Text from {sender}
+ call-am:
+ enabled: false
+ exec: "/usr/local/mmfwd-callam"
diff --git a/src/mmfwd-callam.cpp b/src/mmfwd-callam.cpp
new file mode 100644
index 0000000..f666721
--- /dev/null
+++ b/src/mmfwd-callam.cpp
@@ -0,0 +1,348 @@
+#include <iostream>
+#include <fstream>
+#include <vector>
+#include <string>
+#include <cstdint>
+#include <unistd.h>
+#include <fcntl.h>
+#include <csignal>
+#include <glob.h>
+#include <sys/mman.h>
+#include <poll.h>
+#include <cassert>
+#include <cerrno>
+#include <cstring>
+#include <termio.h>
+
+// mmfwd call answering machine
+
+// read a filename from stdin
+// create the file and start recording
+// until an empty string or a filename is read from stdin
+
+// stty -F /dev/ttyUSB4 -drain raw -echo
+// aplay -f S16_LE -r8000 -c1 /dev/ttyUSB4
+// ffmpeg -re -i ... -ar 8000 -f s16le -ac 1 - > /dev/ttyUSB4
+
+#define ARGV0 "mmfwd-callam"
+
+struct {
+ const char *dev;
+ const char *hello_sample_pat; // glob patterns
+ const char *outfile_prefix;
+ struct {
+ const char *sample_rate;
+ const char *channels;
+ const char *fmt;
+ bool playback; // TODO
+ } playback;
+ int vl;
+} params;
+
+struct {
+ struct {
+ std::string path;
+ const void *m;
+ size_t len;
+ } hsample;
+ std::string outfile[2];
+ int fd_dev;
+ int fd[2];
+ int playback[2];
+} g;
+
+
+std::vector<uint8_t> geturnd (const size_t l) {
+ std::vector<uint8_t> ret(l);
+ std::ifstream f("/dev/urandom", std::ios_base::in | std::ios_base::binary);
+ f.read((char*)ret.data(), l);
+ return ret;
+}
+
+size_t getrndsize (void) {
+ const auto r = geturnd(sizeof(size_t));
+ return *(const size_t*)r.data();
+}
+
+void init_params (void) {
+ params.hello_sample_pat = "hello/*.pcm";
+ params.playback.sample_rate = "8000";
+ params.playback.channels = "1";
+ params.playback.fmt = "S16_LE";
+}
+
+void init_g (void) {
+ g.fd_dev = -1;
+ g.fd[0] = g.fd[1] = -1;
+ g.playback[0] = g.playback[1] = -1;
+}
+
+bool open_dev (void) {
+ struct termios tis;
+ const int fd = open(params.dev, O_RDWR | O_NONBLOCK);
+
+ if (fd < 0) {
+ std::cerr << ARGV0 << ": ";
+ perror(params.dev);
+ return false;
+ }
+
+ // doesn't fucking work. just reset the damn modem
+ // tcflush(fd, TCIOFLUSH);
+
+ // make the serial raw
+ tcgetattr(fd, &tis);
+ cfmakeraw(&tis);
+ tis.c_cflag &= ~CRTSCTS;
+ tcsetattr(fd, TCSANOW, &tis);
+
+ g.fd_dev = fd;
+
+ return true;
+}
+
+bool open_hsample (void) {
+ bool ret = false;
+ glob_t gl;
+ size_t n = getrndsize();
+ int fr, fd = -1;
+ off_t flen;
+ const void *mapped;
+ std::string errmsg, path;
+
+ memset(&gl, 0, sizeof(gl));
+
+ do {
+ fr = glob(params.hello_sample_pat, GLOB_NOSORT, NULL, &gl);
+ switch (fr) {
+ case GLOB_ABORTED: errmsg = "read aborted"; break;
+ case GLOB_NOMATCH: errmsg = "no match"; break;
+ }
+ if (fr != 0) {
+ std::cerr << ARGV0 << ": " << params.hello_sample_pat << ": ";
+
+ if (errmsg.empty()) {
+ perror(NULL);
+ }
+ else {
+ std::cerr << errmsg << std::endl;
+ }
+ break;
+ }
+
+ n = n % gl.gl_pathc;
+ path = gl.gl_pathv[n];
+
+ fd = open(path.c_str(), O_RDONLY);
+ if (fd < 0) {
+ std::cerr << ARGV0 << ": " << path << ": ";
+ perror(NULL);
+ break;
+ }
+
+ flen = lseek(fd, 0, SEEK_END);
+ if (flen < 0) {
+ std::cerr << ARGV0 << ": " << path << ": ";
+ perror(NULL);
+ break;
+ }
+
+ mapped = mmap(NULL, (size_t)flen, PROT_READ, MAP_PRIVATE, fd, 0);
+ if (mapped == MAP_FAILED) {
+ std::cerr << ARGV0 << ": " << path << ": ";
+ perror(NULL);
+ break;
+ }
+
+ g.hsample.m = mapped;
+ g.hsample.len = (size_t)flen;
+ g.hsample.path = std::move(path);
+ ret = true;
+ std::cerr << ARGV0 << ": hello sample: " << g.hsample.path << std::endl;
+ } while (false);
+
+ close(fd);
+ globfree(&gl);
+ return ret;
+}
+
+bool open_fds (void) {
+ std::string path[2];
+
+ path[0] = path[1] = params.outfile_prefix;
+ path[0] += ".in.pcm";
+ path[1] += ".out.pcm";
+
+ for (size_t i = 0; i < 2; i += 1) {
+ g.fd[i] = open(path[i].c_str(), O_WRONLY | O_CREAT, 0644);
+ if (g.fd[i] < 0) {
+ std::cerr << ARGV0 << ": " << path[i] << ": ";
+ perror(NULL);
+
+ return false;
+ }
+ }
+
+ g.outfile[0] = std::move(path[0]);
+ g.outfile[1] = std::move(path[1]);
+
+ return true;
+}
+
+bool write_full (const int fd, const void *m, size_t len, const std::string &fn) {
+ const uint8_t *ptr = (const uint8_t*)m;
+ ssize_t iofr;
+
+ while (len > 0) {
+ iofr = write(fd, ptr, len);
+ if (iofr < 0) {
+ std::cerr << ARGV0 << ": " << fn << ": ";
+ perror(NULL);
+ return false;
+ }
+ assert(iofr != 0);
+
+ ptr += iofr;
+ len -= iofr;
+ }
+
+ return true;
+}
+
+bool loop_main (void) {
+ uint8_t buf[4096];
+ size_t sample_pos = 0;
+ int fr;
+ ssize_t iofr;
+ size_t blen;
+ struct pollfd pfd;
+
+ memset(&pfd, 0, sizeof(pfd));
+
+ pfd.fd = g.fd_dev;
+
+ while (true) {
+ if (sample_pos < g.hsample.len) {
+ pfd.events = POLLIN | POLLOUT;
+ }
+ else {
+ pfd.events = POLLIN;
+ }
+
+ fr = poll(&pfd, 1, -1);
+ assert(fr != 0);
+ if (fr < 0) {
+ perror(ARGV0 ": poll()");
+ abort();
+ }
+
+ if (pfd.events & POLLIN) {
+ iofr = read(g.fd_dev, buf, sizeof(buf));
+ if (iofr == 0) {
+ std::cerr << ARGV0 << ": " << params.dev << ": read EOF reached" << std::endl;
+ return true;
+ }
+ else if (iofr < 0) {
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+ continue;
+ }
+ std::cerr << ARGV0 << ": " << params.dev << ": ";
+ perror(NULL);
+ return false;
+ }
+ blen = (size_t)iofr;
+
+ if (params.vl > 1) {
+ std::cerr << ARGV0 ": < " << iofr << std::endl;
+ }
+
+ if (!write_full(g.fd[0], buf, blen, g.outfile[0])) {
+ return false;
+ }
+
+ if (g.playback[0] >= 0) {
+ iofr = write(g.playback[0], buf, blen);
+ if (iofr < 0) {
+ close(g.playback[0]);
+ g.playback[0] = -1;
+ }
+ }
+ }
+
+ if (pfd.events & POLLOUT) {
+ const void *ptr = (const uint8_t*)g.hsample.m + sample_pos;
+
+ iofr = write(g.fd_dev, ptr, g.hsample.len - sample_pos);
+ if (iofr == 0) {
+ std::cerr << ARGV0 << ": " << params.dev << ": write EOF reached" << std::endl;
+ return true;
+ }
+ else if (iofr < 0) {
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+ continue;
+ }
+ std::cerr << ARGV0 << ": " << params.dev << ": ";
+ perror(NULL);
+ return false;
+ }
+ blen = (size_t)iofr;
+
+ if (params.vl > 1) {
+ std::cerr << ARGV0 ": > " << iofr << std::endl;
+ }
+
+ if (!write_full(g.fd[1], ptr, blen, g.outfile[1])) {
+ return false;
+ }
+
+ if (g.playback[1] >= 0) {
+ iofr = write(g.playback[1], ptr, blen);
+ if (iofr < 0) {
+ close(g.playback[1]);
+ g.playback[1] = -1;
+ }
+ }
+
+ sample_pos += blen;
+ if (sample_pos >= g.hsample.len) {
+ std::cerr << ARGV0 ": playback finished" << std::endl;
+ }
+ }
+ }
+}
+
+void handle_termsig (int) {
+ // doesn't fucking work. just reset the damn modem
+ // tcflush(g.fd_dev, TCIOFLUSH);
+ close(g.fd_dev);
+
+ close(g.fd[0]);
+ close(g.fd[1]);
+
+ close(g.playback[0]);
+ close(g.playback[1]);
+
+ exit(0);
+}
+
+int main (const int argc, const char **argv) {
+ init_params();
+ init_g();
+ // TODO: getopt()
+
+ if (argc <= 2) {
+ std::cerr
+ << "Usage: " ARGV0 " <modem audio char path> <output prefix>"
+ << std::endl;
+ return 2;
+ }
+
+ params.dev = argv[1];
+ params.outfile_prefix = argv[2];
+
+ if (!open_dev() || !open_hsample() || !open_fds()) {
+ return 1;
+ }
+
+ return loop_main() ? 0 : 1;
+}
diff --git a/src/mmfwd/__init__.py b/src/mmfwd/__init__.py
index b3a7b54..17d1485 100644
--- a/src/mmfwd/__init__.py
+++ b/src/mmfwd/__init__.py
@@ -1,14 +1,15 @@
from copy import copy
+import datetime
import os
import re
import subprocess
import sys
-import threading
from typing import Any
import gi
import yaml
gi.require_version('ModemManager', '1.0')
-from gi.repository import Gio, ModemManager
+gi.require_version('GLib', '2.0')
+from gi.repository import GLib, Gio, ModemManager
try:
from yaml import CLoader as Loader, CDumper as Dumper
@@ -65,6 +66,11 @@ class Instance:
self.mobj: str = None
self.mid = ModemIdentity(conf.get("mid"))
self.fwd = Forward(conf.get("fwd"))
+ self.callam = conf.get("call-am", {
+ 'enabled': False
+ })
+ self.callam_proc = None
+ self.callam_timer = None
def match (self, m) -> bool:
if self.mid.n_own:
@@ -120,6 +126,16 @@ class Application:
ud.messaging = messaging
ud.voice = voice
ud.own_numbers = modem.get_property('own-numbers')
+ ud.device = modem.get_property('device')
+ ud.audio_port = None
+
+ if instance.callam['enabled']:
+ for p in modem.get_property('ports'):
+ if p[1] == ModemManager.ModemPortType.AUDIO:
+ ud.audio_port = p[0]
+ break
+
+ assert ud.audio_port is not None, "Call-am enabled, but the modem has no audio port"
modem.connect('state-changed', self.on_modem_state_updated, ud)
messaging.connect('added', self.on_message_added, ud)
@@ -127,7 +143,7 @@ class Application:
# fire request to sync
messaging.list(None, self.on_messages, ud)
- voice.list_calls(None, self.on_calls, ud)
+ voice.list_calls(None, self.on_calls_sync, ud)
def set_available(self):
"""
@@ -245,7 +261,8 @@ class Application:
messaging.delete_finish(task)
def on_call_added (self, voice, path, ud):
- voice.list_calls(None, self.on_calls, ud)
+ print("on_call_added()") # FIXME
+ voice.list_calls(None, self.on_calls_added, ud)
def on_incoming_call (self, call, ud):
doc = {
@@ -260,40 +277,113 @@ class Application:
yaml.dump(doc, sys.stdout, allow_unicode = True)
ud.instance.fwd.post_call(doc)
- def on_calls (self, voice, task, ud = None):
+ def on_calls_sync (self, voice, task, ud):
+ print("on_calls_sync()") # FIXME
for c in voice.list_calls_finish(task):
state = c.get_state()
path = c.get_path()
+
+ if (state == ModemManager.CallState.ACTIVE or
+ state == ModemManager.CallState.RINGING_IN):
+ c.hangup(None, self.on_call_hangup, ud)
+ elif state == ModemManager.CallState.TERMINATED:
+ voice.delete_call(path, None, self.on_call_delete, None)
+
+ def on_calls_cleanup (self, voice, task, ud):
+ print("on_calls_cleanup()") # FIXME
+ for c in voice.list_calls_finish(task):
+ if c.get_state() == ModemManager.CallState.TERMINATED:
+ voice.delete_call(c.get_path(), None, self.on_call_delete, None)
+
+ def on_calls_added (self, voice, task, ud):
+ print("on_calls_added()") # FIXME
+
+ hasCall = False
+ for c in voice.list_calls_finish(task):
+ state = c.get_state()
nud = copy(ud)
nud.call = c
- if state == ModemManager.CallState.ACTIVE:
+ if state != ModemManager.CallState.RINGING_IN:
+ continue
+
+ if hasCall:
c.hangup(None, self.on_call_hangup, nud)
- elif state == ModemManager.CallState.RINGING_IN:
+ else:
+ hasCall = True
self.on_incoming_call(c, ud)
- if True:
- # FIXME
- # just hang up for now
- c.hangup(None, self.on_call_hangup, nud)
- else:
+ if ud.instance.callam['enabled']:
c.accept(None, self.on_call_accept, nud)
- elif state == ModemManager.CallState.TERMINATED:
- voice.delete_call(path, None, self.on_call_delete, nud)
+ else:
+ c.hangup(None, self.on_call_hangup, nud)
def on_call_change (self, call, old, new, reason, ud):
- ud.voice.list_calls(None, self.on_calls, ud)
+ print("on_call_change()") # FIXME
+ if new == ModemManager.CallState.TERMINATED:
+ ud.voice.list_calls(None, self.on_calls_cleanup, ud)
+ else:
+ call.hangup(None, self.on_call_hangup, ud)
+
+ if ud.instance.callam_proc is not None:
+ ud.instance.callam_proc.terminate()
+ ud.instance.callam_proc.wait()
+ ud.instance.callam_proc = None
+
+ # reset the modem
+ # fucking hate this cheap BS modem
+ path = "%s/bConfigurationValue" % ud.device
+ os.system('''echo -1 > ''' + path)
+ os.system('''echo 1 > ''' + path)
+
+ if ud.instance.callam_timer is not None:
+ GLib.source_remove(ud.instance.callam_timer)
+ ud.instance.callam_timer = None
def on_call_hangup (self, call, task, ud):
+ print("on_call_hangup()") # FIXME
call.hangup_finish(task)
- ud.voice.list_calls(None, self.on_calls, ud)
+ ud.voice.list_calls(None, self.on_calls_cleanup, ud)
def on_call_delete (self, voice, task, ud):
- voice.delete_call_finish(task)
+ try:
+ voice.delete_call_finish(task)
+ except: pass
def on_call_accept (self, call, task, ud):
- call.accept_finish(task)
+ try:
+ call.accept_finish(task)
+ except gi.repository.GLib.GError as e:
+ sys.stderr.write("on_call_accept(): " + str(e))
+ return
+ print("on_call_accept()") # FIXME
call.connect('state-changed', self.on_call_change, ud)
# The custom ModemManager will send AT+CPCMREG.
- # TODO: play the voice message
+ # mmfwd-callam process will set up the serial, play the hello message
+ # and record
+ try:
+ now = datetime.datetime.now(datetime.UTC)
+
+ n_from = call.get_number() or ""
+
+ dir = "rec/%02d-%02d" % (now.year, now.month)
+ filename = now.isoformat(timespec = 'milliseconds') + '_' + n_from
+ path = dir + '/' + filename
+
+ os.makedirs(dir, exist_ok = True)
+
+ exec = [ ud.instance.callam['exec'], "/dev/" + ud.audio_port, path ]
+ ud.instance.callam_proc = subprocess.Popen(exec)
+ # 5 minutes timeout
+ ud.instance.callam_timer = GLib.timeout_add_seconds(
+ 60 * 4,
+ self.on_call_timeout,
+ ud)
+ except Exception as e:
+ raise e
+
+ def on_call_timeout (self, ud):
+ ud.call.hangup(None, self.on_call_hangup, ud)
+ ud.instance.callam_timer = None
+ return False