example 'meshtastic': let user select device, remember latest one used, update for android 12

This commit is contained in:
pls.153 2023-06-22 15:09:15 +02:00
parent ae461af4ca
commit 96d6843267
29 changed files with 461 additions and 216 deletions

View file

@ -7,7 +7,8 @@
:components ((:file "lisp/package")
(:file "lisp/qt")
(:file "lisp/ui-vars")
(:file "lisp/radio")
(:file "lisp/lora")
(:file "lisp/messages")
(:file "lisp/radios")
(:file "lisp/main")))

View file

@ -20,14 +20,18 @@ BLE::BLE(const QBluetoothUuid& uuid) : mainServiceUuid(uuid) {
}
void BLE::startDeviceDiscovery() {
connected = false;
scanned = false;
currentDevice = QBluetoothDeviceInfo();
devices.clear();
qDebug() << "scanning for devices...";
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
}
void BLE::addDevice(const QBluetoothDeviceInfo& device) {
if (deviceFilter(device)) {
qDebug() << "device added:" << device.name() << device.address().toString();
qDebug() << "device added:" << device.name();
}
}
@ -51,7 +55,7 @@ void BLE::scanServices() {
return;
}
if (!currentDevice.isValid()) {
if (initialDeviceName.isNull()) {
if (initialDeviceName.isEmpty()) {
currentDevice = devices.at(0);
} else {
for (auto device : qAsConst(devices)) {
@ -64,7 +68,7 @@ void BLE::scanServices() {
}
services.clear();
qDebug() << "connecting to device...";
if (controller && (previousAddress != currentDevice.address())) {
if (controller) {
Q_EMIT deviceDisconnecting();
controller->disconnectFromDevice();
delete controller; controller = nullptr;
@ -85,7 +89,6 @@ void BLE::scanServices() {
}
controller->connectToDevice();
previousAddress = currentDevice.address();
}
void BLE::setCurrentDevice(const QBluetoothDeviceInfo& device) {
@ -160,8 +163,7 @@ void BLE::disconnectFromDevice() {
}
void BLE::deviceDisconnected() {
connected = false;
qDebug() << "disconnect from device";
qDebug() << "disconnected from device";
}
void BLE::deviceScanError(QBluetoothDeviceDiscoveryAgent::Error error) {

View file

@ -38,12 +38,12 @@ Q_SIGNALS:
public Q_SLOTS:
void startDeviceDiscovery();
void disconnectFromDevice();
/*** </INTERFACE> *********************************************************/
void scanServices();
void connectToService(const QString&);
void disconnectFromDevice();
private Q_SLOTS:
// QBluetoothDeviceDiscoveryAgent related
@ -62,7 +62,6 @@ private:
void retryScan();
QBluetoothDeviceDiscoveryAgent* discoveryAgent;
QList<QLowEnergyService*> services;
QBluetoothAddress previousAddress;
QLowEnergyController* controller = nullptr;
bool connected = false;
bool scanned = false;

View file

@ -92,23 +92,23 @@ void BLE_ME::searchCharacteristics() {
}
if (toRadio.isValid() && fromRadio.isValid() && fromNum.isValid()) {
ecl_fun("radio:set-ready");
ecl_fun("lora:set-ready");
}
}
void BLE_ME::characteristicChanged(const QLowEnergyCharacteristic&,
const QByteArray& data) {
if (!data.isEmpty()) {
ecl_fun("radio:received-from-radio", data, "notified");
ecl_fun("lora:received-from-radio", data, "notified");
}
}
void BLE_ME::characteristicRead(const QLowEnergyCharacteristic&,
const QByteArray& data) {
if (data.isEmpty()) {
ecl_fun("radio:receiving-done");
ecl_fun("lora:receiving-done");
} else {
ecl_fun("radio:received-from-radio", data);
ecl_fun("lora:received-from-radio", data);
QTimer::singleShot(0, this, &BLE_ME::read);
}
}
@ -151,7 +151,7 @@ void BLE_ME::disconnecting() {
// disable notifications
mainService->writeDescriptor(notifications, QByteArray::fromHex("0000"));
}
ecl_fun("radio:set-ready", false);
ecl_fun("lora:set-ready", false);
delete mainService; mainService = nullptr;
}

View file

@ -16,17 +16,6 @@ QT::QT() : QObject() {
ble = new BLE_ME;
}
QVariant QT::setDevice(const QVariant& vName) {
auto name = vName.toString();
for (auto device : qAsConst(ble->devices)) {
if (device.name().contains(name, Qt::CaseInsensitive)) {
ble->setCurrentDevice(device);
return vName;
}
}
return QVariant();
}
QVariant QT::startDeviceDiscovery(const QVariant& vName) {
auto name = vName.toString();
if (!name.isNull()) {

View file

@ -19,8 +19,7 @@ class QT : public QObject {
public:
// BLE_ME
Q_INVOKABLE QVariant setDevice(const QVariant&);
Q_INVOKABLE QVariant startDeviceDiscovery(const QVariant& = QVariant());
Q_INVOKABLE QVariant startDeviceDiscovery(const QVariant&);
Q_INVOKABLE QVariant read2();
Q_INVOKABLE QVariant write2(const QVariant&);

View file

@ -1,6 +1,6 @@
(in-package :radio)
(in-package :lora)
(defvar *region* :eu-868) ; Europe 868 MHz
(defvar *settings* (list :region :eu-868)) ; Europe 868 MHz
(defvar *my-channel* nil)
(defvar *channels* nil)
@ -32,11 +32,10 @@
:element-type '(unsigned-byte 8)
:initial-contents list))
(defun set-ready (&optional (ready t)) ; called from Qt
(setf *ready* ready)
(when ready
(qlater 'start-config))
(values))
(defun start-device-discovery (&optional (name ""))
(setf radios:*schedule-clear* t)
(qt:start-device-discovery qt:*ble* name)
(q> |playing| ui:*busy* t))
(defun start-config ()
(when *ready*
@ -44,6 +43,12 @@
(send-to-radio
(me:make-to-radio :want-config-id *config-id*))))
(defun set-ready (&optional (ready t)) ; called from Qt
(setf *ready* ready)
(when ready
(qlater 'start-config))
(values))
(defun send-message (text)
"Sends TEXT to radio and adds it to QML item model."
(incf msg:*message-id*)
@ -133,7 +138,21 @@
(let ((info (me:node-info struct)))
(if (eql *my-node-info* (me:num info))
(setf *my-node-info* info)
(push info *node-infos*))))
(setf *node-infos*
(nconc *node-infos* (list info))))
(when radios:*schedule-clear*
(radios:clear))
(let ((name (me:short-name (me:user info)))
(current (= (me:num info)
(me:num *my-node-info*))))
(radios:add-radio
(list :name name
:hw-model (symbol-name (me:hw-model (me:user info)))
:battery-level (me:battery-level (me:device-metrics info))
:current current))
(when current
(setf (getf *settings* :device) name))))
(app:save-settings))
;; channel
((me:from-radio.has-channel struct)
(let ((channel (me:channel struct)))
@ -176,7 +195,7 @@
:set-config (me:make-config
:lora (me:make-config.lo-ra-config
:use-preset t
:region *region*
:region (getf *settings* :region)
:hop-limit 3
:tx-enabled t))))
;; channel settings

View file

@ -1,16 +1,26 @@
(in-package :app)
;; set here the 4 character short name of your 2 devices
;; (see debug output during device discovery)
(defvar *device-1* "128c")
(defvar *device-2* "1c9c")
(defun ini ()
(qt:ini)
(qt:start-device-discovery qt:*ble* *device-1*) ; set device (see above)
(load-settings)
(msg:load-messages)
(q> |playing| ui:*loading* nil) ; shown during Lisp startup
(q> |playing| ui:*busy* t)) ; shown during BLE setup
(q> |playing| ui:*loading* nil)
#+android
(ensure-permissions :bluetooth-scan :bluetooth-connect) ; android >= 12
(lora:start-device-discovery (getf lora:*settings* :device "")))
;;; settings
(defvar *file* (merge-pathnames "data/settings.exp"))
(defun load-settings ()
(when (probe-file *file*)
(with-open-file (s *file*)
(setf lora:*settings* (read s)))))
(defun save-settings ()
(with-open-file (s *file* :direction :output :if-exists :supersede)
(let ((*print-pretty* nil))
(prin1 lora:*settings* s))))
(qlater 'ini)

View file

@ -1,8 +1,10 @@
(defpackage :app
(:use :cl :qml)
(:export))
(:export
#:load-settings
#:save-settings))
(defpackage :radio
(defpackage :lora
(:use :cl :qml)
(:local-nicknames (:pr :cl-protobufs)
(:me :cl-protobufs.meshtastic))
@ -16,10 +18,11 @@
#:*reading*
#:*ready*
#:*received*
#:*region*
#:*remote-node*
#:*settings*
#:channel-to-url
#:start-config
#:start-device-discovery
#:read-radio
#:received-from-radio
#:send-message
@ -37,3 +40,12 @@
#:change-state
#:load-messages
#:save-messages))
(defpackage :radios
(:use :cl :qml)
(:export
#:*schedule-clear*
#:add-radio
#:change-radio
#:clear))

View file

@ -2,4 +2,3 @@
(in-package :qml-user)

View file

@ -3,7 +3,6 @@
(:export
#:*ble*
#:ini
#:set-device
#:start-device-discovery
#:read*
#:write*))

View file

@ -0,0 +1,18 @@
(in-package :radios)
(defvar *schedule-clear* nil)
(defun add-radio (radio)
"Adds passed RADIO (a PLIST) to QML item model.
The model keys are:
:name :hw-model :battery-level :current"
(qjs |addRadio| ui:*radios* radio))
(defun clear ()
(setf *schedule-clear* nil)
(q! |clear| ui:*radios*))
(defun change-radio (name) ; called from QML
(qlater (lambda () (lora:start-device-discovery name)))
(values))

View file

@ -6,12 +6,12 @@
#:*busy*
#:*loading*
#:*messages*
#:*view*))
#:*radios*))
(in-package :ui)
(defparameter *busy* "busy")
(defparameter *loading* "loading")
(defparameter *messages* "messages")
(defparameter *view* "view")
(defparameter *radios* "radios")

View file

@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/> <!-- android >= 12 -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <!-- android >= 12 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
@ -77,5 +79,4 @@
</activity>
<!-- For adding service(s) please check: https://wiki.qt.io/AndroidServices -->
</application>
</manifest>

View file

@ -0,0 +1,34 @@
import QtQuick 2.15
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 10
height: 25
color: "#f0f0f0"
radius: 2
border.width: 1
border.color: "#505050"
property int level: 0
Rectangle {
x: 1
width: parent.width - 2
height: (parent.height - 2) * level / 100
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
color: (level > 15) ? "#28c940" : "#ff5f57"
}
Text {
x: -4 - paintedWidth
height: parent.height
verticalAlignment: Text.AlignVCenter
font.pixelSize: 10
font.bold: true
font.family: fontMono.name
color: "white"
text: level + "%"
}
}

View file

@ -0,0 +1,5 @@
import QtQuick 2.15
Rectangle {
color: "#ccebc5"
}

View file

@ -0,0 +1,13 @@
import QtQuick 2.15
Image {
horizontalAlignment: Image.AlignHCenter
verticalAlignment: Image.AlignVCenter
width: header.height
height: width
MouseArea {
anchors.fill: parent
onClicked: view.currentIndex = parent.Positioner.index
}
}

View file

@ -0,0 +1,54 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import "." as Ext
Item {
anchors.fill: parent
Rectangle {
id: header
width: parent.width
height: main.headerHeight
color: "#f2f2f2"
Row {
height: parent.height
spacing: 5
anchors.horizontalCenter: parent.horizontalCenter
Ext.MainIcon { source: "../img/group.png" }
Ext.MainIcon { source: "../img/message.png" }
Ext.MainIcon { source: "../img/radio.png" }
}
}
SwipeView {
id: view
y: header.height
width: parent.width
height: parent.height - header.height
currentIndex: 1
Ext.Groups {}
Ext.Messages {}
Ext.Radios {}
}
PageIndicator {
id: control
y: header.height - 12
count: view.count
currentIndex: view.currentIndex
anchors.horizontalCenter: parent.horizontalCenter
delegate: Rectangle {
width: header.height
height: 5
radius: width / 2
color: "dodgerblue"
opacity: (index === control.currentIndex) ? 1 : 0
Behavior on opacity { OpacityAnimator { duration: 500 }}
}
}
}

View file

@ -0,0 +1,146 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
Rectangle {
id: main
color: loading.visible ? "#1974d3" : "#e5d8bd"
ListView {
id: view
anchors.fill: parent
anchors.bottomMargin: rectEdit.height + 5
anchors.margins: 5
spacing: 5
delegate: messageDelegate
model: messages
clip: true
}
ListModel {
id: messages
objectName: "messages"
// hack to define all model key _types_
ListElement {
text: ""; sender: ""; me: true; timestamp: ""; mid: 0; ackState: 0
}
function addMessage(message) {
append(message)
view.positionViewAtEnd()
}
function changeState(state, mid) {
for (var i = count - 1; i >= 0; i--) {
if (get(i).mid === mid) {
setProperty(i, "ackState", state)
break
}
}
}
Component.onCompleted: remove(0) // see hack above
}
Component {
id: messageDelegate
Rectangle {
id: delegate
width: Math.max(text.contentWidth, rowSender.width + 4 * text.padding) + 2 * text.padding
height: text.contentHeight + 2 * text.padding + sender.contentHeight
color: model.me ? "#f2f2f2" : "#ffffcc"
radius: 12
Row {
id: rowSender
padding: text.padding
spacing: padding
AnimatedImage {
id: semaphore
playing: false
y: 2
width: 8
height: width
source: "../img/semaphore.gif"
currentFrame: model.ackState
visible: model.me
}
Text {
id: sender
font.pixelSize: 10
font.bold: true
font.family: fontMono.name
color: "#8B0000"
text: model.sender
}
}
Text {
id: timestamp
x: delegate.width - contentWidth - text.padding
y: text.padding
font.pixelSize: 10
font.family: fontText.name
color: "#505050"
text: model.timestamp
}
Text {
id: text
y: sender.contentHeight
width: main.width
padding: 5
wrapMode: Text.Wrap
font.pixelSize: 18
font.family: fontText.name
color: "#303030"
text: model.text
}
}
}
Rectangle {
id: rectEdit
anchors.bottom: parent.bottom
width: parent.width
height: edit.paintedHeight + 14
border.width: 2
border.color: edit.focus ? "dodgerblue" : "#c0c0c0"
radius: 12
TextArea {
id: edit
anchors.fill: parent
textFormat: TextEdit.PlainText
font.pixelSize: 18
font.family: fontText.name
selectionColor: "#228ae3"
selectedTextColor: "white"
wrapMode: TextEdit.Wrap
textMargin: 0
placeholderText: qsTr("message")
}
Image {
anchors.right: parent.right
anchors.bottom: parent.top
anchors.margins: 3
width: 38
height: width
source: "../img/send.png"
visible: edit.focus
MouseArea {
anchors.fill: parent
onClicked: {
edit.focus = Qt.NoFocus
Lisp.call("lora:send-message", edit.text)
edit.clear()
}
}
}
}
}

View file

@ -0,0 +1,88 @@
import QtQuick 2.15
import "." as Ext
Rectangle {
color: "#b3cde3"
ListView {
id: view
anchors.fill: parent
anchors.margins: 9
spacing: 9
delegate: radioDelegate
model: radios
}
ListModel {
id: radios
objectName: "radios"
// hack to define all model key _types_
ListElement {
name: ""; hwModel: ""; batteryLevel: 0; current: false
}
function addRadio(radio) {
append(radio)
if (radio.current) {
view.currentIndex = view.count - 1
}
}
Component.onCompleted: remove(0) // see hack above
}
Component {
id: radioDelegate
Rectangle {
id: delegate
width: Math.min(265, view.width)
height: 35
color: (index === view.currentIndex) ? "firebrick" : "steelblue"
radius: height / 2
Rectangle {
x: 10
width: 42
height: 15
anchors.verticalCenter: parent.verticalCenter
color: "#f0f0f0"
radius: height / 2
Text {
anchors.centerIn: parent
font.pixelSize: 12
font.bold: true
font.family: fontMono.name
color: "black"
text: model.name
}
}
Text {
x: 58
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: 16
font.family: fontText.name
color: "white"
text: model.hwModel
}
Ext.BatteryLevel {
anchors.right: parent.right
anchors.rightMargin: 14
level: model.batteryLevel
}
MouseArea {
anchors.fill: parent
onClicked: {
view.currentIndex = index
Lisp.call("radios:change-radio", model.name)
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -1,6 +1,7 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Window 2.15
import "ext/" as Ext
Item {
id: main
@ -8,160 +9,21 @@ Item {
width: 300
height: 500
function availableHeight() {
var h = Math.round(Qt.inputMethod.keyboardRectangle.y /
((Qt.platform.os === "android") ? Screen.devicePixelRatio : 1))
return (h === 0) ? main.height : h
}
property double headerHeight: 48
Rectangle {
anchors.fill: parent
color: loading.visible ? "#1974d3" : "#e5d8bd"
}
Ext.MainView {}
ListView {
id: view
objectName: "view"
width: parent.width
height: availableHeight() - rectEdit.height - 3
anchors.margins: 3
spacing: 3
delegate: messageDelegate
model: messages
}
ListModel {
id: messages
objectName: "messages"
// hack to define all model key _types_
ListElement {
text: ""; sender: ""; me: true; timestamp: ""; mid: 0; ackState: 0
}
function addMessage(message) {
append(message)
view.positionViewAtEnd()
}
function changeState(state, mid) {
for (var i = count - 1; i >= 0; i--) {
if (get(i).mid === mid) {
setProperty(i, "ackState", state)
break
}
}
}
Component.onCompleted: remove(0) // see hack above
}
Component {
id: messageDelegate
Item {
id: delegate
width: Math.max(text.contentWidth, rowSender.width + 4 * text.padding) + 2 * text.padding
height: text.contentHeight + 2 * text.padding + sender.contentHeight
Rectangle {
anchors.fill: parent
color: model.me ? "#f2f2f2" : "#ffffcc"
radius: 12
border.width: 0
border.color: "#dc1128"
Row {
id: rowSender
padding: text.padding
spacing: padding
AnimatedImage {
id: semaphore
playing: false
y: 2
width: 8
Image {
source: "img/logo-128.png"
width: headerHeight
height: width
source: "img/semaphore.gif"
currentFrame: model.ackState
visible: model.me
}
Text {
id: sender
font.pixelSize: 10
font.bold: true
font.family: fontMono.name
color: "#8B0000"
text: model.sender
}
}
Text {
id: timestamp
x: delegate.width - contentWidth - text.padding
y: text.padding
font.pixelSize: 10
font.family: fontText.name
color: "#505050"
text: model.timestamp
}
Text {
id: text
y: sender.contentHeight
width: main.width
padding: 5
wrapMode: Text.Wrap
font.pixelSize: 18
font.family: fontText.name
color: "#303030"
text: model.text
}
}
}
}
Rectangle {
id: rectEdit
anchors.bottom: parent.bottom
width: parent.width
height: edit.paintedHeight + 14
border.width: 2
border.color: edit.focus ? "#228ae3" : "#c0c0c0"
radius: 12
TextArea {
id: edit
anchors.fill: parent
textFormat: TextEdit.PlainText
font.pixelSize: 18
font.family: fontText.name
selectionColor: "#228ae3"
selectedTextColor: "white"
wrapMode: TextEdit.Wrap
textMargin: 0
placeholderText: qsTr("message")
}
Image {
anchors.right: parent.right
anchors.bottom: parent.top
anchors.margins: 3
width: 38
source: "img/settings.png"
width: headerHeight
height: width
source: "img/send.png"
visible: edit.focus
MouseArea {
anchors.fill: parent
onClicked: {
edit.focus = Qt.NoFocus
Lisp.call("radio:send-message", edit.text)
edit.clear()
}
}
}
anchors.right: parent.right
}
// shown while loading app (may take a while)

View file

@ -55,19 +55,14 @@ Pairing might sometimes require some playing around. If it asks for a PIN and
your device doesn't have a display (like the RAK starter kit), just use
`123456`.
On iOS pairing is not needed beforehand, because it will ask for pairing and
PIN during BLE ini.
Pairing of your LoRa radios is generally not needed beforehand, except on
android, where you need to start the app only after successful pairing.
If your android phone says "no BLE devices found" (see logcat output), you
might need to uninstall an eventual app which used the LoRa radio before, and
restart the phone.
might need to unpair the devices and pair them again.
On Linux you might need to restart the bluetooth service if you want to pair
a different device (after already pairing a first one).
To choose which app instance will use which device, set both name and address
in [main.lisp](lisp/main.lisp), and set one app to `*device-1*` and the other
to `*device-2*`.
On Linux you might sometimes also need to unpair and pair again, if there are
errors when trying to connect.

View file

@ -35,5 +35,5 @@
(when (option "-slime")
(load "~/slime/lqml-start-swank") ; for 'slime-connect' from Emacs
(qlater (lambda () (in-package :radio))))
(qlater (lambda () (in-package :lora))))