#!/usr/bin/env python3
"""
PizzaPOS landline caller-ID agent — for shops on a normal phone line (no SIM,
no VoIP). Plug a USB caller-ID modem into the phone socket (in parallel with
your phone, via an RJ11 splitter) and into the till PC's USB. This watches the
modem's serial port and, the moment the line rings, POSTs the caller's number
to PizzaPOS — so the customer pops up on screen exactly like the SIM/VoIP paths.

It talks to the SAME capture endpoint as everything else (call.php), so the
server needs no special setup — just your shop token.

Config via environment variables (or call-agent.conf next to this file):
  POS_TOKEN    your shop token (POS -> Setup tab)                  [required]
  POS_SERIAL   modem serial port. Leave blank to auto-detect.      [optional]
                 Linux:   /dev/ttyACM0  (or /dev/ttyUSB0)
                 macOS:   /dev/tty.usbmodem1411
                 Windows: COM3
  POS_BAUD     serial speed (default 115200; USB modems ignore it)
  POS_SERVER   default https://pizza.aydayazdani.com
  POS_CID_INIT extra AT init command to enable caller ID (default tries the
               common ones automatically)

Run:  POS_TOKEN=xxxx python3 caller_id.py
Needs Python 3 + pyserial:  pip3 install pyserial
"""
import os, sys, time, json, glob, re, urllib.request, urllib.parse

try:
    import serial  # pyserial
except ImportError:
    sys.exit("This agent needs pyserial. Install it with:  pip3 install pyserial")

# ── config ───────────────────────────────────────────────────────────────────
_conf = {}
_conf_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "call-agent.conf")
if os.path.exists(_conf_path):
    for ln in open(_conf_path):
        ln = ln.strip()
        if ln and not ln.startswith("#") and "=" in ln:
            k, _, v = ln.partition("=")
            _conf[k.strip()] = v.strip()

def cfg(k, d=""):
    return os.environ.get(k, _conf.get(k, d)).strip()

SERVER = cfg("POS_SERVER", "https://pizza.aydayazdani.com").rstrip("/")
TOKEN  = cfg("POS_TOKEN")
PORT   = cfg("POS_SERIAL")
BAUD   = int(cfg("POS_BAUD", "115200") or "115200")
CIDCMD = cfg("POS_CID_INIT")

if not TOKEN:
    sys.exit("Set POS_TOKEN to your shop token (POS -> Setup tab).")

# Caller-ID lines from a modem look like:   NMBR = 07700900123
#   NMBR / NUMBER / CALLERID, plus NAME, DATE, TIME.  Out-of-area/private lines
#   report letters (O / P) or words instead of a number -> treated as withheld.
NUM_RE  = re.compile(r"\b(?:NMBR|NUMBER|CALLERID|CID)\s*[=:]\s*(.+)", re.I)
PRIV_RE = re.compile(r"private|withheld|anonym|blocked|out.?of.?area|unavail|^[OP]$", re.I)

def find_serial_port():
    if PORT:
        return PORT
    cands = (glob.glob("/dev/ttyACM*") + glob.glob("/dev/ttyUSB*") +
             glob.glob("/dev/tty.usbmodem*") + glob.glob("/dev/tty.usbserial*") +
             glob.glob("/dev/cu.usbmodem*") + glob.glob("/dev/cu.usbserial*"))
    if cands:
        return sorted(cands)[0]
    if os.name == "nt":
        return "COM3"
    sys.exit("No modem serial port found. Plug in the USB caller-ID modem, or set POS_SERIAL "
             "(e.g. /dev/ttyACM0 on Linux, COM3 on Windows).")

def post_number(num, raw):
    body = json.dumps({"number": num, "ts": int(time.time())}).encode()
    req = urllib.request.Request(SERVER + "/call.php", data=body,
            headers={"X-POS-Secret": TOKEN, "Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=15) as r:
        res = json.load(r)
    print(f"  -> sent {res.get('number', num)}  (raw: {raw!r})")
    return res

def open_modem(port):
    m = serial.Serial(port, BAUD, timeout=1)
    time.sleep(0.3)
    # Enable formatted caller-ID reporting. Different chipsets use different
    # commands, so send the common ones; harmless if a modem ignores some.
    inits = [b"ATZ"]
    # AT+GCI=B4 tells the modem it's on a UK line (T.35 country code B4) so it
    # decodes BT "Caller Display" (V.23 FSK before the first ring) and not just
    # the US scheme. Then enable formatted caller-ID; chipsets vary, so try the
    # common ones (harmless if a modem ignores some).
    inits += [CIDCMD.encode()] if CIDCMD else [b"AT+GCI=B4", b"AT+VCID=1", b"AT#CID=1", b"AT+FCLASS=8;+VCID=1"]
    for cmd in inits:
        m.write(cmd + b"\r")
        time.sleep(0.3)
        m.reset_input_buffer()
    return m

def run():
    port = find_serial_port()
    print(f"PizzaPOS caller-ID agent -> {SERVER}")
    print(f"Listening on {port} @ {BAUD} baud. Ring the line to test. Ctrl-C to stop.")
    last_num, last_at = None, 0.0
    while True:
        try:
            modem = open_modem(port)
        except Exception as e:
            print(f"can't open {port} ({e}); retrying in 5s "
                  f"(check it's plugged in / not used by another program)")
            time.sleep(5)
            continue
        try:
            while True:
                line = modem.readline().decode("latin-1", "replace").strip()
                if not line:
                    continue
                m = NUM_RE.search(line)
                if not m:
                    continue
                val = m.group(1).strip()
                num = "WITHHELD" if (val == "" or PRIV_RE.search(val)) else val
                # de-dupe: a single ring can emit the number more than once
                now = time.time()
                if num == last_num and (now - last_at) < 8:
                    continue
                last_num, last_at = num, now
                print(f"☎ incoming: {num}")
                try:
                    post_number(num, line)
                except Exception as e:
                    print("  send failed (will catch the next ring):", e)
        except Exception as e:
            print("serial error, reopening:", e)
            try:
                modem.close()
            except Exception:
                pass
            time.sleep(2)

if __name__ == "__main__":
    try:
        run()
    except KeyboardInterrupt:
        print("\nbye")
