diff options
author | David Timber <dxdt@dev.snart.me> | 2025-05-29 13:24:07 +0900 |
---|---|---|
committer | David Timber <dxdt@dev.snart.me> | 2025-05-29 13:24:07 +0900 |
commit | a959d91b1eb8b89b27eeb9c544280b7b52067380 (patch) | |
tree | d0138e07f22c0795321005b4e87e164fe9734ec9 | |
parent | f953db685c356f99f61f5121e52610c1a1d612a5 (diff) |
Add mmfwd-callam (call answering machine)
-rw-r--r-- | doc/mmfwd.sample.yaml | 3 | ||||
-rw-r--r-- | src/mmfwd-callam.cpp | 348 | ||||
-rw-r--r-- | src/mmfwd/__init__.py | 128 |
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 |