1298 lines
48 KiB
EmacsLisp
1298 lines
48 KiB
EmacsLisp
;;; meshtastic.el --- Chat client for Meshtastic LoRa devices via serial -*- lexical-binding: t; -*-
|
|
|
|
;; Copyright (C) 2026 Andros Fenollosa
|
|
|
|
;; Author: Andros Fenollosa <hi@andros.dev>
|
|
;; Maintainer: Andros Fenollosa <hi@andros.dev>
|
|
;; Version: 1.1.0
|
|
;; Package-Requires: ((emacs "28.1"))
|
|
;; Keywords: comm
|
|
;; URL: https://git.andros.dev/andros/meshtastic.el
|
|
|
|
;; SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
;; This file is NOT part of GNU Emacs.
|
|
|
|
;; This program is free software; you can redistribute it and/or modify
|
|
;; it under the terms of the GNU General Public License as published by
|
|
;; the Free Software Foundation, either version 3 of the License, or
|
|
;; (at your option) any later version.
|
|
|
|
;; This program is distributed in the hope that it will be useful, but
|
|
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
;; General Public License for more details.
|
|
|
|
;; You should have received a copy of the GNU General Public License
|
|
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
;;; Commentary:
|
|
|
|
;; Emacs chat client for Meshtastic LoRa mesh networks.
|
|
;; Connects to a Meshtastic device over serial USB via a Python bridge.
|
|
;;
|
|
;; Requires the meshtastic Python package:
|
|
;; pip install meshtastic
|
|
;;
|
|
;; Configure `meshtastic-serial-port' in your init file, then:
|
|
;;
|
|
;; M-x meshtastic - welcome screen
|
|
;; M-x meshtastic-channels - list channels
|
|
;; M-x meshtastic-nodes - list nodes by hops
|
|
;;
|
|
;; Messages received since the bridge started are buffered in memory
|
|
;; and shown when a chat buffer is opened.
|
|
|
|
;;; Code:
|
|
|
|
(require 'json)
|
|
(require 'cl-lib)
|
|
(require 'notifications)
|
|
(require 'ring)
|
|
(require 'seq)
|
|
|
|
;;;; Customization
|
|
|
|
(defgroup meshtastic nil
|
|
"Chat client for Meshtastic LoRa mesh networks."
|
|
:group 'communication
|
|
:prefix "meshtastic-")
|
|
|
|
(defcustom meshtastic-serial-port "/dev/ttyUSB0"
|
|
"Serial port path for the Meshtastic device."
|
|
:type 'string
|
|
:group 'meshtastic)
|
|
|
|
(defcustom meshtastic-python-executable "python3"
|
|
"Python executable used to launch the bridge script.
|
|
See also `python-shell-interpreter'."
|
|
:type 'string
|
|
:group 'meshtastic)
|
|
|
|
(defcustom meshtastic-bridge-script
|
|
(expand-file-name
|
|
"meshtastic-bridge.py"
|
|
(file-name-directory
|
|
(or load-file-name
|
|
(locate-library "meshtastic")
|
|
"")))
|
|
"Absolute path to meshtastic-bridge.py."
|
|
:type 'file
|
|
:group 'meshtastic)
|
|
|
|
(defcustom meshtastic-notify t
|
|
"Non-nil means show desktop notifications for new messages."
|
|
:type 'boolean
|
|
:group 'meshtastic)
|
|
|
|
(defcustom meshtastic-timestamp-format "%H:%M"
|
|
"Format string for message timestamps, passed to `format-time-string'."
|
|
:type 'string
|
|
:group 'meshtastic)
|
|
|
|
(defcustom meshtastic-message-history 200
|
|
"Number of messages to keep in memory per channel or DM."
|
|
:type 'integer
|
|
:group 'meshtastic)
|
|
|
|
;;;; Faces
|
|
|
|
(defface meshtastic-timestamp-face
|
|
'((t :foreground "gray50"))
|
|
"Face for message timestamps."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-nick-self-face
|
|
'((t :foreground "sea green" :weight bold))
|
|
"Face for the local node nick in messages."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-nick-other-face
|
|
'((t :foreground "dodger blue" :weight bold))
|
|
"Face for remote node nicks in messages."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-prompt-face
|
|
'((t :foreground "cyan" :weight bold))
|
|
"Face for the input prompt."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-system-face
|
|
'((t :foreground "gray60" :slant italic))
|
|
"Face for system messages."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-delivery-pending-face
|
|
'((t :foreground "gray50"))
|
|
"Face for the pending delivery indicator."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-delivery-confirmed-face
|
|
'((t :foreground "green"))
|
|
"Face for the confirmed delivery indicator."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-delivery-failed-face
|
|
'((t :foreground "red"))
|
|
"Face for the failed delivery indicator."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-welcome-title-face
|
|
'((t :weight bold :height 1.3))
|
|
"Face for the title in the welcome buffer."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-welcome-heading-face
|
|
'((t :weight bold))
|
|
"Face for section headings in the welcome buffer."
|
|
:group 'meshtastic)
|
|
|
|
(defface meshtastic-welcome-key-face
|
|
'((t :foreground "cyan" :weight bold))
|
|
"Face for keyboard shortcuts in the welcome buffer."
|
|
:group 'meshtastic)
|
|
|
|
;;;; Internal state
|
|
|
|
(defvar meshtastic--process nil
|
|
"The bridge subprocess, or nil when stopped.")
|
|
|
|
(defvar meshtastic--connected nil
|
|
"Non-nil when the bridge has confirmed connection to the device.")
|
|
|
|
(defvar meshtastic--my-node-id nil
|
|
"Node ID string of the local device, e.g. \"!deadbeef\".")
|
|
|
|
(defvar meshtastic--my-long-name nil
|
|
"Long name of the local device as reported by the firmware.")
|
|
|
|
(defvar meshtastic--nodes (make-hash-table :test 'equal)
|
|
"Hash table mapping node ID strings to node alists.")
|
|
|
|
(defvar meshtastic--channels nil
|
|
"List of channel alists fetched from the device.")
|
|
|
|
(defvar meshtastic--chat-buffers nil
|
|
"Alist of ((TYPE . TARGET) . BUFFER) for open chat buffers.")
|
|
|
|
(defvar meshtastic--message-rings (make-hash-table :test 'equal)
|
|
"Hash table mapping (TYPE . TARGET) cons to a ring of message alists.")
|
|
|
|
(defvar meshtastic--cmd-counter 0
|
|
"Monotonic counter for generating unique command IDs.")
|
|
|
|
(defvar meshtastic--traceroute-entries (make-hash-table :test 'equal)
|
|
"Hash table mapping cmd-id to (status-start-marker . status-end-marker).")
|
|
|
|
(defvar meshtastic--telemetry (make-hash-table :test 'equal)
|
|
"Hash table mapping node ID strings to their latest telemetry alist.")
|
|
|
|
(defvar meshtastic--pending-cmds (make-hash-table :test 'equal)
|
|
"Hash table mapping cmd-id string to a delivery callback function.")
|
|
|
|
;;;; Buffer-local variables
|
|
|
|
(defvar-local meshtastic--target nil
|
|
"Target for this chat buffer: channel index (integer) or node ID (string).")
|
|
|
|
(defvar-local meshtastic--target-type nil
|
|
"Target type for this chat buffer: symbol `channel' or `dm'.")
|
|
|
|
(defvar-local meshtastic--prompt-start nil
|
|
"Marker at the start of the input prompt.")
|
|
|
|
(defvar-local meshtastic--prompt-end nil
|
|
"Marker at the end of the input prompt (start of user input).")
|
|
|
|
(defvar-local meshtastic--seen-ids nil
|
|
"Hash table of rendered message IDs used for deduplication.")
|
|
|
|
(defvar-local meshtastic--input-ring nil
|
|
"Ring of previous input strings for history navigation.")
|
|
|
|
(defvar-local meshtastic--input-ring-index 0
|
|
"Current position in `meshtastic--input-ring'.")
|
|
|
|
(defvar-local meshtastic--pending-send nil
|
|
"Alist of (CMD-ID . MARKER) for messages awaiting delivery confirmation.")
|
|
|
|
;;;; Timestamp helpers
|
|
|
|
(defun meshtastic--format-time (ts)
|
|
"Format unix timestamp TS using `meshtastic-timestamp-format'."
|
|
(condition-case nil
|
|
(format-time-string meshtastic-timestamp-format
|
|
(seconds-to-time ts))
|
|
(error "??:??")))
|
|
|
|
(defun meshtastic--format-last-heard (ts)
|
|
"Format last-heard unix timestamp TS as a relative string."
|
|
(if (and ts (numberp ts) (> ts 0))
|
|
(let* ((secs (- (float-time) ts))
|
|
(mins (/ secs 60))
|
|
(hours (/ mins 60))
|
|
(days (/ hours 24)))
|
|
(cond
|
|
((< mins 1) "now")
|
|
((< mins 60) (format "%dm" (truncate mins)))
|
|
((< hours 24) (format "%dh" (truncate hours)))
|
|
(t (format "%dd" (truncate days)))))
|
|
"?"))
|
|
|
|
(defun meshtastic--node-online-p (last-heard)
|
|
"Return non-nil if LAST-HEARD is within the last 15 minutes."
|
|
(and last-heard (numberp last-heard) (> last-heard 0)
|
|
(< (- (float-time) last-heard) 900)))
|
|
|
|
;;;; Node and channel helpers
|
|
|
|
(defun meshtastic--node-name (id)
|
|
"Return a display name for node ID, falling back to ID itself."
|
|
(let ((node (and id (gethash id meshtastic--nodes))))
|
|
(if node
|
|
(or (alist-get 'longName node)
|
|
(alist-get 'shortName node)
|
|
(format "%s" id))
|
|
(format "%s" (or id "?")))))
|
|
|
|
(defun meshtastic--channel-name (index)
|
|
"Return the name for channel INDEX, falling back to its number."
|
|
(let ((ch (seq-find (lambda (c) (equal (alist-get 'index c) index))
|
|
meshtastic--channels)))
|
|
(if ch
|
|
(let ((name (alist-get 'name ch)))
|
|
(if (and name (not (string-empty-p name)))
|
|
name
|
|
(format "%d" index)))
|
|
(format "%d" index))))
|
|
|
|
(defun meshtastic--node-count ()
|
|
"Return the number of nodes in the in-memory cache."
|
|
(hash-table-count meshtastic--nodes))
|
|
|
|
(defun meshtastic--is-self-p (msg)
|
|
"Return non-nil if MSG was sent by the local device."
|
|
(let ((from (alist-get 'from msg)))
|
|
(and meshtastic--my-node-id from
|
|
(equal from meshtastic--my-node-id))))
|
|
|
|
(defun meshtastic--broadcast-p (to-id)
|
|
"Return non-nil if TO-ID represents a broadcast address."
|
|
(or (null to-id)
|
|
(string-empty-p to-id)
|
|
(string= to-id "!ffffffff")
|
|
(string= to-id "^all")
|
|
(string= to-id "4294967295")))
|
|
|
|
;;;; Message ring buffer
|
|
|
|
(defun meshtastic--ring-push (ttype target msg)
|
|
"Append MSG to the in-memory ring for TTYPE and TARGET."
|
|
(let ((key (cons ttype target)))
|
|
(let ((ring (or (gethash key meshtastic--message-rings)
|
|
(let ((r (make-ring meshtastic-message-history)))
|
|
(puthash key r meshtastic--message-rings)
|
|
r))))
|
|
(ring-insert ring msg))))
|
|
|
|
(defun meshtastic--ring-messages (ttype target)
|
|
"Return all buffered messages for TTYPE and TARGET in chronological order."
|
|
(let* ((key (cons ttype target))
|
|
(ring (gethash key meshtastic--message-rings)))
|
|
(when ring
|
|
(let ((msgs nil))
|
|
(dotimes (i (ring-length ring))
|
|
(push (ring-ref ring i) msgs))
|
|
msgs))))
|
|
|
|
;;;; Process management
|
|
|
|
(defun meshtastic--cmd-id ()
|
|
"Return a fresh unique command ID string."
|
|
(format "cmd-%d" (cl-incf meshtastic--cmd-counter)))
|
|
|
|
(defun meshtastic--send-cmd (alist)
|
|
"Serialize ALIST as JSON and send it to the bridge stdin."
|
|
(when (and meshtastic--process
|
|
(process-live-p meshtastic--process))
|
|
(process-send-string
|
|
meshtastic--process
|
|
(concat (json-encode alist) "\n"))))
|
|
|
|
(defun meshtastic--process-filter (proc string)
|
|
"Accumulate STRING from bridge PROC and dispatch complete JSON lines."
|
|
(let ((buf (concat (or (process-get proc 'partial) "") string)))
|
|
(let ((lines (split-string buf "\n")))
|
|
(process-put proc 'partial (car (last lines)))
|
|
(dolist (line (butlast lines))
|
|
(unless (string-empty-p (string-trim line))
|
|
(meshtastic--handle-line line))))))
|
|
|
|
(defun meshtastic--process-sentinel (proc event)
|
|
"Handle bridge PROC lifecycle EVENT."
|
|
(unless (process-live-p proc)
|
|
(when (eq meshtastic--process proc)
|
|
(setq meshtastic--connected nil
|
|
meshtastic--process nil)
|
|
(message "Meshtastic: bridge stopped (%s)" (string-trim event))
|
|
(dolist (entry meshtastic--chat-buffers)
|
|
(let ((buf (cdr entry)))
|
|
(when (buffer-live-p buf)
|
|
(meshtastic--insert-msg buf nil nil
|
|
"Bridge disconnected" nil t)))))))
|
|
|
|
(defun meshtastic--handle-line (line)
|
|
"Parse a JSON LINE from the bridge and dispatch to the correct handler."
|
|
(condition-case _err
|
|
(let* ((json-object-type 'alist)
|
|
(json-array-type 'list)
|
|
(json-key-type 'symbol)
|
|
(obj (json-read-from-string line))
|
|
(type (alist-get 'type obj)))
|
|
(pcase type
|
|
("connected" (meshtastic--on-connected obj))
|
|
("message" (meshtastic--on-message obj))
|
|
("nodes" (meshtastic--on-nodes obj))
|
|
("channels" (meshtastic--on-channels obj))
|
|
("info" (meshtastic--on-info obj))
|
|
("sent" (meshtastic--on-sent obj))
|
|
("traceroute" (meshtastic--on-traceroute obj))
|
|
("telemetry" (meshtastic--on-telemetry obj))
|
|
("nodeUpdated" (meshtastic--on-node-updated obj))
|
|
("error" (message "Meshtastic bridge: %s"
|
|
(alist-get 'message obj)))))
|
|
(error (message "Meshtastic bridge stderr: %s" line))))
|
|
|
|
(defun meshtastic--start ()
|
|
"Launch the Python bridge subprocess."
|
|
(when (and meshtastic--process (process-live-p meshtastic--process))
|
|
(delete-process meshtastic--process))
|
|
(unless (file-exists-p meshtastic-bridge-script)
|
|
(user-error "Bridge script not found: %s" meshtastic-bridge-script))
|
|
(let ((default-directory (file-name-directory meshtastic-bridge-script)))
|
|
(setq meshtastic--process
|
|
(make-process
|
|
:name "meshtastic-bridge"
|
|
:command (append (split-string meshtastic-python-executable)
|
|
(list "-u"
|
|
meshtastic-bridge-script
|
|
meshtastic-serial-port))
|
|
:filter #'meshtastic--process-filter
|
|
:sentinel #'meshtastic--process-sentinel
|
|
:connection-type 'pipe
|
|
:noquery t)))
|
|
(message "Meshtastic: connecting on %s..." meshtastic-serial-port))
|
|
|
|
(defun meshtastic--ensure-connected ()
|
|
"Start the bridge if it is not already running."
|
|
(when (string-empty-p meshtastic-serial-port)
|
|
(user-error "Set `meshtastic-serial-port' in your init file"))
|
|
(unless (and meshtastic--process
|
|
(process-live-p meshtastic--process))
|
|
(meshtastic--start)))
|
|
|
|
;;;; Bridge event handlers
|
|
|
|
(defun meshtastic--on-connected (obj)
|
|
"Handle a connected event OBJ from the bridge."
|
|
(setq meshtastic--connected t
|
|
meshtastic--my-node-id (alist-get 'nodeId obj)
|
|
meshtastic--my-long-name (alist-get 'longName obj))
|
|
(meshtastic--send-cmd '((action . "channels")))
|
|
(meshtastic--send-cmd '((action . "nodes")))
|
|
(message "Meshtastic: connected as %s (%s)"
|
|
(or meshtastic--my-long-name "?")
|
|
(or meshtastic--my-node-id "?"))
|
|
(let ((buf (get-buffer "*Meshtastic*")))
|
|
(when (buffer-live-p buf)
|
|
(meshtastic--render-welcome))))
|
|
|
|
(defun meshtastic--on-message (obj)
|
|
"Handle an incoming message OBJ from the bridge."
|
|
(let* ((from (alist-get 'from obj))
|
|
(to (alist-get 'to obj))
|
|
(text (alist-get 'text obj))
|
|
(channel (or (alist-get 'channel obj) 0))
|
|
(ttype (if (meshtastic--broadcast-p to) 'channel 'dm))
|
|
(target (if (eq ttype 'channel) channel from)))
|
|
(meshtastic--ring-push ttype target obj)
|
|
(let ((buf (cdr (assoc (cons ttype target) meshtastic--chat-buffers))))
|
|
(when (buffer-live-p buf)
|
|
(meshtastic--render-messages buf (list obj)))
|
|
(unless (and (buffer-live-p buf) (get-buffer-window buf))
|
|
(meshtastic--notify (meshtastic--node-name from)
|
|
(or text "") ttype target)))))
|
|
|
|
(defun meshtastic--on-nodes (obj)
|
|
"Handle a nodes data OBJ from the bridge."
|
|
(let ((data (alist-get 'data obj)))
|
|
(when (listp data)
|
|
(clrhash meshtastic--nodes)
|
|
(dolist (node data)
|
|
(let ((id (alist-get 'nodeId node)))
|
|
(when (and id (not (string-empty-p id)))
|
|
(puthash id node meshtastic--nodes))))))
|
|
(let ((buf (get-buffer "*Meshtastic: Nodes*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshtastic--populate-node-list))))
|
|
(let ((buf (get-buffer "*Meshtastic*")))
|
|
(when (buffer-live-p buf)
|
|
(meshtastic--render-welcome))))
|
|
|
|
(defun meshtastic--on-channels (obj)
|
|
"Handle a channels data OBJ from the bridge."
|
|
(setq meshtastic--channels (alist-get 'data obj))
|
|
(let ((buf (get-buffer "*Meshtastic: Channels*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshtastic--populate-channel-list))))
|
|
(let ((buf (get-buffer "*Meshtastic*")))
|
|
(when (buffer-live-p buf)
|
|
(meshtastic--render-welcome))))
|
|
|
|
(defun meshtastic--on-info (obj)
|
|
"Handle a device info OBJ from the bridge."
|
|
(setq meshtastic--my-node-id (alist-get 'nodeId obj)
|
|
meshtastic--my-long-name (alist-get 'longName obj)))
|
|
|
|
(defun meshtastic--on-sent (obj)
|
|
"Handle a sent confirmation OBJ from the bridge."
|
|
(let* ((cmd-id (alist-get 'id obj))
|
|
(cb (and cmd-id (gethash cmd-id meshtastic--pending-cmds))))
|
|
(when cb
|
|
(remhash cmd-id meshtastic--pending-cmds)
|
|
(funcall cb 'confirmed))))
|
|
|
|
(defun meshtastic--on-traceroute (obj)
|
|
"Handle a traceroute response OBJ from the bridge."
|
|
(let ((cmd-id (alist-get 'id obj))
|
|
(dest-id (alist-get 'dest obj))
|
|
(route (alist-get 'route obj))
|
|
(route-back (alist-get 'routeBack obj))
|
|
(snr-fwd (alist-get 'snrTowards obj))
|
|
(snr-back (alist-get 'snrBack obj)))
|
|
(meshtastic--traceroute-set-result
|
|
cmd-id dest-id route route-back snr-fwd snr-back)))
|
|
|
|
(defun meshtastic--on-telemetry (obj)
|
|
"Handle a telemetry event OBJ from the bridge."
|
|
(let ((from (alist-get 'from obj)))
|
|
(when (and from (not (string-empty-p from)))
|
|
(puthash from obj meshtastic--telemetry)
|
|
(let ((buf (get-buffer "*Meshtastic: Nodes*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshtastic--populate-node-list)))))))
|
|
|
|
(defun meshtastic--on-node-updated (obj)
|
|
"Handle a nodeUpdated event OBJ from the bridge."
|
|
(let ((node-id (alist-get 'nodeId obj)))
|
|
(when (and node-id (not (string-empty-p node-id)))
|
|
(puthash node-id obj meshtastic--nodes)
|
|
(let ((buf (get-buffer "*Meshtastic: Nodes*")))
|
|
(when (buffer-live-p buf)
|
|
(with-current-buffer buf
|
|
(meshtastic--populate-node-list)))))))
|
|
|
|
;;;; Delivery icons
|
|
|
|
(defun meshtastic--delivery-icon (state)
|
|
"Return a propertized delivery icon string for STATE."
|
|
(let ((icon (pcase state ('confirmed "✓") ('failed "✗") (_ "·")))
|
|
(face (pcase state
|
|
('confirmed 'meshtastic-delivery-confirmed-face)
|
|
('failed 'meshtastic-delivery-failed-face)
|
|
(_ 'meshtastic-delivery-pending-face))))
|
|
(propertize icon 'face face 'read-only t 'rear-nonsticky t)))
|
|
|
|
(defun meshtastic--update-delivery (cmd-id state buffer)
|
|
"Replace the delivery icon for CMD-ID with STATE in BUFFER."
|
|
(when (buffer-live-p buffer)
|
|
(with-current-buffer buffer
|
|
(let ((entry (assoc cmd-id meshtastic--pending-send)))
|
|
(when entry
|
|
(let ((marker (cdr entry))
|
|
(inhibit-read-only t))
|
|
(when (and marker (marker-buffer marker))
|
|
(save-excursion
|
|
(goto-char marker)
|
|
(delete-char 1)
|
|
(insert (meshtastic--delivery-icon state)))))
|
|
(setq meshtastic--pending-send
|
|
(delq entry meshtastic--pending-send)))))))
|
|
|
|
;;;; API commands
|
|
|
|
(defun meshtastic--api-send (text channel dest callback)
|
|
"Send TEXT to CHANNEL index (integer) or DEST node ID (string).
|
|
Call CALLBACK with symbol `confirmed' or `failed' when the bridge responds."
|
|
(let* ((cmd-id (meshtastic--cmd-id))
|
|
(alist (list (cons 'action "send")
|
|
(cons 'text text)
|
|
(cons 'id cmd-id))))
|
|
(when (and channel (not dest))
|
|
(push (cons 'channel channel) alist))
|
|
(when dest
|
|
(push (cons 'dest dest) alist))
|
|
(when callback
|
|
(puthash cmd-id callback meshtastic--pending-cmds))
|
|
(meshtastic--send-cmd alist)
|
|
cmd-id))
|
|
|
|
(defun meshtastic--api-nodes ()
|
|
"Request an updated node list from the bridge."
|
|
(meshtastic--send-cmd '((action . "nodes"))))
|
|
|
|
(defun meshtastic--api-channels ()
|
|
"Request an updated channel list from the bridge."
|
|
(meshtastic--send-cmd '((action . "channels"))))
|
|
|
|
;;;; Chat mode
|
|
|
|
(defvar meshtastic-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(define-key map (kbd "RET") #'meshtastic-send-input)
|
|
(define-key map (kbd "M-p") #'meshtastic-previous-input)
|
|
(define-key map (kbd "M-n") #'meshtastic-next-input)
|
|
(define-key map (kbd "C-c C-l") #'meshtastic-refresh-chat)
|
|
map)
|
|
"Keymap for `meshtastic-mode'.")
|
|
|
|
(define-derived-mode meshtastic-mode fundamental-mode "MeshChat"
|
|
"Major mode for Meshtastic chat buffers.
|
|
Provides an input prompt with message history above."
|
|
:group 'meshtastic
|
|
(setq-local meshtastic--prompt-start (make-marker))
|
|
(setq-local meshtastic--prompt-end (make-marker))
|
|
(setq-local meshtastic--input-ring (make-ring 64))
|
|
(setq-local meshtastic--input-ring-index 0)
|
|
(setq-local meshtastic--seen-ids (make-hash-table :test 'equal))
|
|
(setq-local meshtastic--pending-send nil)
|
|
(setq mode-line-process
|
|
'(" " (:eval (if meshtastic--connected "[on]" "[off]")))))
|
|
|
|
;;;; Message rendering
|
|
|
|
(defun meshtastic--insert-at-prompt (buffer fn)
|
|
"In BUFFER, call FN at the prompt insertion point.
|
|
Preserves cursor position when the user is at the end of the buffer."
|
|
(when (buffer-live-p buffer)
|
|
(with-current-buffer buffer
|
|
(let ((inhibit-read-only t)
|
|
(at-end (>= (point) meshtastic--prompt-end)))
|
|
(save-excursion
|
|
(set-marker-insertion-type meshtastic--prompt-start t)
|
|
(set-marker-insertion-type meshtastic--prompt-end t)
|
|
(goto-char meshtastic--prompt-start)
|
|
(funcall fn)
|
|
(set-marker-insertion-type meshtastic--prompt-start nil)
|
|
(set-marker-insertion-type meshtastic--prompt-end nil))
|
|
(when at-end
|
|
(goto-char meshtastic--prompt-end))))))
|
|
|
|
(defun meshtastic--insert-msg (buffer ts sender text
|
|
&optional selfp sysp)
|
|
"Insert a chat line into BUFFER.
|
|
TS is a unix timestamp, SENDER the display name, TEXT the message body.
|
|
SELFP non-nil marks the message as from the local node.
|
|
SYSP non-nil renders a system notification line instead."
|
|
(meshtastic--insert-at-prompt
|
|
buffer
|
|
(lambda ()
|
|
(insert
|
|
(propertize
|
|
(if sysp
|
|
(format "*** %s\n" text)
|
|
(concat
|
|
(propertize (format "[%s] "
|
|
(if ts (meshtastic--format-time ts) "--:--"))
|
|
'face 'meshtastic-timestamp-face)
|
|
(propertize (format "<%s> " sender)
|
|
'face (if selfp
|
|
'meshtastic-nick-self-face
|
|
'meshtastic-nick-other-face))
|
|
text "\n"))
|
|
'read-only t
|
|
'rear-nonsticky t
|
|
'front-sticky t
|
|
'face (when sysp 'meshtastic-system-face)
|
|
'meshtastic-msg-text text
|
|
'meshtastic-sender sender)))))
|
|
|
|
(defun meshtastic--insert-sent-msg (buffer ts sender text cmd-id)
|
|
"Insert an outgoing message with delivery tracking into BUFFER.
|
|
TS is the timestamp, SENDER the display name, TEXT the content.
|
|
CMD-ID is used to update the delivery icon when the bridge confirms."
|
|
(meshtastic--insert-at-prompt
|
|
buffer
|
|
(lambda ()
|
|
(insert
|
|
(propertize
|
|
(concat
|
|
(propertize (format "[%s] "
|
|
(if ts (meshtastic--format-time ts) "--:--"))
|
|
'face 'meshtastic-timestamp-face)
|
|
(propertize (format "<%s> " sender)
|
|
'face 'meshtastic-nick-self-face)
|
|
text " ")
|
|
'read-only t 'rear-nonsticky t 'front-sticky t
|
|
'meshtastic-msg-text text))
|
|
(let ((icon-pos (point)))
|
|
(insert (meshtastic--delivery-icon 'pending))
|
|
(insert (propertize "\n" 'read-only t 'rear-nonsticky t))
|
|
(when cmd-id
|
|
(push (cons cmd-id (copy-marker icon-pos))
|
|
meshtastic--pending-send))))))
|
|
|
|
(defun meshtastic--render-messages (buffer messages)
|
|
"Render MESSAGES into BUFFER, skipping already-seen message IDs."
|
|
(when (and (buffer-live-p buffer) messages)
|
|
(let ((sorted (sort (copy-sequence messages)
|
|
(lambda (a b)
|
|
(< (or (alist-get 'rxTime a) 0)
|
|
(or (alist-get 'rxTime b) 0))))))
|
|
(dolist (msg sorted)
|
|
(let ((id (alist-get 'id msg)))
|
|
(unless (and id
|
|
(with-current-buffer buffer
|
|
(gethash id meshtastic--seen-ids)))
|
|
(let* ((from (alist-get 'from msg))
|
|
(sender (meshtastic--node-name from))
|
|
(text (or (alist-get 'text msg) ""))
|
|
(ts (alist-get 'rxTime msg))
|
|
(selfp (meshtastic--is-self-p msg)))
|
|
(meshtastic--insert-msg buffer ts sender text selfp)
|
|
(unless (or selfp (get-buffer-window buffer))
|
|
(with-current-buffer buffer
|
|
(meshtastic--notify sender text
|
|
meshtastic--target-type
|
|
meshtastic--target))))
|
|
(when id
|
|
(with-current-buffer buffer
|
|
(puthash id t meshtastic--seen-ids)))))))))
|
|
|
|
;;;; Prompt
|
|
|
|
(defun meshtastic--prompt-string ()
|
|
"Return the prompt string for the current chat buffer."
|
|
(pcase meshtastic--target-type
|
|
('channel (format "#%s> " (meshtastic--channel-name meshtastic--target)))
|
|
('dm (format "%s> " (meshtastic--node-name meshtastic--target)))
|
|
(_ "Meshtastic> ")))
|
|
|
|
(defun meshtastic--setup-prompt ()
|
|
"Insert the read-only input prompt at the end of the current buffer."
|
|
(goto-char (point-max))
|
|
(let ((start (point))
|
|
(txt (meshtastic--prompt-string)))
|
|
(insert (propertize txt
|
|
'face 'meshtastic-prompt-face
|
|
'read-only t
|
|
'front-sticky t
|
|
'rear-nonsticky t))
|
|
(set-marker meshtastic--prompt-start start)
|
|
(set-marker meshtastic--prompt-end (point))))
|
|
|
|
;;;; Input handling
|
|
|
|
(defun meshtastic-send-input ()
|
|
"Send the text typed after the prompt."
|
|
(interactive)
|
|
(if (< (point) meshtastic--prompt-end)
|
|
(goto-char (point-max))
|
|
(let ((input (buffer-substring-no-properties
|
|
meshtastic--prompt-end (point-max))))
|
|
(when (string-blank-p input)
|
|
(user-error "Empty message"))
|
|
(ring-insert meshtastic--input-ring input)
|
|
(setq meshtastic--input-ring-index 0)
|
|
(let ((inhibit-read-only t))
|
|
(delete-region meshtastic--prompt-end (point-max)))
|
|
(meshtastic--send-text input))))
|
|
|
|
(defun meshtastic--send-text (text)
|
|
"Send TEXT to the target of the current chat buffer."
|
|
(unless meshtastic--connected
|
|
(user-error "Meshtastic is not connected"))
|
|
(let* ((buf (current-buffer))
|
|
(target meshtastic--target)
|
|
(ttype meshtastic--target-type)
|
|
(sender (or meshtastic--my-long-name
|
|
meshtastic--my-node-id
|
|
"me"))
|
|
(cmd-id (meshtastic--cmd-id)))
|
|
(meshtastic--insert-sent-msg buf (float-time) sender text cmd-id)
|
|
(pcase ttype
|
|
('channel
|
|
(meshtastic--api-send
|
|
text target nil
|
|
(lambda (state)
|
|
(meshtastic--update-delivery cmd-id state buf))))
|
|
('dm
|
|
(meshtastic--api-send
|
|
text nil target
|
|
(lambda (state)
|
|
(meshtastic--update-delivery cmd-id state buf))))
|
|
(_ (user-error "No target set for this buffer")))))
|
|
|
|
(defun meshtastic--navigate-input (dir)
|
|
"Move DIR step through the input ring (1 = older, -1 = newer)."
|
|
(when (> (ring-length meshtastic--input-ring) 0)
|
|
(let ((inhibit-read-only t))
|
|
(delete-region meshtastic--prompt-end (point-max))
|
|
(when (= dir -1)
|
|
(setq meshtastic--input-ring-index
|
|
(mod (1- meshtastic--input-ring-index)
|
|
(ring-length meshtastic--input-ring))))
|
|
(insert (ring-ref meshtastic--input-ring
|
|
meshtastic--input-ring-index))
|
|
(when (= dir 1)
|
|
(setq meshtastic--input-ring-index
|
|
(mod (1+ meshtastic--input-ring-index)
|
|
(ring-length meshtastic--input-ring)))))))
|
|
|
|
(defun meshtastic-previous-input ()
|
|
"Replace the current input with the previous history entry."
|
|
(interactive)
|
|
(meshtastic--navigate-input 1))
|
|
|
|
(defun meshtastic-next-input ()
|
|
"Replace the current input with the next history entry."
|
|
(interactive)
|
|
(meshtastic--navigate-input -1))
|
|
|
|
;;;; Buffer management
|
|
|
|
(defun meshtastic--buffer-name (ttype target)
|
|
"Return the buffer name for chat type TTYPE and TARGET."
|
|
(pcase ttype
|
|
('channel (format "*Meshtastic: #%s*" (meshtastic--channel-name target)))
|
|
('dm (format "*Meshtastic: DM %s*" (meshtastic--node-name target)))))
|
|
|
|
(defun meshtastic--get-or-create-buffer (ttype target)
|
|
"Return an existing or new chat buffer for TTYPE and TARGET."
|
|
(let* ((name (meshtastic--buffer-name ttype target))
|
|
(buf (get-buffer name)))
|
|
(if (and buf (buffer-live-p buf))
|
|
buf
|
|
(setq buf (get-buffer-create name))
|
|
(with-current-buffer buf
|
|
(meshtastic-mode)
|
|
(setq meshtastic--target target
|
|
meshtastic--target-type ttype)
|
|
(meshtastic--setup-prompt))
|
|
(push (cons (cons ttype target) buf) meshtastic--chat-buffers)
|
|
buf)))
|
|
|
|
(defun meshtastic-refresh-chat ()
|
|
"Re-render buffered messages in the current chat buffer."
|
|
(interactive)
|
|
(unless meshtastic--target
|
|
(user-error "Not a chat buffer"))
|
|
(let ((msgs (meshtastic--ring-messages meshtastic--target-type
|
|
meshtastic--target)))
|
|
(when msgs
|
|
(meshtastic--render-messages (current-buffer) msgs)))
|
|
(message "Meshtastic: refreshed"))
|
|
|
|
;;;; Notifications
|
|
|
|
(defun meshtastic--notify (sender text target-type target)
|
|
"Show a desktop notification for a message from SENDER.
|
|
TEXT is the message body; TARGET-TYPE and TARGET identify the conversation."
|
|
(when meshtastic-notify
|
|
(let ((title (pcase target-type
|
|
('channel (format "#%s" (meshtastic--channel-name target)))
|
|
('dm sender)
|
|
(_ "Meshtastic")))
|
|
(body (truncate-string-to-width
|
|
(format "%s: %s" sender text) 100 nil nil "...")))
|
|
(condition-case nil
|
|
(notifications-notify
|
|
:title title
|
|
:body body
|
|
:app-name "Meshtastic"
|
|
:category "im.received"
|
|
:urgency 'normal)
|
|
(error nil)))))
|
|
|
|
;;;; Channel list mode
|
|
|
|
(defvar meshtastic-channel-list-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(set-keymap-parent map tabulated-list-mode-map)
|
|
(define-key map (kbd "RET") #'meshtastic-channel-list-open)
|
|
(define-key map (kbd "g") #'meshtastic-channel-list-refresh)
|
|
(dotimes (i 8)
|
|
(define-key map (kbd (number-to-string i))
|
|
#'meshtastic-channel-list-open-by-number))
|
|
map)
|
|
"Keymap for `meshtastic-channel-list-mode'.")
|
|
|
|
(define-derived-mode meshtastic-channel-list-mode tabulated-list-mode
|
|
"MeshChannels"
|
|
"Major mode for listing Meshtastic channels."
|
|
:group 'meshtastic
|
|
(setq tabulated-list-format
|
|
[("ID" 4 t) ("Name" 20 t) ("Role" 12 t)])
|
|
(setq tabulated-list-padding 2)
|
|
(tabulated-list-init-header))
|
|
|
|
(defun meshtastic-channel-list-open-by-number ()
|
|
"Open the channel whose number matches the digit key pressed."
|
|
(interactive)
|
|
(meshtastic-open-channel (- last-command-event ?0)))
|
|
|
|
(defun meshtastic-channel-list-open ()
|
|
"Open a chat buffer for the channel at point."
|
|
(interactive)
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when entry
|
|
(meshtastic-open-channel (string-to-number (aref entry 0))))))
|
|
|
|
(defun meshtastic-channel-list-refresh ()
|
|
"Request an updated channel list from the device."
|
|
(interactive)
|
|
(meshtastic--api-channels)
|
|
(message "Meshtastic: refreshing channels..."))
|
|
|
|
(defun meshtastic--populate-channel-list ()
|
|
"Fill the channel list buffer from the in-memory channel cache."
|
|
(let ((role-names '((0 . "Disabled") (1 . "Primary") (2 . "Secondary"))))
|
|
(setq tabulated-list-entries
|
|
(mapcar
|
|
(lambda (ch)
|
|
(let ((idx (or (alist-get 'index ch) 0))
|
|
(name (or (alist-get 'name ch) ""))
|
|
(role (or (alist-get 'role ch) 0)))
|
|
(list idx
|
|
(vector (number-to-string idx)
|
|
name
|
|
(or (cdr (assq role role-names))
|
|
(format "%d" role))))))
|
|
(seq-filter (lambda (ch) (> (or (alist-get 'role ch) 0) 0))
|
|
meshtastic--channels)))
|
|
(tabulated-list-print t)))
|
|
|
|
;;;; Node list mode
|
|
|
|
(defvar meshtastic-node-list-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(set-keymap-parent map tabulated-list-mode-map)
|
|
(define-key map (kbd "RET") #'meshtastic-node-list-open)
|
|
(define-key map (kbd "g") #'meshtastic-node-list-refresh)
|
|
(define-key map (kbd "t") #'meshtastic-traceroute)
|
|
map)
|
|
"Keymap for `meshtastic-node-list-mode'.")
|
|
|
|
(define-derived-mode meshtastic-node-list-mode tabulated-list-mode
|
|
"MeshNodes"
|
|
"Major mode for listing Meshtastic mesh nodes sorted by hop count."
|
|
:group 'meshtastic
|
|
(setq tabulated-list-format
|
|
[("Hops" 5 meshtastic--sort-by-hops)
|
|
("Name" 30 t)
|
|
("Node ID" 14 t)
|
|
("Last heard" 12 t)
|
|
("Battery" 8 t)])
|
|
(setq tabulated-list-padding 2)
|
|
(setq tabulated-list-sort-key '("Hops"))
|
|
(tabulated-list-init-header))
|
|
|
|
(defun meshtastic--sort-by-hops (a b)
|
|
"Sort tabulated entries A and B by hop count ascending."
|
|
(< (string-to-number (aref (cadr a) 0))
|
|
(string-to-number (aref (cadr b) 0))))
|
|
|
|
(defun meshtastic-node-list-open ()
|
|
"Open a DM chat buffer with the node at point."
|
|
(interactive)
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when entry
|
|
(meshtastic-open-dm (aref entry 2)))))
|
|
|
|
(defun meshtastic-node-list-refresh ()
|
|
"Request an updated node list from the device."
|
|
(interactive)
|
|
(meshtastic--api-nodes)
|
|
(message "Meshtastic: refreshing nodes..."))
|
|
|
|
(defun meshtastic--populate-node-list ()
|
|
"Fill the node list buffer from the in-memory node cache."
|
|
(let ((entries nil))
|
|
(maphash
|
|
(lambda (_key node)
|
|
(let* ((node-id (alist-get 'nodeId node))
|
|
(hops (or (alist-get 'hopsAway node) 0))
|
|
(name (or (alist-get 'longName node)
|
|
(alist-get 'shortName node) ""))
|
|
(last-heard (alist-get 'lastHeard node))
|
|
(indicator (if (meshtastic--node-online-p last-heard)
|
|
"🟢" "⚫")))
|
|
(let* ((tel (gethash node-id meshtastic--telemetry))
|
|
(battery (and tel (alist-get 'batteryLevel tel)))
|
|
(bat-str (if (and battery (numberp battery))
|
|
(format "%d%%" battery)
|
|
"-")))
|
|
(push (list node-id
|
|
(vector (number-to-string hops)
|
|
(format "%s %s" indicator name)
|
|
(or node-id "")
|
|
(meshtastic--format-last-heard last-heard)
|
|
bat-str))
|
|
entries))))
|
|
meshtastic--nodes)
|
|
(setq tabulated-list-entries entries)
|
|
(tabulated-list-print t)))
|
|
|
|
;;;; Node actions
|
|
|
|
(defun meshtastic--node-id-at-point ()
|
|
"Return the node ID string from the tabulated-list entry at point."
|
|
(let ((entry (tabulated-list-get-entry)))
|
|
(when (and entry (> (length entry) 2))
|
|
(aref entry 2))))
|
|
|
|
(defun meshtastic--node-id-context ()
|
|
"Return a contextual node ID from the current buffer.
|
|
Tries the tabulated-list entry at point first, then the DM target."
|
|
(or (meshtastic--node-id-at-point)
|
|
(and (eq meshtastic--target-type 'dm) meshtastic--target)))
|
|
|
|
;;;###autoload
|
|
(defun meshtastic-traceroute (node-id)
|
|
"Send a traceroute to NODE-ID via the bridge.
|
|
When called from the node list, uses the node at point.
|
|
When called from a DM buffer, uses the DM target.
|
|
Otherwise prompts for a node ID."
|
|
(interactive
|
|
(list (or (meshtastic--node-id-context)
|
|
(read-string "Node ID: "))))
|
|
(meshtastic--send-traceroute node-id))
|
|
|
|
(defun meshtastic--send-traceroute (node-id)
|
|
"Send a traceroute request to NODE-ID and show the traceroute buffer."
|
|
(unless meshtastic--connected
|
|
(user-error "Not connected"))
|
|
(let ((cmd-id (meshtastic--cmd-id)))
|
|
(meshtastic--traceroute-append cmd-id node-id)
|
|
(meshtastic--send-cmd
|
|
`((action . "traceroute")
|
|
(id . ,cmd-id)
|
|
(dest . ,node-id)
|
|
(hopLimit . 7)))
|
|
(display-buffer (meshtastic--traceroute-buffer))
|
|
(message "Meshtastic: traceroute to %s..."
|
|
(meshtastic--node-name node-id))))
|
|
|
|
;;;; Traceroute buffer
|
|
|
|
(defvar meshtastic-traceroute-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(define-key map (kbd "q") #'quit-window)
|
|
map)
|
|
"Keymap for `meshtastic-traceroute-mode'.")
|
|
|
|
(define-derived-mode meshtastic-traceroute-mode special-mode "MeshTrace"
|
|
"Major mode for the Meshtastic traceroute log."
|
|
:group 'meshtastic
|
|
(buffer-disable-undo)
|
|
(setq truncate-lines t))
|
|
|
|
(defun meshtastic--traceroute-buffer ()
|
|
"Return the traceroute log buffer, creating it if needed."
|
|
(let ((buf (get-buffer-create "*Meshtastic: Traceroute*")))
|
|
(with-current-buffer buf
|
|
(unless (derived-mode-p 'meshtastic-traceroute-mode)
|
|
(meshtastic-traceroute-mode)))
|
|
buf))
|
|
|
|
(defun meshtastic--format-route-segment (nodes snrs)
|
|
"Return \" → node (snr)\" string for each element in NODES paired with SNRS."
|
|
(let ((result ""))
|
|
(cl-mapcar
|
|
(lambda (node snr)
|
|
(setq result
|
|
(concat result
|
|
(format " → %s%s"
|
|
(meshtastic--node-name node)
|
|
(if snr (format " (%.1fdB)" snr) "")))))
|
|
nodes
|
|
(append snrs (make-list (max 0 (- (length nodes) (length snrs))) nil)))
|
|
result))
|
|
|
|
(defun meshtastic--traceroute-format-result (dest-id route route-back snr-fwd snr-back)
|
|
"Return formatted multi-line status for a completed traceroute to DEST-ID.
|
|
ROUTE and ROUTE-BACK are lists of intermediate node ID strings.
|
|
SNR-FWD and SNR-BACK are lists of SNR floats (dB) for each hop."
|
|
(let* ((local (or meshtastic--my-long-name meshtastic--my-node-id "Local"))
|
|
(dest (meshtastic--node-name dest-id))
|
|
(fwd (concat local
|
|
(meshtastic--format-route-segment route snr-fwd)
|
|
" → " dest))
|
|
(bck (concat dest
|
|
(meshtastic--format-route-segment route-back snr-back)
|
|
" → " local)))
|
|
(concat
|
|
(propertize (format " ✓ → %s\n" fwd) 'face 'meshtastic-delivery-confirmed-face)
|
|
(propertize (format " ← %s\n" bck) 'face 'meshtastic-system-face))))
|
|
|
|
(defun meshtastic--traceroute-append (cmd-id dest-id)
|
|
"Append a pending entry for CMD-ID targeting DEST-ID to the traceroute buffer."
|
|
(let ((buf (meshtastic--traceroute-buffer)))
|
|
(with-current-buffer buf
|
|
(let ((inhibit-read-only t))
|
|
(goto-char (point-max))
|
|
(insert (format "[%s] → %s (%s)\n"
|
|
(format-time-string "%H:%M:%S")
|
|
(meshtastic--node-name dest-id)
|
|
dest-id))
|
|
(let ((s-start (copy-marker (point))))
|
|
(insert " ⧖ Pending...\n")
|
|
(let ((s-end (copy-marker (point))))
|
|
(insert "\n")
|
|
(puthash cmd-id (cons s-start s-end)
|
|
meshtastic--traceroute-entries)))))))
|
|
|
|
(defun meshtastic--traceroute-set-result (cmd-id dest-id route route-back snr-fwd snr-back)
|
|
"Replace the pending status for CMD-ID with route result data.
|
|
DEST-ID is the destination node ID. ROUTE and ROUTE-BACK are lists of
|
|
intermediate node ID strings. SNR-FWD and SNR-BACK are SNR floats in dB."
|
|
(let* ((entry (gethash cmd-id meshtastic--traceroute-entries))
|
|
(buf (get-buffer "*Meshtastic: Traceroute*")))
|
|
(when (and entry (buffer-live-p buf))
|
|
(let ((s-start (car entry))
|
|
(s-end (cdr entry)))
|
|
(when (and (marker-buffer s-start) (marker-buffer s-end))
|
|
(with-current-buffer buf
|
|
(let ((inhibit-read-only t))
|
|
(delete-region s-start s-end)
|
|
(save-excursion
|
|
(goto-char s-start)
|
|
(insert (meshtastic--traceroute-format-result
|
|
dest-id route route-back snr-fwd snr-back)))))
|
|
(remhash cmd-id meshtastic--traceroute-entries))))))
|
|
|
|
;;;; Position
|
|
|
|
(defun meshtastic--calendar-to-float (val)
|
|
"Convert calendar coordinate VAL to a float.
|
|
Handles both plain numbers and (degrees minutes direction) lists."
|
|
(cond
|
|
((numberp val) (float val))
|
|
((and (listp val) (>= (length val) 2))
|
|
(let* ((deg (float (nth 0 val)))
|
|
(min (float (nth 1 val)))
|
|
(dir (nth 2 val))
|
|
(abs (+ deg (/ min 60.0))))
|
|
(if (memq dir '(south west)) (- abs) abs)))
|
|
(t nil)))
|
|
|
|
(defun meshtastic--get-emacs-position ()
|
|
"Return (lat lon) from Emacs calendar variables, or nil if not set."
|
|
(when (and (boundp 'calendar-latitude)
|
|
(boundp 'calendar-longitude)
|
|
calendar-latitude
|
|
calendar-longitude)
|
|
(let ((lat (meshtastic--calendar-to-float calendar-latitude))
|
|
(lon (meshtastic--calendar-to-float calendar-longitude)))
|
|
(when (and lat lon)
|
|
(list lat lon)))))
|
|
|
|
;;;###autoload
|
|
(defun meshtastic-send-position (node-id)
|
|
"Send GPS position to NODE-ID.
|
|
Uses `calendar-latitude' and `calendar-longitude' if set, otherwise
|
|
falls back to the device GPS. Signals an error if neither is available.
|
|
When called from the node list, uses the node at point.
|
|
When called from a DM buffer, uses the DM target.
|
|
See also `calendar-latitude', `calendar-longitude'."
|
|
(interactive
|
|
(list (or (meshtastic--node-id-context)
|
|
(read-string "Node ID: "))))
|
|
(unless meshtastic--connected
|
|
(user-error "Not connected"))
|
|
(let* ((pos (meshtastic--get-emacs-position))
|
|
(cmd-id (meshtastic--cmd-id))
|
|
(alist `((action . "sendposition")
|
|
(id . ,cmd-id)
|
|
(dest . ,node-id))))
|
|
(when pos
|
|
(push `(lon . ,(cadr pos)) alist)
|
|
(push `(lat . ,(car pos)) alist))
|
|
(meshtastic--send-cmd alist)
|
|
(message "Meshtastic: sending position to %s..."
|
|
(meshtastic--node-name node-id))))
|
|
|
|
;;;; Welcome buffer
|
|
|
|
(defvar meshtastic-welcome-mode-map
|
|
(let ((map (make-sparse-keymap)))
|
|
(define-key map (kbd "c") #'meshtastic-channels)
|
|
(define-key map (kbd "n") #'meshtastic-nodes)
|
|
(define-key map (kbd "g") #'meshtastic-refresh)
|
|
(define-key map (kbd "q") #'quit-window)
|
|
map)
|
|
"Keymap for `meshtastic-welcome-mode'.")
|
|
|
|
(define-derived-mode meshtastic-welcome-mode special-mode "Meshtastic"
|
|
"Major mode for the Meshtastic welcome screen."
|
|
:group 'meshtastic
|
|
(buffer-disable-undo)
|
|
(setq truncate-lines t))
|
|
|
|
(defun meshtastic--insert-shortcut (key label)
|
|
"Insert a formatted shortcut KEY with LABEL."
|
|
(insert " ")
|
|
(insert (propertize (format "[%s]" key)
|
|
'face 'meshtastic-welcome-key-face))
|
|
(insert (format " %-17s" label)))
|
|
|
|
(defun meshtastic--render-welcome ()
|
|
"Render the welcome buffer and return it."
|
|
(let ((buf (get-buffer-create "*Meshtastic*")))
|
|
(with-current-buffer buf
|
|
(unless (derived-mode-p 'meshtastic-welcome-mode)
|
|
(meshtastic-welcome-mode))
|
|
(let ((inhibit-read-only t)
|
|
(sep (propertize (format " %s\n" (make-string 38 ?-))
|
|
'face 'meshtastic-timestamp-face)))
|
|
(erase-buffer)
|
|
(insert "\n")
|
|
(insert (propertize " Meshtastic\n"
|
|
'face 'meshtastic-welcome-title-face))
|
|
(insert (propertize (format " %s\n" (make-string 38 ?=))
|
|
'face 'meshtastic-timestamp-face))
|
|
(insert "\n")
|
|
(insert (propertize " Connection\n"
|
|
'face 'meshtastic-welcome-heading-face))
|
|
(insert (format " Port: %s\n" meshtastic-serial-port))
|
|
(insert (format " Status: %s\n"
|
|
(propertize
|
|
(cond
|
|
(meshtastic--connected "Connected")
|
|
((and meshtastic--process
|
|
(process-live-p meshtastic--process))
|
|
"Connecting...")
|
|
(t "Disconnected"))
|
|
'face (cond
|
|
(meshtastic--connected
|
|
'meshtastic-delivery-confirmed-face)
|
|
((and meshtastic--process
|
|
(process-live-p meshtastic--process))
|
|
'meshtastic-delivery-pending-face)
|
|
(t
|
|
'meshtastic-delivery-failed-face)))))
|
|
(when meshtastic--my-long-name
|
|
(insert (format " Node: %s (%s)\n"
|
|
meshtastic--my-long-name
|
|
meshtastic--my-node-id)))
|
|
(insert "\n")
|
|
(when (> (meshtastic--node-count) 0)
|
|
(insert (propertize " Statistics\n"
|
|
'face 'meshtastic-welcome-heading-face))
|
|
(insert (format " Nodes: %d\n" (meshtastic--node-count)))
|
|
(when meshtastic--channels
|
|
(insert (format " Channels: %d\n"
|
|
(length
|
|
(seq-filter
|
|
(lambda (c) (> (or (alist-get 'role c) 0) 0))
|
|
meshtastic--channels)))))
|
|
(insert "\n"))
|
|
(insert sep)
|
|
(insert "\n")
|
|
(meshtastic--insert-shortcut "c" "Channels")
|
|
(meshtastic--insert-shortcut "n" "Nodes")
|
|
(insert "\n")
|
|
(meshtastic--insert-shortcut "g" "Refresh")
|
|
(meshtastic--insert-shortcut "q" "Quit")
|
|
(insert "\n\n")
|
|
(insert sep)
|
|
(goto-char (point-min))))
|
|
buf))
|
|
|
|
;;;; Open chat buffers
|
|
|
|
(defun meshtastic-open-channel (channel-id)
|
|
"Open a chat buffer for CHANNEL-ID and load buffered messages."
|
|
(meshtastic--ensure-connected)
|
|
(let ((buf (meshtastic--get-or-create-buffer 'channel channel-id)))
|
|
(switch-to-buffer buf)
|
|
(let ((msgs (meshtastic--ring-messages 'channel channel-id)))
|
|
(when msgs
|
|
(meshtastic--render-messages buf msgs)))))
|
|
|
|
(defun meshtastic-open-dm (node-id)
|
|
"Open a DM chat buffer with NODE-ID and load buffered messages."
|
|
(meshtastic--ensure-connected)
|
|
(let ((buf (meshtastic--get-or-create-buffer 'dm node-id)))
|
|
(switch-to-buffer buf)
|
|
(let ((msgs (meshtastic--ring-messages 'dm node-id)))
|
|
(when msgs
|
|
(meshtastic--render-messages buf msgs)))))
|
|
|
|
;;;; Entry points
|
|
|
|
;;;###autoload
|
|
(defun meshtastic-channels ()
|
|
"Show the Meshtastic channel list buffer."
|
|
(interactive)
|
|
(meshtastic--ensure-connected)
|
|
(let ((buf (get-buffer-create "*Meshtastic: Channels*")))
|
|
(with-current-buffer buf
|
|
(unless (derived-mode-p 'meshtastic-channel-list-mode)
|
|
(meshtastic-channel-list-mode))
|
|
(meshtastic--populate-channel-list))
|
|
(switch-to-buffer buf)))
|
|
|
|
;;;###autoload
|
|
(defun meshtastic-nodes ()
|
|
"Show the Meshtastic node list buffer."
|
|
(interactive)
|
|
(meshtastic--ensure-connected)
|
|
(meshtastic--api-nodes)
|
|
(let ((buf (get-buffer-create "*Meshtastic: Nodes*")))
|
|
(with-current-buffer buf
|
|
(unless (derived-mode-p 'meshtastic-node-list-mode)
|
|
(meshtastic-node-list-mode))
|
|
(meshtastic--populate-node-list))
|
|
(switch-to-buffer buf)))
|
|
|
|
;;;###autoload
|
|
(defun meshtastic ()
|
|
"Open the Meshtastic welcome screen and start the bridge if needed."
|
|
(interactive)
|
|
(meshtastic--ensure-connected)
|
|
(switch-to-buffer (meshtastic--render-welcome)))
|
|
|
|
;;;###autoload
|
|
(defun meshtastic-disconnect ()
|
|
"Stop the bridge subprocess and reset all state."
|
|
(interactive)
|
|
(when (and meshtastic--process (process-live-p meshtastic--process))
|
|
(meshtastic--send-cmd '((action . "quit")))
|
|
(sit-for 0.5)
|
|
(when (process-live-p meshtastic--process)
|
|
(delete-process meshtastic--process)))
|
|
(setq meshtastic--process nil
|
|
meshtastic--connected nil
|
|
meshtastic--my-node-id nil
|
|
meshtastic--my-long-name nil
|
|
meshtastic--channels nil
|
|
meshtastic--chat-buffers nil)
|
|
(clrhash meshtastic--nodes)
|
|
(clrhash meshtastic--message-rings)
|
|
(clrhash meshtastic--pending-cmds)
|
|
(clrhash meshtastic--traceroute-entries)
|
|
(clrhash meshtastic--telemetry)
|
|
(message "Meshtastic: disconnected"))
|
|
|
|
(defun meshtastic-refresh ()
|
|
"Refresh the welcome buffer with current state."
|
|
(interactive)
|
|
(meshtastic--render-welcome))
|
|
|
|
(provide 'meshtastic)
|
|
;;; meshtastic.el ends here
|