From 1884a8decf2273d5c1c554c3271f6e07c5be52dd Mon Sep 17 00:00:00 2001
From: Teemu Ikonen <tpikonen@mailbox.org>
Date: Mon, 4 Sep 2023 20:41:51 +0300
Subject: [PATCH] Add modemmanager driver (MR 12)

The driver is called 'mm' in config.

Requires the dbus module.

[ci:skip-build] already built successfully in CI
---
 cmd/gnss-share/main.go        |   2 +
 gnss-share.conf               |   2 +-
 go.mod                        |   1 +
 go.sum                        |   2 +
 internal/gnss/modemmanager.go | 298 ++++++++++++++++++++++++++++++++++
 5 files changed, 304 insertions(+), 1 deletion(-)
 create mode 100644 internal/gnss/modemmanager.go

diff --git a/cmd/gnss-share/main.go b/cmd/gnss-share/main.go
index e2684df..77b6ab0 100644
--- a/cmd/gnss-share/main.go
+++ b/cmd/gnss-share/main.go
@@ -57,6 +57,8 @@ func main() {
 		driver = gnss.NewStmGnss(conf.DevicePath, debug)
 	case "stm_serial":
 		driver = gnss.NewStmSerial(conf.DevicePath, conf.BaudRate, debug)
+	case "mm":
+		driver = gnss.NewModemManager(debug)
 	}
 	gnssDevice, err := gnss.New(driver)
 	if err != nil {
diff --git a/gnss-share.conf b/gnss-share.conf
index 55aa8fc..4e53e36 100644
--- a/gnss-share.conf
+++ b/gnss-share.conf
@@ -4,7 +4,7 @@ socket="/var/run/gnss-share.sock"
 group="geoclue"
 
 # GPS device driver to use
-# Supported values: stm, stm_serial
+# Supported values: stm, stm_serial, mm
 device_driver="stm"
 
 # Path to GPS device to use
diff --git a/go.mod b/go.mod
index d6684b4..8ae21b2 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module gitlab.com/postmarketOS/gnss-share
 go 1.15
 
 require (
+	github.com/godbus/dbus/v5 v5.1.0
 	github.com/google/uuid v1.3.0
 	github.com/pelletier/go-toml v1.9.4
 	github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
diff --git a/go.sum b/go.sum
index 0d3d4d6..0fa9402 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
diff --git a/internal/gnss/modemmanager.go b/internal/gnss/modemmanager.go
new file mode 100644
index 0000000..a9c8578
--- /dev/null
+++ b/internal/gnss/modemmanager.go
@@ -0,0 +1,298 @@
+// Copyright 2023 Teemu Ikonen <tpikonen@mailbox.org>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package gnss
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"time"
+
+	dbus "github.com/godbus/dbus/v5"
+)
+
+const (
+	// Timeout for Dbus calls
+	DbusCallTimeout = 10 * time.Second
+	// Timeout for GPS setup Dbus calls
+	// (MM GPS setup sometimes takes > 20 s, e.g. on Oneplus 6)
+	GpsSetupTimeout = 50 * time.Second
+	// MMModemLocationSource
+	mmModemLocationSourceGpsNmea uint32 = 1 << 2
+	// MMModemState
+	mmModemStateEnabled int32 = 6
+)
+
+type ModemManager struct {
+	modemObj  dbus.BusObject
+	systemBus *dbus.Conn
+	debug     bool
+}
+
+func NewModemManager(debug bool) *ModemManager {
+	m := ModemManager{
+		modemObj:  nil,
+		systemBus: nil,
+		debug:     debug,
+	}
+	return &m
+}
+
+func (m *ModemManager) ensure_bus() error {
+	if m.systemBus != nil {
+		return nil
+	}
+	var err error
+	m.systemBus, err = dbus.ConnectSystemBus()
+	if err != nil {
+		return err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), DbusCallTimeout)
+	err = m.systemBus.AddMatchSignalContext(ctx,
+		dbus.WithMatchObjectPath("/org/freedesktop/ModemManager1"),
+		dbus.WithMatchInterface("org.freedesktop.DBus.ObjectManager"),
+		dbus.WithMatchMember("InterfacesAdded"),
+	)
+	cancel() // Free context resources
+	if err != nil {
+		return err
+	}
+	ctx, cancel = context.WithTimeout(context.Background(), DbusCallTimeout)
+	err = m.systemBus.AddMatchSignalContext(ctx,
+		dbus.WithMatchObjectPath("/org/freedesktop/ModemManager1"),
+		dbus.WithMatchInterface("org.freedesktop.DBus.ObjectManager"),
+		dbus.WithMatchMember("InterfacesRemoved"),
+	)
+	cancel() // Free context resources
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func wait_for_modem_enabled(modem dbus.BusObject, maxwait int) error {
+	var state int32 = -1
+
+	for i := 0; i < maxwait; i++ {
+		err := modem.StoreProperty("org.freedesktop.ModemManager1.Modem.State", &state)
+		if err == nil && state >= mmModemStateEnabled {
+			return nil
+		}
+		time.Sleep(1 * time.Second)
+	}
+
+	return fmt.Errorf("modem not enabled after waiting %d seconds", maxwait)
+}
+
+func (m *ModemManager) initialize_modem() error {
+	if err := m.ensure_bus(); err != nil {
+		return err
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), DbusCallTimeout)
+	manager := m.systemBus.Object("org.freedesktop.ModemManager1", dbus.ObjectPath("/org/freedesktop/ModemManager1"))
+	getcall := manager.CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0)
+	cancel()
+	if getcall.Err != nil {
+		return fmt.Errorf("unable to enumerate modems")
+	}
+	// The return value of GetManagedObjects is a somewhat involved map of map of map,
+	// see https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces-objectmanager
+	// We're only interested in the keys of the first map (the object paths) though.
+	modems := make(map[dbus.ObjectPath]map[string]map[string]dbus.Variant)
+	getcall.Store(&modems)
+	if len(modems) < 1 {
+		return fmt.Errorf("no modems found")
+	}
+	paths := make([]dbus.ObjectPath, 0, len(modems))
+	for k := range modems {
+		paths = append(paths, k)
+	}
+	if m.debug {
+		fmt.Println("Modems found:", paths)
+	}
+
+	m.modemObj = nil
+	// Find a modemObj which has a GPS NMEA location source and is enabled
+	for _, path := range paths {
+		var capabilities uint32
+
+		modem := m.systemBus.Object("org.freedesktop.ModemManager1", path)
+		err := modem.StoreProperty("org.freedesktop.ModemManager1.Modem.Location.Capabilities", &capabilities)
+		if err != nil || (capabilities&mmModemLocationSourceGpsNmea == 0) {
+			continue
+		}
+		err = wait_for_modem_enabled(modem, 5)
+		if err == nil {
+			if m.debug {
+				fmt.Printf("Modem %s is enabled and has GPS\n", path)
+			}
+			m.modemObj = modem
+			break
+		} else {
+			if m.debug {
+				fmt.Printf("Modem %s: %s\n", path, err)
+			}
+		}
+	}
+	if m.modemObj == nil {
+		return fmt.Errorf("could not find enabled modem with GPS capability")
+	}
+
+	return nil
+}
+
+func (m *ModemManager) enable_gps(refresh_interval time.Duration) (uint32, error) {
+	var enabled uint32 = 0
+	err := m.modemObj.StoreProperty("org.freedesktop.ModemManager1.Modem.Location.Enabled", &enabled)
+	if err != nil {
+		return 0, fmt.Errorf("unable get enabled location sources: %w", err)
+	}
+
+	ctx, cancel_setup := context.WithTimeout(context.Background(), GpsSetupTimeout)
+	err = m.modemObj.CallWithContext(ctx, "org.freedesktop.ModemManager1.Modem.Location.Setup", 0, enabled|mmModemLocationSourceGpsNmea, false).Err
+	cancel_setup() // Free context resources
+	if err != nil {
+		return enabled, fmt.Errorf("unable to enable GPS: %w", err)
+	}
+
+	ctx, cancel_setup = context.WithTimeout(context.Background(), DbusCallTimeout)
+	err = m.modemObj.CallWithContext(ctx, "org.freedesktop.ModemManager1.Modem.Location.SetGpsRefreshRate", 0, uint(refresh_interval.Seconds())).Err
+	cancel_setup() // Free context resources
+	if err != nil {
+		return enabled, fmt.Errorf("unable to set GPS refresh rate: %w", err)
+	}
+
+	return enabled, nil
+}
+
+func (m *ModemManager) disable_gps(enabled uint32) {
+	// Disable GPS NMEA, but keep other sources as they were
+	ctx, cancel := context.WithTimeout(context.Background(), DbusCallTimeout)
+	err := m.modemObj.CallWithContext(ctx, "org.freedesktop.ModemManager1.Modem.Location.Setup", 0, enabled&^mmModemLocationSourceGpsNmea, false).Err
+	cancel()
+	if m.debug && err != nil {
+		fmt.Println("Unable to disable GPS location on modem:", err)
+	}
+}
+
+func (m *ModemManager) Start(send func(data []byte), write <-chan []byte, stop <-chan struct{}) error {
+	if err := m.ensure_bus(); err != nil {
+		return err
+	}
+
+	refresh_interval := 1 * time.Second
+
+	go func() {
+		var enabled uint32 = 0
+		var errcount = 0
+		const max_errors = 2
+
+		signalChan := make(chan *dbus.Signal, 1)
+		m.systemBus.Signal(signalChan)
+		defer m.systemBus.RemoveSignal(signalChan)
+
+	run_loop:
+		// Repeat modem initialization and location polling until stopped.
+		for {
+
+		init_loop:
+			for {
+				if err := m.initialize_modem(); err != nil {
+					fmt.Println("Error initializing modem:", err)
+				} else {
+					if enabled, err = m.enable_gps(refresh_interval); err != nil {
+						fmt.Println("Error enabling GPS:", err)
+					} else {
+						break init_loop
+					}
+				}
+				select {
+				case <-stop:
+					return
+				case msg := <-write:
+					fmt.Println("message received, but mm driver is unable to forward:", string(msg))
+				case signal := <-signalChan:
+					if m.debug {
+						fmt.Println("Signal received:", signal.Name)
+					}
+				case <-time.After(60 * time.Second):
+				}
+				fmt.Println("Retrying modem init")
+			}
+
+			errcount = 0
+			stopped := false
+		poll_loop:
+			// This loop exits either when stopped, or when we get more than
+			// max_errors consecutive errors. In case of errors, initialize again.
+			for {
+				select {
+				case <-stop:
+					stopped = true
+					break poll_loop
+				case msg := <-write:
+					fmt.Println("message received, but mm driver is unable to forward:", string(msg))
+				case signal := <-signalChan:
+					if m.debug {
+						fmt.Println("Signal received:", signal.Name)
+					}
+				case <-time.After(refresh_interval): // TODO: make interval configurable
+					loc_ctx, cancel_loc := context.WithTimeout(context.Background(), refresh_interval)
+					loc_call := m.modemObj.CallWithContext(loc_ctx, "org.freedesktop.ModemManager1.Modem.Location.GetLocation", 0)
+					cancel_loc()
+					if loc_call.Err != nil {
+						fmt.Println("ERROR: unable to get location data from response: ", loc_call.Err)
+						errcount++
+						continue
+					}
+					loc := make(map[uint32]dbus.Variant)
+					loc_call.Store(&loc)
+					if val, ok := loc[mmModemLocationSourceGpsNmea]; !ok {
+						// MM can return locations without NMEA data, so this is not an error
+						continue
+					} else {
+						b := []byte{}
+						if err := val.Store(&b); err != nil {
+							fmt.Println("ERROR: unable to Store NMEAs: ", val.String())
+							errcount++
+							continue
+						}
+						// Remove double newlines and add one to the end
+						send(append(
+							bytes.ReplaceAll(b, []byte("\r\n\r\n"), []byte("\r\n")),
+							[]byte("\r\n")...))
+						errcount = 0
+					}
+				}
+				if errcount >= max_errors {
+					// The modem probably disappeared, so go back to init_loop
+					// to find and initialize it.
+					if m.debug {
+						fmt.Println("Too many errors when requesting location, re-initializing modem")
+					}
+					break poll_loop
+				}
+			}
+			// Disable GPS on both modem error and when stopped
+			m.disable_gps(enabled)
+			m.modemObj = nil
+			// Only exit run_loop (and the goroutine) when stopped.
+			if stopped {
+				break run_loop
+			}
+		}
+	}()
+
+	return nil
+}
+
+func (m *ModemManager) Save(dir string) (err error) {
+	return
+}
+
+func (m *ModemManager) Load(dir string) (err error) {
+	return
+}
-- 
GitLab