--- a/__main__.py	2026-10-04 20:47:07.340151505 +0000
+++ b/__main__.py	2026-10-04 20:47:07.342505608 +0000
@@ -29,6 +29,8 @@
     wrt("\t-h | -? | --help\tThis help page\n")
     wrt("\t-i # | --ini #\t\tAlternative ini file for testing\n")
     wrt("\t-l | --list\t\tList all recently opened files\n")
+    wrt("\t--ntfy-topic #\t\tntfy topic for phone alerts (overrides\n")
+    wrt("\t\t\t\t$BCNC_NTFY_TOPIC and ~/.bCNC-ntfy-topic)\n")
     wrt("\t-p # | --pendant #\tOpen pendant to specified port\n")
     wrt("\t-P\t\t\tDo not start pendant\n")
     wrt("\t-r | --recent\t\tLoad the most recent file opened\n")
@@ -69,6 +71,7 @@
                 "serial=",
                 "baud=",
                 "run",
+                "ntfy-topic=",
             ],
         )
     except getopt.GetoptError:
@@ -150,6 +153,10 @@
         elif opt == "--run":
             run = True
 
+        elif opt == "--ntfy-topic":
+            # Read by Sender.ntfy_topic() when an alert is sent
+            os.environ["BCNC_NTFY_TOPIC"] = val.strip()
+
     application = bmain.Application(className=f"  {Utils.__prg__}  ")
 
     palette = {"background": application.cget("background")}
--- a/Sender.py	2026-10-04 20:47:07.338954822 +0000
+++ b/Sender.py	2026-10-04 20:47:07.341182013 +0000
@@ -11,6 +11,7 @@
 import threading
 import time
 import traceback
+import urllib.request
 import webbrowser
 from datetime import datetime
 from tkinter import messagebox
@@ -24,6 +25,121 @@
 import Utils
 from CNC import CNC, MSG, UPDATE, WAIT, GCode
 
+# ----------------------------------------------------------------------
+# Phone alerts via ntfy (https://ntfy.sh)
+# ----------------------------------------------------------------------
+ALARM_REPEATS = 5  # total alarm alerts while the alarm persists
+ALARM_REPEAT_SECS = 120  # seconds between repeated alarm alerts
+NTFY_TOPIC_FILE = os.path.expanduser("~/.bCNC-ntfy-topic")
+_ntfy_warned = [False]
+
+
+def ntfy_topic():
+    """ntfy topic from $BCNC_NTFY_TOPIC (which the --ntfy-topic launch
+    option sets), else the first line of ~/.bCNC-ntfy-topic.
+    Returns "" when not configured (alerts off)."""
+    topic = os.environ.get("BCNC_NTFY_TOPIC", "").strip()
+    if not topic:
+        try:
+            with open(NTFY_TOPIC_FILE) as f:
+                topic = f.readline().strip()
+        except OSError:
+            topic = ""
+    return topic
+
+
+def notify_phone(msg, priority=None, tags=None):
+    topic = ntfy_topic()
+    if not topic:
+        if not _ntfy_warned[0]:
+            _ntfy_warned[0] = True
+            print("bCNC phone alerts are off: no ntfy topic in", NTFY_TOPIC_FILE)
+        return
+
+    def _send():
+        try:
+            headers = {"Title": "bCNC"}
+            if priority:
+                headers["Priority"] = priority  # e.g. "high", "urgent"
+            if tags:
+                headers["Tags"] = tags  # ntfy emoji shortcodes
+            req = urllib.request.Request(
+                "https://ntfy.sh/" + topic,
+                data=msg.encode("utf-8"),
+                headers=headers,
+                method="POST",
+            )
+            urllib.request.urlopen(req, timeout=10)
+        except Exception:
+            pass  # never let a failed alert interrupt the machine
+
+    threading.Thread(target=_send, daemon=True).start()
+
+
+def job_summary(sender):
+    """Returns (file name, duration string or None)."""
+    name = (
+        os.path.basename(getattr(sender.gcode, "filename", "") or "") or "unnamed job"
+    )
+    start = getattr(sender, "_job_start", None)
+    if not start:
+        return name, None
+    m, s = divmod(int(time.time() - start), 60)
+    h, m = divmod(m, 60)
+    return name, ("{}h {}m".format(h, m) if h else "{}m {}s".format(m, s))
+
+
+_last_problem = [0.0]
+
+
+def notify_problem(msg):
+    # High priority, at most one per 60 seconds, so a burst of
+    # errors does not flood the phone.
+    now = time.time()
+    if now - _last_problem[0] < 60:
+        return
+    _last_problem[0] = now
+    notify_phone(msg, priority="urgent", tags="rotating_light")
+
+
+_alarm = {"timer": None, "active": False, "last": 0.0}
+
+
+def alarm_cancel():
+    _alarm["active"] = False
+    t = _alarm["timer"]
+    _alarm["timer"] = None
+    if t is not None:
+        t.cancel()
+
+
+def _alarm_schedule(msg, count, delay):
+    t = threading.Timer(delay, _alarm_loop, (msg, count))
+    t.daemon = True
+    _alarm["timer"] = t
+    t.start()
+
+
+def _alarm_loop(msg, count):
+    if not _alarm["active"]:
+        return
+    _alarm["last"] = time.time()
+    notify_phone(msg, priority="urgent", tags="rotating_light")
+    if count < ALARM_REPEATS:
+        _alarm_schedule(msg, count + 1, ALARM_REPEAT_SECS)
+
+
+def alarm_start(msg):
+    # May be called many times while the controller stays in Alarm (or
+    # flips in and out of it); only the first call starts a series, and
+    # alerts are never closer together than ALARM_REPEAT_SECS.
+    if _alarm["active"]:
+        return
+    _alarm["active"] = True
+    wait = _alarm["last"] + ALARM_REPEAT_SECS - time.time()
+    _alarm_schedule(msg, 1, max(0.0, wait))
+
+
 __author__ = "Vasilis Vlachoudis"
 __email__ = "vvlachoudis@gmail.com"
 
@@ -650,6 +766,8 @@
         self._pause = False
         self._paths = None
         self.running = True
+        self._job_start = time.time()
+        self._job_stopped = False
         self.disable()
         self.emptyQueue()
         time.sleep(1)
@@ -662,6 +780,14 @@
             self.log.put((Sender.MSG_RUNEND, _("Run ended")))
             self.log.put((Sender.MSG_RUNEND, str(datetime.now())))
             self.log.put((Sender.MSG_RUNEND, str(CNC.vars["msg"])))
+            if not getattr(self, "_job_stopped", False):
+                name, dur = job_summary(self)
+                if dur:
+                    notify_phone(
+                        "{} finished in {}".format(name, dur), tags="white_check_mark"
+                    )
+                else:
+                    notify_phone("{} finished".format(name), tags="white_check_mark")
             if self._onStop:
                 try:
                     os.system(self._onStop)
@@ -678,6 +804,14 @@
     # Stop the current run
     # ----------------------------------------------------------------------
     def stopRun(self, event=None):
+        if self.running and not getattr(self, "_job_stopped", False):
+            self._job_stopped = True
+            name, dur = job_summary(self)
+            notify_phone(
+                "{} stopped{}".format(name, " after " + dur if dur else ""),
+                priority="high",
+                tags="stop_sign",
+            )
         self.feedHold()
         self._stop = True
         # if we are in the process of submitting do not do anything
@@ -701,6 +835,13 @@
     def controllerStateChange(self, state):
         print(
             f"Controller state changed to: {state} (Running: {self.running})")
+        if state == "Alarm":
+            if self.running:
+                alarm_start("ALARM during {}".format(job_summary(self)[0]))
+            else:
+                alarm_start("Controller ALARM")
+        else:
+            alarm_cancel()
         if state in ("Idle"):
             self.mcontrol.viewParameters()
             self.mcontrol.viewState()
@@ -774,6 +915,11 @@
                         except Exception:
                             for s in str(sys.exc_info()[1]).splitlines():
                                 self.log.put((Sender.MSG_ERROR, s))
+                            notify_problem(
+                                "G-code error in {}: {}".format(
+                                    job_summary(self)[0], str(sys.exc_info()[1])[:100]
+                                )
+                            )
                             self._gcount += 1
                             tosend = None
                 except Empty:
