mirror of
https://github.com/markqvist/Sideband.git
synced 2024-12-26 16:09:23 -05:00
Merge branch 'markqvist:main' into main
This commit is contained in:
commit
ab9daceeee
40
README.md
40
README.md
@ -1,7 +1,7 @@
|
|||||||
Sideband <img align="right" src="https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg"/>
|
Sideband <img align="right" src="https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg"/>
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Sideband is an LXMF client for Android, Linux and macOS. It allows you to communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, Encrypted QR Paper Messages, or anything else Reticulum supports.
|
Sideband is an extensible LXMF messaging client, situational awareness tracker and remote control and monitoring system for Android, Linux, macOS and Windows. It allows you to communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, Encrypted QR Paper Messages, or anything else Reticulum supports.
|
||||||
|
|
||||||
![Screenshot](https://github.com/markqvist/Sideband/raw/main/docs/screenshots/devices_small.webp)
|
![Screenshot](https://github.com/markqvist/Sideband/raw/main/docs/screenshots/devices_small.webp)
|
||||||
|
|
||||||
@ -9,15 +9,25 @@ Sideband is completely free, end-to-end encrypted, permission-less, anonymous an
|
|||||||
|
|
||||||
This also means that Sideband operates differently than what you might be used to. It does not need a connection to a server on the Internet to function, and you do not have an account anywhere. Please read the Guide section included in the program, to get an understanding of how Sideband differs from other messaging systems.
|
This also means that Sideband operates differently than what you might be used to. It does not need a connection to a server on the Internet to function, and you do not have an account anywhere. Please read the Guide section included in the program, to get an understanding of how Sideband differs from other messaging systems.
|
||||||
|
|
||||||
The program currently includes basic functionality for secure and independent communication, and many useful features are planned for implementation. Sideband is currently released as a beta version. Please help make all the functionality a reality by supporting the development with donations.
|
Sideband provides many useful and interesting functions, such as:
|
||||||
|
|
||||||
|
- Secure and self-sovereign messaging using the LXMF protocol over Reticulum.
|
||||||
|
- Image and file transfers over all supported mediums.
|
||||||
|
- Secure and direct P2P telemetry and location sharing. No third parties or servers ever have your data.
|
||||||
|
- Situation display on both online and locally stored offline maps.
|
||||||
|
- Geospatial awareness calculations.
|
||||||
|
- Exchanging messages through encrypted QR-codes on paper, or through messages embedded directly in **lxm://** links.
|
||||||
|
- Using Android devices as impromptu Reticulum routers (*Transport Instances*), for setting up or extending networks easily.
|
||||||
|
- Remote command execution and response engine, with built-in commands, such as `ping`, `signal` reports and `echo`.
|
||||||
|
- Remote telemetry querying, with strong, secure and cryptographically robust authentication and control.
|
||||||
|
- Plugin system that allows you to easily create your own commands, services and telemetry sources.
|
||||||
|
|
||||||
|
|
||||||
Sideband works well with the terminal-based LXMF client [Nomad Network](https://github.com/markqvist/nomadnet), which allows you to easily host Propagation Nodes for your LXMF network, and more.
|
Sideband works well with the terminal-based LXMF client [Nomad Network](https://github.com/markqvist/nomadnet), which allows you to easily host Propagation Nodes for your LXMF network, and more.
|
||||||
|
|
||||||
If you want to help develop this program, get in touch.
|
|
||||||
|
|
||||||
## Installation On Linux, Android and MacOS
|
## Installation On Linux, Android and MacOS
|
||||||
|
|
||||||
For your Android devices, download an [APK on the latest release](https://github.com/markqvist/Sideband/releases/latest) page.
|
For your Android devices, you can install Sideband through F-Droid, by adding the [Between the Borders Archive Repo](https://reticulum.betweentheborders.com/fdroid/repo/), or you can download an [APK on the latest release](https://github.com/markqvist/Sideband/releases/latest) page. Both sources are signed with the same release keys, and can be used interchangably.
|
||||||
|
|
||||||
A DMG file containing a macOS app bundle is also available on the [latest release](https://github.com/markqvist/Sideband/releases/latest) page.
|
A DMG file containing a macOS app bundle is also available on the [latest release](https://github.com/markqvist/Sideband/releases/latest) page.
|
||||||
|
|
||||||
@ -113,13 +123,23 @@ You can help support the continued development of open, free and private communi
|
|||||||
|
|
||||||
## Development Roadmap
|
## Development Roadmap
|
||||||
|
|
||||||
- Adding a Nomad Net page browser
|
- <s>Secure and private location and telemetry sharing</s>
|
||||||
|
- <s>Including images in messages</s>
|
||||||
|
- <s>Sending file attachments</s>
|
||||||
|
- <s>Offline and online maps</s>
|
||||||
|
- <s>Paper messages</s>
|
||||||
|
- <s>Using Sideband as a Reticulum Transport Instance</s>
|
||||||
|
- <s>Encryption keys export and import</s>
|
||||||
|
- <s>Plugin support for commands, services and telemetry</s>
|
||||||
|
- Sending voice messages (using Codec2 and Opus)
|
||||||
- Implementing the Local Broadcasts feature
|
- Implementing the Local Broadcasts feature
|
||||||
- Adding a debug log option and viewer
|
- LXMF sneakernet functionality
|
||||||
- Adding a Linux .desktop file
|
- Network visualisation and test tools
|
||||||
- Message sorting mechanism
|
- A debug log viewer
|
||||||
|
- Better message sorting mechanism
|
||||||
- Fix I2P status not being displayed correctly when the I2P router disappears unexpectedly
|
- Fix I2P status not being displayed correctly when the I2P router disappears unexpectedly
|
||||||
- Adding LXMF sneakernet functionality
|
- Adding a Linux .desktop file
|
||||||
|
- Adding a Nomad Net page browser
|
||||||
|
|
||||||
## License
|
## License
|
||||||
Unless otherwise noted, this work is licensed under a [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License][cc-by-nc-sa].
|
Unless otherwise noted, this work is licensed under a [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License][cc-by-nc-sa].
|
||||||
|
41
docs/example_plugins/basic.py
Normal file
41
docs/example_plugins/basic.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import RNS
|
||||||
|
|
||||||
|
class BasicCommandPlugin(SidebandCommandPlugin):
|
||||||
|
command_name = "basic_example"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Do any initialisation work here
|
||||||
|
RNS.log("Basic command plugin example starting...")
|
||||||
|
|
||||||
|
# And finally call start on superclass
|
||||||
|
super().start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
# Do any teardown work here
|
||||||
|
pass
|
||||||
|
|
||||||
|
# And finally call stop on superclass
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
def handle_command(self, arguments, lxm):
|
||||||
|
response_content = "Hello "+RNS.prettyhexrep(lxm.source_hash)+". "
|
||||||
|
response_content += "This is a response from the basic command example. It doesn't do much, but here is a list of the arguments you included:\n"
|
||||||
|
|
||||||
|
for argument in arguments:
|
||||||
|
response_content += "\n"+str(argument)
|
||||||
|
|
||||||
|
# Let the Sideband core send a reply.
|
||||||
|
self.get_sideband().send_message(
|
||||||
|
response_content,
|
||||||
|
lxm.source_hash,
|
||||||
|
False, # Don't use propagation by default, try direct first
|
||||||
|
skip_fields = True, # Don't include any additional fields automatically
|
||||||
|
no_display = True, # Don't display this message in the message stream
|
||||||
|
attachment = None, # Don't add any attachment field to this message
|
||||||
|
image = None, # Don't add any image field to this message
|
||||||
|
audio = None, # Don't add any audio field to this message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finally, tell Sideband what class in this
|
||||||
|
# file is the actual plugin class.
|
||||||
|
plugin_class = BasicCommandPlugin
|
70
docs/example_plugins/comic.py
Normal file
70
docs/example_plugins/comic.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import io
|
||||||
|
import RNS
|
||||||
|
import requests
|
||||||
|
from PIL import Image as PilImage
|
||||||
|
|
||||||
|
class ComicCommandPlugin(SidebandCommandPlugin):
|
||||||
|
command_name = "comic"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Do any initialisation work here
|
||||||
|
RNS.log("Comic command plugin example starting...")
|
||||||
|
|
||||||
|
# And finally call start on superclass
|
||||||
|
super().start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
# Do any teardown work here
|
||||||
|
pass
|
||||||
|
|
||||||
|
# And finally call stop on superclass
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
def handle_command(self, arguments, lxm):
|
||||||
|
comic_source = "https://imgs.xkcd.com/comics/tsp_vs_tbsp.png"
|
||||||
|
response_content = f"The source for this comic is:\n{comic_source}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_request = requests.get(comic_source, stream=True)
|
||||||
|
if image_request.status_code == 200:
|
||||||
|
max_size = 320, 320
|
||||||
|
with PilImage.open(io.BytesIO(image_request.content)) as im:
|
||||||
|
im.thumbnail(max_size)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
im.save(buf, format="webp", quality=22)
|
||||||
|
image_field = ["webp", buf.getvalue()]
|
||||||
|
|
||||||
|
# Send the fetched comic as a message
|
||||||
|
self.get_sideband().send_message(
|
||||||
|
response_content,
|
||||||
|
lxm.source_hash,
|
||||||
|
False, # Don't use propagation by default, try direct first
|
||||||
|
skip_fields = True, # Don't include any additional fields automatically
|
||||||
|
no_display = True, # Don't display this message in the message stream
|
||||||
|
image = image_field, # Add the scaled and compressed image
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Send an error message
|
||||||
|
self.get_sideband().send_message(
|
||||||
|
"The specified comic could not be downloaded",
|
||||||
|
lxm.source_hash,
|
||||||
|
False, # Don't use propagation by default, try direct first
|
||||||
|
skip_fields = True, # Don't include any additional fields automatically
|
||||||
|
no_display = True, # Don't display this message in the message stream
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Send an error message
|
||||||
|
self.get_sideband().send_message(
|
||||||
|
"An error occurred while trying to fetch the specified comic:\n\n"+str(e),
|
||||||
|
lxm.source_hash,
|
||||||
|
False, # Don't use propagation by default, try direct first
|
||||||
|
skip_fields = True, # Don't include any additional fields automatically
|
||||||
|
no_display = True, # Don't display this message in the message stream
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Finally, tell Sideband what class in this
|
||||||
|
# file is the actual plugin class.
|
||||||
|
plugin_class = ComicCommandPlugin
|
100
docs/example_plugins/gpsd_location.py
Normal file
100
docs/example_plugins/gpsd_location.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import RNS
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# This plugin requires the "gpsdclient" pip
|
||||||
|
# package to be installed on your system.
|
||||||
|
# Install it with: pip install gpsdclient
|
||||||
|
from gpsdclient import GPSDClient
|
||||||
|
|
||||||
|
class GpsdLocationPlugin(SidebandTelemetryPlugin):
|
||||||
|
plugin_name = "gpsd_location"
|
||||||
|
|
||||||
|
def __init__(self, sideband_core):
|
||||||
|
self.connect_timeout = 5.0
|
||||||
|
self.client = None
|
||||||
|
self.client_connected = False
|
||||||
|
self.should_run = False
|
||||||
|
|
||||||
|
self.latitude = None
|
||||||
|
self.longitude = None
|
||||||
|
self.altitude = None
|
||||||
|
self.speed = None
|
||||||
|
self.bearing = None
|
||||||
|
self.accuracy = None
|
||||||
|
self.last_update = None
|
||||||
|
|
||||||
|
super().__init__(sideband_core)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
RNS.log("Starting Linux GPSd Location provider plugin...")
|
||||||
|
|
||||||
|
self.should_run = True
|
||||||
|
update_thread = threading.Thread(target=self.update_job, daemon=True)
|
||||||
|
update_thread.start()
|
||||||
|
|
||||||
|
super().start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.should_run = False
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
def update_job(self):
|
||||||
|
while self.should_run:
|
||||||
|
RNS.log("Connecting to local GPSd...", RNS.LOG_DEBUG)
|
||||||
|
self.client_connected = False
|
||||||
|
try:
|
||||||
|
self.client = GPSDClient(timeout=self.connect_timeout)
|
||||||
|
for result in self.client.dict_stream(convert_datetime=True, filter=["TPV"]):
|
||||||
|
if not self.client_connected:
|
||||||
|
RNS.log("Connected, streaming GPSd data", RNS.LOG_DEBUG)
|
||||||
|
|
||||||
|
self.client_connected = True
|
||||||
|
self.last_update = time.time()
|
||||||
|
self.latitude = result.get("lat", None)
|
||||||
|
self.longitude = result.get("lon", None)
|
||||||
|
self.altitude = result.get("altHAE", None)
|
||||||
|
self.speed = result.get("speed", None)
|
||||||
|
self.bearing = result.get("track", None)
|
||||||
|
|
||||||
|
epx = result.get("epx", None); epy = result.get("epy", None)
|
||||||
|
epv = result.get("epv", None)
|
||||||
|
if epx != None and epy != None and epv != None:
|
||||||
|
self.accuracy = max(epx, epy, epv)
|
||||||
|
else:
|
||||||
|
self.accuracy = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log("Could not connect to local GPSd, retrying later", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
def has_location(self):
|
||||||
|
lat = self.latitude != None; lon = self.longitude != None
|
||||||
|
alt = self.altitude != None; spd = self.speed != None
|
||||||
|
brg = self.bearing != None; acc = self.accuracy != None
|
||||||
|
return lat and lon and alt and spd and brg and acc
|
||||||
|
|
||||||
|
def update_telemetry(self, telemeter):
|
||||||
|
if self.is_running() and telemeter != None:
|
||||||
|
if self.has_location():
|
||||||
|
RNS.log("Updating location from gpsd", RNS.LOG_DEBUG)
|
||||||
|
if not "location" in telemeter.sensors:
|
||||||
|
telemeter.synthesize("location")
|
||||||
|
|
||||||
|
telemeter.sensors["location"].latitude = self.latitude
|
||||||
|
telemeter.sensors["location"].longitude = self.longitude
|
||||||
|
telemeter.sensors["location"].altitude = self.altitude
|
||||||
|
telemeter.sensors["location"].speed = self.speed
|
||||||
|
telemeter.sensors["location"].bearing = self.bearing
|
||||||
|
telemeter.sensors["location"].accuracy = self.accuracy
|
||||||
|
telemeter.sensors["location"].stale_time = 5
|
||||||
|
telemeter.sensors["location"].set_update_time(self.last_update)
|
||||||
|
|
||||||
|
else:
|
||||||
|
RNS.log("No location from GPSd yet", RNS.LOG_DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
# Finally, tell Sideband what class in this
|
||||||
|
# file is the actual plugin class.
|
||||||
|
plugin_class = GpsdLocationPlugin
|
34
docs/example_plugins/service.py
Normal file
34
docs/example_plugins/service.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import RNS
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class BasicServicePlugin(SidebandServicePlugin):
|
||||||
|
service_name = "service_example"
|
||||||
|
|
||||||
|
def service_jobs(self):
|
||||||
|
while self.should_run:
|
||||||
|
time.sleep(5)
|
||||||
|
RNS.log("Service ping from "+str(self))
|
||||||
|
|
||||||
|
RNS.log("Jobs stopped running for "+str(self))
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Do any initialisation work here
|
||||||
|
RNS.log("Basic service plugin example starting...")
|
||||||
|
self.should_run = True
|
||||||
|
self.service_thread = threading.Thread(target=self.service_jobs, daemon=True)
|
||||||
|
self.service_thread.start()
|
||||||
|
|
||||||
|
# And finally call start on superclass
|
||||||
|
super().start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
# Do any teardown work here
|
||||||
|
self.should_run = False
|
||||||
|
|
||||||
|
# And finally call stop on superclass
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
# Finally, tell Sideband what class in this
|
||||||
|
# file is the actual plugin class.
|
||||||
|
plugin_class = BasicServicePlugin
|
34
docs/example_plugins/telemetry.py
Normal file
34
docs/example_plugins/telemetry.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import RNS
|
||||||
|
|
||||||
|
class BasicTelemetryPlugin(SidebandTelemetryPlugin):
|
||||||
|
plugin_name = "telemetry_example"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Do any initialisation work here
|
||||||
|
RNS.log("Basic telemetry plugin example starting...")
|
||||||
|
|
||||||
|
# And finally call start on superclass
|
||||||
|
super().start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
# Do any teardown work here
|
||||||
|
pass
|
||||||
|
|
||||||
|
# And finally call stop on superclass
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
def update_telemetry(self, telemeter):
|
||||||
|
if telemeter != None:
|
||||||
|
RNS.log("Updating power sensors")
|
||||||
|
telemeter.synthesize("power_consumption")
|
||||||
|
telemeter.sensors["power_consumption"].update_consumer(2163.15, type_label="Heater consumption")
|
||||||
|
telemeter.sensors["power_consumption"].update_consumer(12.7/1e6, type_label="Receiver consumption")
|
||||||
|
telemeter.sensors["power_consumption"].update_consumer(0.055, type_label="LED consumption")
|
||||||
|
telemeter.sensors["power_consumption"].update_consumer(982.22*1e9, type_label="Smelter consumption")
|
||||||
|
|
||||||
|
telemeter.synthesize("power_production")
|
||||||
|
telemeter.sensors["power_production"].update_producer(5732.15, type_label="Solar production")
|
||||||
|
|
||||||
|
# Finally, tell Sideband what class in this
|
||||||
|
# file is the actual plugin class.
|
||||||
|
plugin_class = BasicTelemetryPlugin
|
325
docs/example_plugins/view.py
Normal file
325
docs/example_plugins/view.py
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
# This plugin lets you remotely query and view a
|
||||||
|
# number of different image sources in Sideband.
|
||||||
|
#
|
||||||
|
# This plugin requires the "pillow" pip package.
|
||||||
|
#
|
||||||
|
# For HTTP and local file sources, no extras are
|
||||||
|
# required, but for fetching images from connected
|
||||||
|
# video sources, you need "opencv-python" from pip.
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import RNS
|
||||||
|
import time
|
||||||
|
import queue
|
||||||
|
import requests
|
||||||
|
import threading
|
||||||
|
import importlib
|
||||||
|
from PIL import Image as PilImage
|
||||||
|
|
||||||
|
if importlib.util.find_spec("cv2") != None:
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# Add view sources to the plugin
|
||||||
|
def register_view_sources():
|
||||||
|
ViewCommandPlugin.add_source("xkcd", HttpSource("https://imgs.xkcd.com/comics/tsp_vs_tbsp.png"))
|
||||||
|
ViewCommandPlugin.add_source("camera", CameraSource(camera_index=0))
|
||||||
|
ViewCommandPlugin.add_source("rocks", FileSource("~/Downloads/rocks.jpg"))
|
||||||
|
ViewCommandPlugin.add_source("osaka", StreamSource("http://honjin1.miemasu.net/nphMotionJpeg?Resolution=640x480&Quality=Standard"))
|
||||||
|
ViewCommandPlugin.add_source("factory", StreamSource("http://takemotopiano.aa1.netvolante.jp:8190/nphMotionJpeg?Resolution=640x480&Quality=Standard&Framerate=1"))
|
||||||
|
|
||||||
|
quality_presets = {
|
||||||
|
"lora": {"max": 160, "quality": 18},
|
||||||
|
"low": {"max": 256, "quality": 25},
|
||||||
|
"default": {"max": 320, "quality": 33},
|
||||||
|
"medium": {"max": 480, "quality": 50},
|
||||||
|
"high": {"max": 960, "quality": 65},
|
||||||
|
"hd": {"max": 1920, "quality": 75},
|
||||||
|
"4k": {"max": 3840, "quality": 65},
|
||||||
|
}
|
||||||
|
|
||||||
|
if not "default" in quality_presets:
|
||||||
|
raise ValueError("No default quality preset defined, please define one and reload the plugin")
|
||||||
|
|
||||||
|
class ViewSource():
|
||||||
|
DEFAULT_STALE_TIME = 3.14159
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.source_data = None
|
||||||
|
self.last_update = 0
|
||||||
|
self.stale_time = ViewSource.DEFAULT_STALE_TIME
|
||||||
|
|
||||||
|
def is_stale(self):
|
||||||
|
return time.time() > self.last_update + self.stale_time
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def scaled_image(self, max_dimension, quality):
|
||||||
|
with PilImage.open(io.BytesIO(self.source_data)) as im:
|
||||||
|
im.thumbnail((max_dimension, max_dimension))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
im.save(buf, format="webp", quality=quality)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def get_image_field(self, preset="default"):
|
||||||
|
if not preset in quality_presets:
|
||||||
|
preset = "default"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.is_stale():
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
if self.source_data != None:
|
||||||
|
max_dimension = quality_presets[preset]["max"]
|
||||||
|
quality = quality_presets[preset]["quality"]
|
||||||
|
return ["webp", self.scaled_image(max_dimension, quality)]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"Could not create image field for {self}. The contained exception was: {e}", RNS.LOG_ERROR)
|
||||||
|
RNS.trace_exception(e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
class HttpSource(ViewSource):
|
||||||
|
def __init__(self, url):
|
||||||
|
self.url = url
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
image_request = requests.get(self.url, stream=True)
|
||||||
|
if image_request.status_code == 200:
|
||||||
|
self.source_data = image_request.content
|
||||||
|
self.last_update = time.time()
|
||||||
|
else:
|
||||||
|
self.source_data = None
|
||||||
|
|
||||||
|
class CameraSource(ViewSource):
|
||||||
|
def __init__(self, camera_index=0, camera_width=1280, camera_height=720):
|
||||||
|
self.camera_index = camera_index
|
||||||
|
self.camera_width = camera_width
|
||||||
|
self.camera_height = camera_height
|
||||||
|
self.camera_ready = False
|
||||||
|
self.frame_queue = queue.Queue()
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.start_reading()
|
||||||
|
|
||||||
|
def start_reading(self):
|
||||||
|
self.camera = cv2.VideoCapture(self.camera_index)
|
||||||
|
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, self.camera_width)
|
||||||
|
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, self.camera_height)
|
||||||
|
threading.Thread(target=self.read_frames, daemon=True).start()
|
||||||
|
|
||||||
|
def read_frames(self):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ret, frame = self.camera.read()
|
||||||
|
self.camera_ready = True
|
||||||
|
if not ret:
|
||||||
|
self.camera_ready = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if not self.frame_queue.empty():
|
||||||
|
try:
|
||||||
|
self.frame_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.frame_queue.put(frame)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log("An error occurred while reading frames from the camera: "+str(e), RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
self.release_camera()
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
if not self.camera:
|
||||||
|
self.start_reading()
|
||||||
|
while not self.camera_ready:
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
retval, frame = self.camera.read()
|
||||||
|
|
||||||
|
if not retval:
|
||||||
|
self.source_data = None
|
||||||
|
else:
|
||||||
|
retval, buffer = cv2.imencode(".png", frame)
|
||||||
|
self.source_data = io.BytesIO(buffer).getvalue()
|
||||||
|
self.last_update = time.time()
|
||||||
|
|
||||||
|
def release_camera(self):
|
||||||
|
try:
|
||||||
|
self.camera.release()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.camera = None
|
||||||
|
self.camera_ready = False
|
||||||
|
|
||||||
|
class StreamSource(ViewSource):
|
||||||
|
DEFAULT_IDLE_TIMEOUT = 10
|
||||||
|
|
||||||
|
def __init__(self, url=None):
|
||||||
|
self.url = url
|
||||||
|
self.stream_ready = False
|
||||||
|
self.frame_queue = queue.Queue()
|
||||||
|
self.stream = None
|
||||||
|
self.started = 0
|
||||||
|
self.idle_timeout = StreamSource.DEFAULT_IDLE_TIMEOUT
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.start_reading()
|
||||||
|
|
||||||
|
def start_reading(self):
|
||||||
|
self.stream = cv2.VideoCapture(self.url)
|
||||||
|
self.started = time.time()
|
||||||
|
threading.Thread(target=self.read_frames, daemon=True).start()
|
||||||
|
|
||||||
|
def read_frames(self):
|
||||||
|
try:
|
||||||
|
while max(self.last_update, self.started)+self.idle_timeout > time.time():
|
||||||
|
ret, frame = self.stream.read()
|
||||||
|
if not ret:
|
||||||
|
self.stream_ready = False
|
||||||
|
else:
|
||||||
|
self.stream_ready = True
|
||||||
|
if not self.frame_queue.empty():
|
||||||
|
if self.frame_queue.qsize() > 1:
|
||||||
|
try:
|
||||||
|
self.frame_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.frame_queue.put(frame)
|
||||||
|
|
||||||
|
RNS.log(str(self)+" idled", RNS.LOG_DEBUG)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log("An error occurred while reading frames from the stream: "+str(e), RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
self.release_stream()
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
if not self.stream:
|
||||||
|
self.start_reading()
|
||||||
|
while not self.stream_ready:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if self.stream == None:
|
||||||
|
self.source_data = None
|
||||||
|
return
|
||||||
|
|
||||||
|
frame = self.frame_queue.get()
|
||||||
|
retval, buffer = cv2.imencode(".png", frame)
|
||||||
|
self.source_data = io.BytesIO(buffer).getvalue()
|
||||||
|
self.last_update = time.time()
|
||||||
|
|
||||||
|
def release_stream(self):
|
||||||
|
try:
|
||||||
|
self.stream.release()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.stream = None
|
||||||
|
self.stream_ready = False
|
||||||
|
|
||||||
|
class FileSource(ViewSource):
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = os.path.expanduser(path)
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
try:
|
||||||
|
with open(self.path, "rb") as image_file:
|
||||||
|
self.source_data = image_file.read()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log("Could not read image at \"{self.path}\": "+str(e), RNS.LOG_ERROR)
|
||||||
|
self.source_data = None
|
||||||
|
|
||||||
|
class ViewCommandPlugin(SidebandCommandPlugin):
|
||||||
|
command_name = "view"
|
||||||
|
sources = {}
|
||||||
|
|
||||||
|
stamptimefmt = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
RNS.log("View command plugin starting...")
|
||||||
|
super().start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_source(name, source):
|
||||||
|
ViewCommandPlugin.sources[name] = source
|
||||||
|
|
||||||
|
def message_response(self, message, destination):
|
||||||
|
self.get_sideband().send_message(
|
||||||
|
message,
|
||||||
|
destination,
|
||||||
|
False, # Don't use propagation by default, try direct first
|
||||||
|
skip_fields = True, # Don't include any additional fields automatically
|
||||||
|
no_display = True, # Don't display this message in the message stream
|
||||||
|
)
|
||||||
|
|
||||||
|
def image_response(self, message, image_field, destination):
|
||||||
|
self.get_sideband().send_message(
|
||||||
|
message,
|
||||||
|
destination,
|
||||||
|
False, # Don't use propagation by default, try direct first
|
||||||
|
skip_fields = True, # Don't include any additional fields automatically
|
||||||
|
no_display = True, # Don't display this message in the message stream
|
||||||
|
image = image_field, # Add the scaled and compressed image
|
||||||
|
)
|
||||||
|
|
||||||
|
def timestamp_str(self, time_s):
|
||||||
|
timestamp = time.localtime(time_s)
|
||||||
|
return time.strftime(self.stamptimefmt, timestamp)
|
||||||
|
|
||||||
|
def handle_command(self, arguments, lxm):
|
||||||
|
requestor = lxm.source_hash
|
||||||
|
|
||||||
|
if len(arguments) == 0:
|
||||||
|
self.message_response("No view source was specified", requestor)
|
||||||
|
return
|
||||||
|
|
||||||
|
if arguments[0] == "--list" or arguments[0] == "-l":
|
||||||
|
if len(self.sources) == 0:
|
||||||
|
response = "No sources available on this system"
|
||||||
|
else:
|
||||||
|
response = "Available Sources:\n"
|
||||||
|
for source in self.sources:
|
||||||
|
response += "\n - "+str(source)
|
||||||
|
|
||||||
|
self.message_response(response, requestor)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = arguments[0]
|
||||||
|
if len(arguments) > 1:
|
||||||
|
quality_preset = arguments[1]
|
||||||
|
else:
|
||||||
|
quality_preset = "default"
|
||||||
|
|
||||||
|
if not source in self.sources:
|
||||||
|
self.message_response("The specified view source does not exist on this system", requestor)
|
||||||
|
|
||||||
|
else:
|
||||||
|
image_field = self.sources[source].get_image_field(quality_preset)
|
||||||
|
image_timestamp = self.timestamp_str(self.sources[source].last_update)
|
||||||
|
message = f"Source [b]{source}[/b] at [b]{image_timestamp}[/b]"
|
||||||
|
|
||||||
|
if image_field != None:
|
||||||
|
self.image_response(message, image_field, requestor)
|
||||||
|
else:
|
||||||
|
self.message_response("The image source could not be retrieved or prepared", requestor)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.message_response(f"An error occurred:\n\n{e}", requestor)
|
||||||
|
|
||||||
|
register_view_sources()
|
||||||
|
|
||||||
|
# Finally, tell Sideband what class in this
|
||||||
|
# file is the actual plugin class.
|
||||||
|
plugin_class = ViewCommandPlugin
|
@ -65,6 +65,7 @@ fetchshare:
|
|||||||
cp ../../dist_archive/rnsh-*-py3-none-any.whl ./share/pkg/
|
cp ../../dist_archive/rnsh-*-py3-none-any.whl ./share/pkg/
|
||||||
cp ../../dist_archive/sbapp-*-py3-none-any.whl ./share/pkg/
|
cp ../../dist_archive/sbapp-*-py3-none-any.whl ./share/pkg/
|
||||||
cp ../../dist_archive/RNode_Firmware_*_Source.zip ./share/pkg/
|
cp ../../dist_archive/RNode_Firmware_*_Source.zip ./share/pkg/
|
||||||
|
zip --junk-paths ./share/pkg/example_plugins.zip ../docs/example_plugins/*.py
|
||||||
cp -r ../../dist_archive/reticulum.network ./share/mirrors/
|
cp -r ../../dist_archive/reticulum.network ./share/mirrors/
|
||||||
cp -r ../../dist_archive/unsigned.io ./share/mirrors/
|
cp -r ../../dist_archive/unsigned.io ./share/mirrors/
|
||||||
cp ../../dist_archive/Reticulum\ Manual.pdf ./share/mirrors/Reticulum_Manual.pdf
|
cp ../../dist_archive/Reticulum\ Manual.pdf ./share/mirrors/Reticulum_Manual.pdf
|
||||||
|
38014
sbapp/assets/geoids/egm2008-5.pgm
Normal file
38014
sbapp/assets/geoids/egm2008-5.pgm
Normal file
File diff suppressed because one or more lines are too long
18
sbapp/assets/geoids/egm2008-5.pgm.aux.xml
Normal file
18
sbapp/assets/geoids/egm2008-5.pgm.aux.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<PAMDataset>
|
||||||
|
<SRS>GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0],UNIT["degree",0.017453292519943295769],AUTHORITY["EPSG","4326"]]</SRS>
|
||||||
|
<GeoTransform>-0.04166666666666666,0.08333333333333333,0,90.04166666666666666,0,-0.08333333333333333</GeoTransform>
|
||||||
|
<Metadata>
|
||||||
|
<MDI key="Description">WGS84 EGM2008, 5-minute grid</MDI>
|
||||||
|
<MDI key="URL">http://earth-info.nga.mil/GandG/wgs84/gravitymod/egm2008</MDI>
|
||||||
|
<MDI key="DateTime">2009-08-29 18:45:00</MDI>
|
||||||
|
<MDI key="MaxBilinearError">0.478</MDI>
|
||||||
|
<MDI key="RMSBilinearError">0.012</MDI>
|
||||||
|
<MDI key="MaxCubicError">0.294</MDI>
|
||||||
|
<MDI key="RMSCubicError">0.005</MDI>
|
||||||
|
<MDI key="Offset">-108</MDI>
|
||||||
|
<MDI key="Scale">0.003</MDI>
|
||||||
|
<MDI key="AREA_OR_POINT">Point</MDI>
|
||||||
|
<MDI key="Vertical_Datum">WGS84</MDI>
|
||||||
|
<MDI key="Tie_Point_Location">pixel_corner</MDI>
|
||||||
|
</Metadata>
|
||||||
|
</PAMDataset>
|
6
sbapp/assets/geoids/egm2008-5.wld
Normal file
6
sbapp/assets/geoids/egm2008-5.wld
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
0.08333333333333333
|
||||||
|
0
|
||||||
|
0
|
||||||
|
-0.08333333333333333
|
||||||
|
0
|
||||||
|
90
|
@ -4,13 +4,13 @@ package.name = sideband
|
|||||||
package.domain = io.unsigned
|
package.domain = io.unsigned
|
||||||
|
|
||||||
source.dir = .
|
source.dir = .
|
||||||
source.include_exts = py,png,jpg,jpeg,webp,ttf,kv,pyi,typed,so,0,1,2,3,atlas,frag,html,css,js,whl,zip,gz,woff2,pdf,epub
|
source.include_exts = py,png,jpg,jpeg,webp,ttf,kv,pyi,typed,so,0,1,2,3,atlas,frag,html,css,js,whl,zip,gz,woff2,pdf,epub,pgm
|
||||||
source.include_patterns = assets/*,assets/fonts/*,share/*
|
source.include_patterns = assets/*,assets/fonts/*,share/*
|
||||||
source.exclude_patterns = app_storage/*,venv/*,Makefile,./Makefil*,requirements,precompiled/*,parked/*,./setup.py,Makef*,./Makefile,Makefile
|
source.exclude_patterns = app_storage/*,venv/*,Makefile,./Makefil*,requirements,precompiled/*,parked/*,./setup.py,Makef*,./Makefile,Makefile
|
||||||
|
|
||||||
version.regex = __version__ = ['"](.*)['"]
|
version.regex = __version__ = ['"](.*)['"]
|
||||||
version.filename = %(source.dir)s/main.py
|
version.filename = %(source.dir)s/main.py
|
||||||
android.numeric_version = 20240214
|
android.numeric_version = 20240326
|
||||||
|
|
||||||
# Cryptography recipe is currently broken, using RNS-internal crypto for now
|
# Cryptography recipe is currently broken, using RNS-internal crypto for now
|
||||||
requirements = kivy==2.3.0,libbz2,pillow==10.2.0,qrcode==7.3.1,usb4a,usbserial4a,libwebp
|
requirements = kivy==2.3.0,libbz2,pillow==10.2.0,qrcode==7.3.1,usb4a,usbserial4a,libwebp
|
||||||
|
214
sbapp/main.py
214
sbapp/main.py
@ -1,6 +1,6 @@
|
|||||||
__debug_build__ = False
|
__debug_build__ = False
|
||||||
__disable_shaders__ = False
|
__disable_shaders__ = False
|
||||||
__version__ = "0.8.0"
|
__version__ = "0.8.1"
|
||||||
__variant__ = "beta"
|
__variant__ = "beta"
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@ -1372,14 +1372,32 @@ class SidebandApp(MDApp):
|
|||||||
self.file_manager.show(path)
|
self.file_manager.show(path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.sideband.config["map_storage_path"] = None
|
if RNS.vendor.platformutils.get_platform() == "android":
|
||||||
self.sideband.save_configuration()
|
|
||||||
toast("Error reading directory, check permissions!")
|
toast("Error reading directory, check permissions!")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Attachment Error",
|
||||||
|
text="Error reading directory, check permissions!",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.sideband.config["map_storage_path"] = None
|
self.sideband.config["map_storage_path"] = None
|
||||||
self.sideband.save_configuration()
|
self.sideband.save_configuration()
|
||||||
|
if RNS.vendor.platformutils.get_platform() == "android":
|
||||||
toast("No file access, check permissions!")
|
toast("No file access, check permissions!")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Attachment Error",
|
||||||
|
text="No file access, check permissions!",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
def message_attach_action(self, attach_type=None):
|
def message_attach_action(self, attach_type=None):
|
||||||
self.attach_path = None
|
self.attach_path = None
|
||||||
@ -1780,7 +1798,7 @@ class SidebandApp(MDApp):
|
|||||||
self.information_screen.ids.information_scrollview.effect_cls = ScrollEffect
|
self.information_screen.ids.information_scrollview.effect_cls = ScrollEffect
|
||||||
self.information_screen.ids.information_logo.icon = self.sideband.asset_dir+"/rns_256.png"
|
self.information_screen.ids.information_logo.icon = self.sideband.asset_dir+"/rns_256.png"
|
||||||
|
|
||||||
info = "This is "+self.root.ids.app_version_info.text+", on RNS v"+RNS.__version__+" and LXMF v"+LXMF.__version__+".\n\nHumbly build using the following open components:\n\n - [b]Reticulum[/b] (MIT License)\n - [b]LXMF[/b] (MIT License)\n - [b]KivyMD[/b] (MIT License)\n - [b]Kivy[/b] (MIT License)\n - [b]Python[/b] (PSF License)"+"\n\nGo to [u][ref=link]https://unsigned.io/donate[/ref][/u] to support the project.\n\nThe Sideband app is Copyright (c) 2024 Mark Qvist / unsigned.io\n\nPermission is granted to freely share and distribute binary copies of Sideband v"+__version__+" "+__variant__+", so long as no payment or compensation is charged for said distribution or sharing.\n\nIf you were charged or paid anything for this copy of Sideband, please report it to [b]license@unsigned.io[/b].\n\nTHIS IS EXPERIMENTAL SOFTWARE - SIDEBAND COMES WITH ABSOLUTELY NO WARRANTY - USE AT YOUR OWN RISK AND RESPONSIBILITY"
|
info = "This is "+self.root.ids.app_version_info.text+", on RNS v"+RNS.__version__+" and LXMF v"+LXMF.__version__+".\n\nHumbly build using the following open components:\n\n - [b]Reticulum[/b] (MIT License)\n - [b]LXMF[/b] (MIT License)\n - [b]KivyMD[/b] (MIT License)\n - [b]Kivy[/b] (MIT License)\n - [b]GeoidHeight[/b] (LGPL License)\n - [b]Python[/b] (PSF License)"+"\n\nGo to [u][ref=link]https://unsigned.io/donate[/ref][/u] to support the project.\n\nThe Sideband app is Copyright (c) 2024 Mark Qvist / unsigned.io\n\nPermission is granted to freely share and distribute binary copies of Sideband v"+__version__+" "+__variant__+", so long as no payment or compensation is charged for said distribution or sharing.\n\nIf you were charged or paid anything for this copy of Sideband, please report it to [b]license@unsigned.io[/b].\n\nTHIS IS EXPERIMENTAL SOFTWARE - SIDEBAND COMES WITH ABSOLUTELY NO WARRANTY - USE AT YOUR OWN RISK AND RESPONSIBILITY"
|
||||||
self.information_screen.ids.information_info.text = info
|
self.information_screen.ids.information_info.text = info
|
||||||
self.information_screen.ids.information_info.bind(on_ref_press=link_exec)
|
self.information_screen.ids.information_info.bind(on_ref_press=link_exec)
|
||||||
|
|
||||||
@ -2368,7 +2386,7 @@ class SidebandApp(MDApp):
|
|||||||
all_valid = True
|
all_valid = True
|
||||||
iftypes = ["local", "tcp", "i2p", "rnode", "modem", "serial"]
|
iftypes = ["local", "tcp", "i2p", "rnode", "modem", "serial"]
|
||||||
for iftype in iftypes:
|
for iftype in iftypes:
|
||||||
element = self.root.ids["connectivity_"+iftype+"_ifmode"]
|
element = self.connectivity_screen.ids["connectivity_"+iftype+"_ifmode"]
|
||||||
modes = ["full", "gateway", "access point", "roaming", "boundary"]
|
modes = ["full", "gateway", "access point", "roaming", "boundary"]
|
||||||
value = element.text.lower()
|
value = element.text.lower()
|
||||||
if value in ["", "f"] or value.startswith("fu"):
|
if value in ["", "f"] or value.startswith("fu"):
|
||||||
@ -3781,6 +3799,158 @@ class SidebandApp(MDApp):
|
|||||||
|
|
||||||
c_dialog.open()
|
c_dialog.open()
|
||||||
|
|
||||||
|
### Plugins & Services screen
|
||||||
|
######################################
|
||||||
|
|
||||||
|
def plugins_action(self, sender=None, direction="left"):
|
||||||
|
if self.root.ids.screen_manager.has_screen("plugins_screen"):
|
||||||
|
self.plugins_open(direction=direction)
|
||||||
|
else:
|
||||||
|
self.loader_action(direction=direction)
|
||||||
|
def final(dt):
|
||||||
|
self.plugins_init()
|
||||||
|
def o(dt):
|
||||||
|
self.plugins_open(no_transition=True)
|
||||||
|
Clock.schedule_once(o, ll_ot)
|
||||||
|
Clock.schedule_once(final, ll_ft)
|
||||||
|
|
||||||
|
def plugins_init(self):
|
||||||
|
if not self.root.ids.screen_manager.has_screen("plugins_screen"):
|
||||||
|
self.plugins_screen = Builder.load_string(layout_plugins_screen)
|
||||||
|
self.plugins_screen.app = self
|
||||||
|
self.root.ids.screen_manager.add_widget(self.plugins_screen)
|
||||||
|
self.bind_clipboard_actions(self.plugins_screen.ids)
|
||||||
|
|
||||||
|
self.plugins_screen.ids.plugins_scrollview.effect_cls = ScrollEffect
|
||||||
|
info = "You can extend Sideband functionality with command and service plugins. This lets you to add your own custom functionality, or add community-developed features.\n\n"
|
||||||
|
info += "[b]Take extreme caution![/b]\nIf you add a plugin that you did not write yourself, make [b]absolutely[/b] sure you know what it is doing! Loaded plugins have full access to your Sideband application, and should only be added if you are completely certain they are trustworthy.\n\n"
|
||||||
|
info += "Command plugins allow you to define custom commands that can be carried out in response to LXMF command messages, and they can respond with any kind of information or data to the requestor (or to any LXMF address).\n\n"
|
||||||
|
info += "By using service plugins, you can start additional services or programs within the Sideband application context, that other plugins (or Sideband itself) can interact with."
|
||||||
|
info += "Restart Sideband for changes to these settings to take effect."
|
||||||
|
self.plugins_screen.ids.plugins_info.text = info
|
||||||
|
|
||||||
|
self.plugins_screen.ids.settings_command_plugins_enabled.active = self.sideband.config["command_plugins_enabled"]
|
||||||
|
self.plugins_screen.ids.settings_service_plugins_enabled.active = self.sideband.config["service_plugins_enabled"]
|
||||||
|
|
||||||
|
def plugins_settings_save(sender=None, event=None):
|
||||||
|
self.sideband.config["command_plugins_enabled"] = self.plugins_screen.ids.settings_command_plugins_enabled.active
|
||||||
|
self.sideband.config["service_plugins_enabled"] = self.plugins_screen.ids.settings_service_plugins_enabled.active
|
||||||
|
self.sideband.save_configuration()
|
||||||
|
|
||||||
|
self.plugins_screen.ids.settings_command_plugins_enabled.bind(active=plugins_settings_save)
|
||||||
|
self.plugins_screen.ids.settings_service_plugins_enabled.bind(active=plugins_settings_save)
|
||||||
|
|
||||||
|
def plugins_open(self, sender=None, direction="left", no_transition=False):
|
||||||
|
if no_transition:
|
||||||
|
self.root.ids.screen_manager.transition = self.no_transition
|
||||||
|
else:
|
||||||
|
self.root.ids.screen_manager.transition = self.slide_transition
|
||||||
|
self.root.ids.screen_manager.transition.direction = direction
|
||||||
|
|
||||||
|
self.root.ids.screen_manager.transition.direction = "left"
|
||||||
|
self.root.ids.screen_manager.current = "plugins_screen"
|
||||||
|
self.root.ids.nav_drawer.set_state("closed")
|
||||||
|
self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current)
|
||||||
|
|
||||||
|
if no_transition:
|
||||||
|
self.root.ids.screen_manager.transition = self.slide_transition
|
||||||
|
|
||||||
|
def close_plugins_action(self, sender=None):
|
||||||
|
self.open_conversations(direction="right")
|
||||||
|
|
||||||
|
def plugins_fm_got_path(self, path):
|
||||||
|
self.plugins_fm_exited()
|
||||||
|
try:
|
||||||
|
if os.path.isdir(path):
|
||||||
|
self.sideband.config["command_plugins_path"] = path
|
||||||
|
self.sideband.save_configuration()
|
||||||
|
|
||||||
|
if RNS.vendor.platformutils.is_android():
|
||||||
|
toast("Using \""+str(path)+"\" as plugin directory")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Directory Set",
|
||||||
|
text="Using \""+str(path)+"\" as plugin directory",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"Error while setting plugins directory to \"{path}\": "+str(e), RNS.LOG_ERROR)
|
||||||
|
if RNS.vendor.platformutils.get_platform() == "android":
|
||||||
|
toast("Could not set plugins directory to \""+str(path)+"\"")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
e_dialog = MDDialog(
|
||||||
|
title="Error",
|
||||||
|
text="Could not set plugins directory to \""+str(path)+"\"",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=e_dialog.dismiss)
|
||||||
|
e_dialog.open()
|
||||||
|
|
||||||
|
def plugins_fm_exited(self, *args):
|
||||||
|
self.manager_open = False
|
||||||
|
self.file_manager.close()
|
||||||
|
|
||||||
|
def plugins_select_directory_action(self, sender=None):
|
||||||
|
perm_ok = False
|
||||||
|
if self.sideband.config["command_plugins_path"] == None:
|
||||||
|
if RNS.vendor.platformutils.is_android():
|
||||||
|
perm_ok = self.check_storage_permission()
|
||||||
|
path = primary_external_storage_path()
|
||||||
|
|
||||||
|
else:
|
||||||
|
perm_ok = True
|
||||||
|
path = os.path.expanduser("~")
|
||||||
|
|
||||||
|
else:
|
||||||
|
perm_ok = True
|
||||||
|
path = self.sideband.config["command_plugins_path"]
|
||||||
|
|
||||||
|
if perm_ok and path != None:
|
||||||
|
try:
|
||||||
|
self.file_manager = MDFileManager(
|
||||||
|
exit_manager=self.plugins_fm_exited,
|
||||||
|
select_path=self.plugins_fm_got_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.file_manager.show(path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.sideband.config["command_plugins_path"] = None
|
||||||
|
self.sideband.save_configuration()
|
||||||
|
|
||||||
|
if RNS.vendor.platformutils.is_android():
|
||||||
|
toast("Error reading directory, check permissions!")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Error",
|
||||||
|
text="Could not read directory, check permissions!",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.sideband.config["command_plugins_path"] = None
|
||||||
|
self.sideband.save_configuration()
|
||||||
|
if RNS.vendor.platformutils.is_android():
|
||||||
|
toast("No file access, check permissions!")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Error",
|
||||||
|
text="No file access, check permissions!",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
|
|
||||||
### Telemetry Screen
|
### Telemetry Screen
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
@ -3929,7 +4099,19 @@ class SidebandApp(MDApp):
|
|||||||
self.sideband.config["map_storage_file"] = path
|
self.sideband.config["map_storage_file"] = path
|
||||||
self.sideband.config["map_storage_path"] = str(pathlib.Path(path).parent.resolve())
|
self.sideband.config["map_storage_path"] = str(pathlib.Path(path).parent.resolve())
|
||||||
self.sideband.save_configuration()
|
self.sideband.save_configuration()
|
||||||
|
|
||||||
|
if RNS.vendor.platformutils.is_android():
|
||||||
toast("Using \""+os.path.basename(path)+"\" as offline map")
|
toast("Using \""+os.path.basename(path)+"\" as offline map")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Map Set",
|
||||||
|
text="Using \""+os.path.basename(path)+"\" as offline map",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log(f"Error while loading map \"{path}\": "+str(e), RNS.LOG_ERROR)
|
RNS.log(f"Error while loading map \"{path}\": "+str(e), RNS.LOG_ERROR)
|
||||||
if RNS.vendor.platformutils.get_platform() == "android":
|
if RNS.vendor.platformutils.get_platform() == "android":
|
||||||
@ -3988,12 +4170,32 @@ class SidebandApp(MDApp):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.sideband.config["map_storage_path"] = None
|
self.sideband.config["map_storage_path"] = None
|
||||||
self.sideband.save_configuration()
|
self.sideband.save_configuration()
|
||||||
|
if RNS.vendor.platformutils.is_android():
|
||||||
toast("Error reading directory, check permissions!")
|
toast("Error reading directory, check permissions!")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Error",
|
||||||
|
text="Could not read directory, check permissions!",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.sideband.config["map_storage_path"] = None
|
self.sideband.config["map_storage_path"] = None
|
||||||
self.sideband.save_configuration()
|
self.sideband.save_configuration()
|
||||||
|
if RNS.vendor.platformutils.is_android():
|
||||||
toast("No file access, check permissions!")
|
toast("No file access, check permissions!")
|
||||||
|
else:
|
||||||
|
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
|
||||||
|
ate_dialog = MDDialog(
|
||||||
|
title="Error",
|
||||||
|
text="No file access, check permissions!",
|
||||||
|
buttons=[ ok_button ],
|
||||||
|
)
|
||||||
|
ok_button.bind(on_release=ate_dialog.dismiss)
|
||||||
|
ate_dialog.open()
|
||||||
|
|
||||||
def map_get_offline_source(self):
|
def map_get_offline_source(self):
|
||||||
if self.offline_source != None:
|
if self.offline_source != None:
|
||||||
@ -4765,7 +4967,7 @@ if not args.daemon:
|
|||||||
def run():
|
def run():
|
||||||
if args.daemon:
|
if args.daemon:
|
||||||
RNS.log("Starting Sideband in daemon mode")
|
RNS.log("Starting Sideband in daemon mode")
|
||||||
sideband = SidebandCore(None, is_client=False, verbose=(args.verbose or __debug_build__))
|
sideband = SidebandCore(None, is_client=False, verbose=(args.verbose or __debug_build__), is_daemon=True)
|
||||||
sideband.start()
|
sideband.start()
|
||||||
while True:
|
while True:
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
@ -7,6 +7,7 @@ import time
|
|||||||
import struct
|
import struct
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import random
|
import random
|
||||||
|
import shlex
|
||||||
|
|
||||||
import RNS.vendor.umsgpack as msgpack
|
import RNS.vendor.umsgpack as msgpack
|
||||||
import RNS.Interfaces.Interface as Interface
|
import RNS.Interfaces.Interface as Interface
|
||||||
@ -16,6 +17,7 @@ import multiprocessing.connection
|
|||||||
from threading import Lock
|
from threading import Lock
|
||||||
from .res import sideband_fb_data
|
from .res import sideband_fb_data
|
||||||
from .sense import Telemeter, Commands
|
from .sense import Telemeter, Commands
|
||||||
|
from .plugins import SidebandCommandPlugin, SidebandServicePlugin, SidebandTelemetryPlugin
|
||||||
|
|
||||||
if RNS.vendor.platformutils.get_platform() == "android":
|
if RNS.vendor.platformutils.get_platform() == "android":
|
||||||
from jnius import autoclass, cast
|
from jnius import autoclass, cast
|
||||||
@ -104,9 +106,10 @@ class SidebandCore():
|
|||||||
# stream logger
|
# stream logger
|
||||||
self.log_announce(destination_hash, app_data, dest_type=SidebandCore.aspect_filter)
|
self.log_announce(destination_hash, app_data, dest_type=SidebandCore.aspect_filter)
|
||||||
|
|
||||||
def __init__(self, owner_app, is_service=False, is_client=False, android_app_dir=None, verbose=False, owner_service=None, service_context=None):
|
def __init__(self, owner_app, is_service=False, is_client=False, android_app_dir=None, verbose=False, owner_service=None, service_context=None, is_daemon=False):
|
||||||
self.is_service = is_service
|
self.is_service = is_service
|
||||||
self.is_client = is_client
|
self.is_client = is_client
|
||||||
|
self.is_daemon = is_daemon
|
||||||
self.db = None
|
self.db = None
|
||||||
|
|
||||||
if not self.is_service and not self.is_client:
|
if not self.is_service and not self.is_client:
|
||||||
@ -137,9 +140,9 @@ class SidebandCore():
|
|||||||
self.owner_service = owner_service
|
self.owner_service = owner_service
|
||||||
|
|
||||||
self.app_dir = plyer.storagepath.get_home_dir()+"/.config/sideband"
|
self.app_dir = plyer.storagepath.get_home_dir()+"/.config/sideband"
|
||||||
self.cache_dir = self.app_dir+"/cache"
|
|
||||||
if self.app_dir.startswith("file://"):
|
if self.app_dir.startswith("file://"):
|
||||||
self.app_dir = self.app_dir.replace("file://", "")
|
self.app_dir = self.app_dir.replace("file://", "")
|
||||||
|
self.cache_dir = self.app_dir+"/cache"
|
||||||
|
|
||||||
self.rns_configdir = None
|
self.rns_configdir = None
|
||||||
if RNS.vendor.platformutils.is_android():
|
if RNS.vendor.platformutils.is_android():
|
||||||
@ -153,6 +156,9 @@ class SidebandCore():
|
|||||||
elif RNS.vendor.platformutils.get_platform() == "linux":
|
elif RNS.vendor.platformutils.get_platform() == "linux":
|
||||||
core_path = os.path.abspath(__file__)
|
core_path = os.path.abspath(__file__)
|
||||||
self.asset_dir = core_path.replace("/sideband/core.py", "/assets")
|
self.asset_dir = core_path.replace("/sideband/core.py", "/assets")
|
||||||
|
elif RNS.vendor.platformutils.is_windows():
|
||||||
|
core_path = os.path.abspath(__file__)
|
||||||
|
self.asset_dir = core_path.replace("\\sideband\\core.py", "\\assets")
|
||||||
else:
|
else:
|
||||||
self.asset_dir = plyer.storagepath.get_application_dir()+"/sbapp/assets"
|
self.asset_dir = plyer.storagepath.get_application_dir()+"/sbapp/assets"
|
||||||
|
|
||||||
@ -166,6 +172,8 @@ class SidebandCore():
|
|||||||
self.icon_macos = self.asset_dir+"/icon_macos.png"
|
self.icon_macos = self.asset_dir+"/icon_macos.png"
|
||||||
self.notification_icon = self.asset_dir+"/notification_icon.png"
|
self.notification_icon = self.asset_dir+"/notification_icon.png"
|
||||||
|
|
||||||
|
os.environ["TELEMETER_GEOID_PATH"] = os.path.join(self.asset_dir, "geoids")
|
||||||
|
|
||||||
if not os.path.isdir(self.app_dir+"/app_storage"):
|
if not os.path.isdir(self.app_dir+"/app_storage"):
|
||||||
os.makedirs(self.app_dir+"/app_storage")
|
os.makedirs(self.app_dir+"/app_storage")
|
||||||
|
|
||||||
@ -243,6 +251,12 @@ class SidebandCore():
|
|||||||
RNS.Transport.register_announce_handler(self)
|
RNS.Transport.register_announce_handler(self)
|
||||||
RNS.Transport.register_announce_handler(self.propagation_detector)
|
RNS.Transport.register_announce_handler(self.propagation_detector)
|
||||||
|
|
||||||
|
self.active_command_plugins = {}
|
||||||
|
self.active_service_plugins = {}
|
||||||
|
self.active_telemetry_plugins = {}
|
||||||
|
if self.is_service or self.is_standalone:
|
||||||
|
self.__load_plugins()
|
||||||
|
|
||||||
def clear_tmp_dir(self):
|
def clear_tmp_dir(self):
|
||||||
if os.path.isdir(self.tmp_dir):
|
if os.path.isdir(self.tmp_dir):
|
||||||
for file in os.listdir(self.tmp_dir):
|
for file in os.listdir(self.tmp_dir):
|
||||||
@ -591,6 +605,13 @@ class SidebandCore():
|
|||||||
if not "telemetry_s_information_text" in self.config:
|
if not "telemetry_s_information_text" in self.config:
|
||||||
self.config["telemetry_s_information_text"] = ""
|
self.config["telemetry_s_information_text"] = ""
|
||||||
|
|
||||||
|
if not "service_plugins_enabled" in self.config:
|
||||||
|
self.config["service_plugins_enabled"] = False
|
||||||
|
if not "command_plugins_enabled" in self.config:
|
||||||
|
self.config["command_plugins_enabled"] = False
|
||||||
|
if not "command_plugins_path" in self.config:
|
||||||
|
self.config["command_plugins_path"] = None
|
||||||
|
|
||||||
if not "map_history_limit" in self.config:
|
if not "map_history_limit" in self.config:
|
||||||
self.config["map_history_limit"] = 7*24*60*60
|
self.config["map_history_limit"] = 7*24*60*60
|
||||||
if not "map_lat" in self.config:
|
if not "map_lat" in self.config:
|
||||||
@ -656,6 +677,78 @@ class SidebandCore():
|
|||||||
if self.is_client:
|
if self.is_client:
|
||||||
self.setstate("wants.settings_reload", True)
|
self.setstate("wants.settings_reload", True)
|
||||||
|
|
||||||
|
def __load_plugins(self):
|
||||||
|
plugins_path = self.config["command_plugins_path"]
|
||||||
|
command_plugins_enabled = self.config["command_plugins_enabled"] == True
|
||||||
|
service_plugins_enabled = self.config["service_plugins_enabled"] == True
|
||||||
|
plugins_enabled = service_plugins_enabled
|
||||||
|
|
||||||
|
if plugins_enabled:
|
||||||
|
if plugins_path != None:
|
||||||
|
if os.path.isdir(plugins_path):
|
||||||
|
for file in os.listdir(plugins_path):
|
||||||
|
if file.lower().endswith(".py"):
|
||||||
|
plugin_globals = {}
|
||||||
|
plugin_globals["SidebandServicePlugin"] = SidebandServicePlugin
|
||||||
|
plugin_globals["SidebandCommandPlugin"] = SidebandCommandPlugin
|
||||||
|
plugin_globals["SidebandTelemetryPlugin"] = SidebandTelemetryPlugin
|
||||||
|
RNS.log("Loading plugin \""+str(file)+"\"", RNS.LOG_NOTICE)
|
||||||
|
plugin_path = os.path.join(plugins_path, file)
|
||||||
|
exec(open(plugin_path).read(), plugin_globals)
|
||||||
|
plugin_class = plugin_globals["plugin_class"]
|
||||||
|
|
||||||
|
if plugin_class != None:
|
||||||
|
plugin = plugin_class(self)
|
||||||
|
plugin.start()
|
||||||
|
|
||||||
|
if plugin.is_running():
|
||||||
|
if issubclass(type(plugin), SidebandCommandPlugin) and command_plugins_enabled:
|
||||||
|
command_name = plugin.command_name
|
||||||
|
if not command_name in self.active_command_plugins:
|
||||||
|
self.active_command_plugins[command_name] = plugin
|
||||||
|
RNS.log("Registered "+str(plugin)+" as handler for command \""+str(command_name)+"\"", RNS.LOG_NOTICE)
|
||||||
|
else:
|
||||||
|
RNS.log("Could not register "+str(plugin)+" as handler for command \""+str(command_name)+"\". Command name was already registered", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
elif issubclass(type(plugin), SidebandServicePlugin):
|
||||||
|
service_name = plugin.service_name
|
||||||
|
if not service_name in self.active_service_plugins:
|
||||||
|
self.active_service_plugins[service_name] = plugin
|
||||||
|
RNS.log("Registered "+str(plugin)+" for service \""+str(service_name)+"\"", RNS.LOG_NOTICE)
|
||||||
|
else:
|
||||||
|
RNS.log("Could not register "+str(plugin)+" for service \""+str(service_name)+"\". Service name was already registered", RNS.LOG_ERROR)
|
||||||
|
try:
|
||||||
|
plugin.stop()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
del plugin
|
||||||
|
|
||||||
|
elif issubclass(type(plugin), SidebandTelemetryPlugin):
|
||||||
|
plugin_name = plugin.plugin_name
|
||||||
|
if not plugin_name in self.active_telemetry_plugins:
|
||||||
|
self.active_telemetry_plugins[plugin_name] = plugin
|
||||||
|
RNS.log("Registered "+str(plugin)+" as telemetry plugin \""+str(plugin_name)+"\"", RNS.LOG_NOTICE)
|
||||||
|
else:
|
||||||
|
RNS.log("Could not register "+str(plugin)+" as telemetry plugin \""+str(plugin_name)+"\". Telemetry type was already registered", RNS.LOG_ERROR)
|
||||||
|
try:
|
||||||
|
plugin.stop()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
del plugin
|
||||||
|
|
||||||
|
else:
|
||||||
|
RNS.log("Unknown plugin type was loaded, ignoring it.", RNS.LOG_ERROR)
|
||||||
|
try:
|
||||||
|
plugin.stop()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
del plugin
|
||||||
|
|
||||||
|
else:
|
||||||
|
RNS.log("Plugin "+str(plugin)+" failed to start, ignoring it.", RNS.LOG_ERROR)
|
||||||
|
del plugin
|
||||||
|
|
||||||
|
|
||||||
def reload_configuration(self):
|
def reload_configuration(self):
|
||||||
self.__reload_config()
|
self.__reload_config()
|
||||||
|
|
||||||
@ -677,6 +770,7 @@ class SidebandCore():
|
|||||||
RNS.log("Error while setting LXMF propagation node: "+str(e), RNS.LOG_ERROR)
|
RNS.log("Error while setting LXMF propagation node: "+str(e), RNS.LOG_ERROR)
|
||||||
|
|
||||||
def notify(self, title, content, group=None, context_id=None):
|
def notify(self, title, content, group=None, context_id=None):
|
||||||
|
if not self.is_daemon:
|
||||||
if self.config["notifications_on"]:
|
if self.config["notifications_on"]:
|
||||||
if RNS.vendor.platformutils.get_platform() == "android":
|
if RNS.vendor.platformutils.get_platform() == "android":
|
||||||
if self.getpersistent("permissions.notifications"):
|
if self.getpersistent("permissions.notifications"):
|
||||||
@ -2355,6 +2449,15 @@ class SidebandCore():
|
|||||||
else:
|
else:
|
||||||
self.telemeter.disable(sensor)
|
self.telemeter.disable(sensor)
|
||||||
|
|
||||||
|
for telemetry_plugin in self.active_telemetry_plugins:
|
||||||
|
try:
|
||||||
|
plugin = self.active_telemetry_plugins[telemetry_plugin]
|
||||||
|
plugin.update_telemetry(self.telemeter)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log("An error occurred while "+str(telemetry_plugin)+" was handling telemetry. The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||||
|
RNS.trace_exception(e)
|
||||||
|
|
||||||
if self.config["telemetry_s_fixed_location"]:
|
if self.config["telemetry_s_fixed_location"]:
|
||||||
self.telemeter.synthesize("location")
|
self.telemeter.synthesize("location")
|
||||||
self.telemeter.sensors["location"].latitude = self.config["telemetry_s_fixed_latlon"][0]
|
self.telemeter.sensors["location"].latitude = self.config["telemetry_s_fixed_latlon"][0]
|
||||||
@ -3325,6 +3428,8 @@ class SidebandCore():
|
|||||||
commands.append({Commands.SIGNAL_REPORT: True})
|
commands.append({Commands.SIGNAL_REPORT: True})
|
||||||
elif content.startswith("ping"):
|
elif content.startswith("ping"):
|
||||||
commands.append({Commands.PING: True})
|
commands.append({Commands.PING: True})
|
||||||
|
else:
|
||||||
|
commands.append({Commands.PLUGIN_COMMAND: content})
|
||||||
|
|
||||||
if len(commands) == 0:
|
if len(commands) == 0:
|
||||||
return False
|
return False
|
||||||
@ -3608,6 +3713,19 @@ class SidebandCore():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log("Error while ingesting LXMF message "+RNS.prettyhexrep(message.hash)+" to database: "+str(e), RNS.LOG_ERROR)
|
RNS.log("Error while ingesting LXMF message "+RNS.prettyhexrep(message.hash)+" to database: "+str(e), RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
def handle_plugin_command(self, command_string, message):
|
||||||
|
try:
|
||||||
|
call = shlex.split(command_string)
|
||||||
|
command = call[0]
|
||||||
|
arguments = call[1:]
|
||||||
|
if command in self.active_command_plugins:
|
||||||
|
RNS.log("Handling command \""+str(command)+"\" via command plugin "+str(self.active_command_plugins[command]), RNS.LOG_DEBUG)
|
||||||
|
self.active_command_plugins[command].handle_command(arguments, message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log("An error occurred while handling a plugin command. The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||||
|
RNS.trace_exception(e)
|
||||||
|
|
||||||
def handle_commands(self, commands, message):
|
def handle_commands(self, commands, message):
|
||||||
try:
|
try:
|
||||||
context_dest = message.source_hash
|
context_dest = message.source_hash
|
||||||
@ -3648,6 +3766,9 @@ class SidebandCore():
|
|||||||
|
|
||||||
self.send_message(phy_str, context_dest, False, skip_fields=True, no_display=True)
|
self.send_message(phy_str, context_dest, False, skip_fields=True, no_display=True)
|
||||||
|
|
||||||
|
elif self.config["command_plugins_enabled"] and Commands.PLUGIN_COMMAND in command:
|
||||||
|
self.handle_plugin_command(command[Commands.PLUGIN_COMMAND], message)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log("Error while handling commands: "+str(e), RNS.LOG_ERROR)
|
RNS.log("Error while handling commands: "+str(e), RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
import mmap
|
||||||
|
import struct
|
||||||
import RNS
|
import RNS
|
||||||
from math import pi, sin, cos, acos, asin, tan, atan, atan2
|
from math import pi, sin, cos, acos, asin, tan, atan, atan2
|
||||||
from math import radians, degrees, sqrt
|
from math import radians, degrees, sqrt
|
||||||
@ -17,6 +20,7 @@ eccentricity_squared = 2*ellipsoid_flattening-pow(ellipsoid_flattening,2)
|
|||||||
###############################
|
###############################
|
||||||
|
|
||||||
mean_earth_radius = (1/3)*(2*equatorial_radius+polar_radius)
|
mean_earth_radius = (1/3)*(2*equatorial_radius+polar_radius)
|
||||||
|
geoid_height = None
|
||||||
|
|
||||||
def geocentric_latitude(geodetic_latitude):
|
def geocentric_latitude(geodetic_latitude):
|
||||||
e2 = eccentricity_squared
|
e2 = eccentricity_squared
|
||||||
@ -290,19 +294,216 @@ def shared_radio_horizon(c1, c2,):
|
|||||||
"antenna_distance": antenna_distance
|
"antenna_distance": antenna_distance
|
||||||
}
|
}
|
||||||
|
|
||||||
def ghtest():
|
def geoid_offset(lat, lon):
|
||||||
import pygeodesy
|
global geoid_height
|
||||||
from pygeodesy.ellipsoidalKarney import LatLon
|
if geoid_height == None:
|
||||||
ginterpolator = pygeodesy.GeoidKarney("./assets/geoids/egm2008-5.pgm")
|
geoid_height = GeoidHeight()
|
||||||
|
return geoid_height.get(lat, lon)
|
||||||
|
|
||||||
# Make an example location
|
def altitude_to_aamsl(alt, lat, lon):
|
||||||
lat=51.416422
|
if alt == None or lat == None or lon == None:
|
||||||
lon=-116.217151
|
return None
|
||||||
|
else:
|
||||||
|
return alt-geoid_offset(lat, lon)
|
||||||
|
|
||||||
|
######################################################
|
||||||
|
# GeoidHeight class by Kim Vandry <vandry@TZoNE.ORG> #
|
||||||
|
# Originally ported fromGeographicLib/src/Geoid.cpp #
|
||||||
|
# LGPLv3 License #
|
||||||
|
######################################################
|
||||||
|
|
||||||
|
class GeoidHeight(object):
|
||||||
|
c0 = 240
|
||||||
|
c3 = (
|
||||||
|
( 9, -18, -88, 0, 96, 90, 0, 0, -60, -20),
|
||||||
|
( -9, 18, 8, 0, -96, 30, 0, 0, 60, -20),
|
||||||
|
( 9, -88, -18, 90, 96, 0, -20, -60, 0, 0),
|
||||||
|
(186, -42, -42, -150, -96, -150, 60, 60, 60, 60),
|
||||||
|
( 54, 162, -78, 30, -24, -90, -60, 60, -60, 60),
|
||||||
|
( -9, -32, 18, 30, 24, 0, 20, -60, 0, 0),
|
||||||
|
( -9, 8, 18, 30, -96, 0, -20, 60, 0, 0),
|
||||||
|
( 54, -78, 162, -90, -24, 30, 60, -60, 60, -60),
|
||||||
|
(-54, 78, 78, 90, 144, 90, -60, -60, -60, -60),
|
||||||
|
( 9, -8, -18, -30, -24, 0, 20, 60, 0, 0),
|
||||||
|
( -9, 18, -32, 0, 24, 30, 0, 0, -60, 20),
|
||||||
|
( 9, -18, -8, 0, -24, -30, 0, 0, 60, 20),
|
||||||
|
)
|
||||||
|
|
||||||
|
c0n = 372
|
||||||
|
c3n = (
|
||||||
|
( 0, 0, -131, 0, 138, 144, 0, 0, -102, -31),
|
||||||
|
( 0, 0, 7, 0, -138, 42, 0, 0, 102, -31),
|
||||||
|
( 62, 0, -31, 0, 0, -62, 0, 0, 0, 31),
|
||||||
|
(124, 0, -62, 0, 0, -124, 0, 0, 0, 62),
|
||||||
|
(124, 0, -62, 0, 0, -124, 0, 0, 0, 62),
|
||||||
|
( 62, 0, -31, 0, 0, -62, 0, 0, 0, 31),
|
||||||
|
( 0, 0, 45, 0, -183, -9, 0, 93, 18, 0),
|
||||||
|
( 0, 0, 216, 0, 33, 87, 0, -93, 12, -93),
|
||||||
|
( 0, 0, 156, 0, 153, 99, 0, -93, -12, -93),
|
||||||
|
( 0, 0, -45, 0, -3, 9, 0, 93, -18, 0),
|
||||||
|
( 0, 0, -55, 0, 48, 42, 0, 0, -84, 31),
|
||||||
|
( 0, 0, -7, 0, -48, -42, 0, 0, 84, 31),
|
||||||
|
)
|
||||||
|
|
||||||
|
c0s = 372
|
||||||
|
c3s = (
|
||||||
|
( 18, -36, -122, 0, 120, 135, 0, 0, -84, -31),
|
||||||
|
(-18, 36, -2, 0, -120, 51, 0, 0, 84, -31),
|
||||||
|
( 36, -165, -27, 93, 147, -9, 0, -93, 18, 0),
|
||||||
|
(210, 45, -111, -93, -57, -192, 0, 93, 12, 93),
|
||||||
|
(162, 141, -75, -93, -129, -180, 0, 93, -12, 93),
|
||||||
|
(-36, -21, 27, 93, 39, 9, 0, -93, -18, 0),
|
||||||
|
( 0, 0, 62, 0, 0, 31, 0, 0, 0, -31),
|
||||||
|
( 0, 0, 124, 0, 0, 62, 0, 0, 0, -62),
|
||||||
|
( 0, 0, 124, 0, 0, 62, 0, 0, 0, -62),
|
||||||
|
( 0, 0, 62, 0, 0, 31, 0, 0, 0, -31),
|
||||||
|
(-18, 36, -64, 0, 66, 51, 0, 0, -102, 31),
|
||||||
|
( 18, -36, 2, 0, -66, -51, 0, 0, 102, 31),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, name="egm2008-5.pgm"):
|
||||||
|
self.offset = None
|
||||||
|
self.scale = None
|
||||||
|
|
||||||
|
if "TELEMETER_GEOID_PATH" in os.environ:
|
||||||
|
geoid_dir = os.environ["TELEMETER_GEOID_PATH"]
|
||||||
|
else:
|
||||||
|
geoid_dir = "./"
|
||||||
|
|
||||||
|
pgm_path = os.path.join(geoid_dir, name)
|
||||||
|
RNS.log(f"Opening {pgm_path} as EGM for altitude correction", RNS.LOG_DEBUG)
|
||||||
|
with open(pgm_path, "rb") as f:
|
||||||
|
line = f.readline()
|
||||||
|
if line != b"P5\012" and line != b"P5\015\012":
|
||||||
|
raise Exception("No PGM header")
|
||||||
|
headerlen = len(line)
|
||||||
|
while True:
|
||||||
|
line = f.readline()
|
||||||
|
if len(line) == 0:
|
||||||
|
raise Exception("EOF before end of file header")
|
||||||
|
headerlen += len(line)
|
||||||
|
if line.startswith(b'# Offset '):
|
||||||
|
try:
|
||||||
|
self.offset = int(line[9:])
|
||||||
|
except ValueError as e:
|
||||||
|
raise Exception("Error reading offset", e)
|
||||||
|
elif line.startswith(b'# Scale '):
|
||||||
|
try:
|
||||||
|
self.scale = float(line[8:])
|
||||||
|
except ValueError as e:
|
||||||
|
raise Exception("Error reading scale", e)
|
||||||
|
elif not line.startswith(b'#'):
|
||||||
|
try:
|
||||||
|
self.width, self.height = list(map(int, line.split()))
|
||||||
|
except ValueError as e:
|
||||||
|
raise Exception("Bad PGM width&height line", e)
|
||||||
|
break
|
||||||
|
line = f.readline()
|
||||||
|
headerlen += len(line)
|
||||||
|
levels = int(line)
|
||||||
|
if levels != 65535:
|
||||||
|
raise Exception("PGM file must have 65535 gray levels")
|
||||||
|
if self.offset is None:
|
||||||
|
raise Exception("PGM file does not contain offset")
|
||||||
|
if self.scale is None:
|
||||||
|
raise Exception("PGM file does not contain scale")
|
||||||
|
|
||||||
|
if self.width < 2 or self.height < 2:
|
||||||
|
raise Exception("Raster size too small")
|
||||||
|
|
||||||
|
fd = f.fileno()
|
||||||
|
fullsize = os.fstat(fd).st_size
|
||||||
|
|
||||||
|
if fullsize - headerlen != self.width * self.height * 2:
|
||||||
|
raise Exception("File has the wrong length")
|
||||||
|
|
||||||
|
self.headerlen = headerlen
|
||||||
|
self.raw = mmap.mmap(fd, fullsize, mmap.MAP_SHARED, mmap.PROT_READ)
|
||||||
|
|
||||||
|
self.rlonres = self.width / 360.0
|
||||||
|
self.rlatres = (self.height - 1) / 180.0
|
||||||
|
self.ix = None
|
||||||
|
self.iy = None
|
||||||
|
|
||||||
|
def _rawval(self, ix, iy):
|
||||||
|
if iy < 0:
|
||||||
|
iy = -iy
|
||||||
|
ix += self.width/2
|
||||||
|
elif iy >= self.height:
|
||||||
|
iy = 2 * (self.height - 1) - iy
|
||||||
|
ix += self.width/2
|
||||||
|
if ix < 0:
|
||||||
|
ix += self.width
|
||||||
|
elif ix >= self.width:
|
||||||
|
ix -= self.width
|
||||||
|
|
||||||
|
return struct.unpack_from('>H', self.raw,
|
||||||
|
(iy * self.width + ix) * 2 + self.headerlen
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
def get(self, lat, lon, cubic=True):
|
||||||
|
if lon < 0:
|
||||||
|
lon += 360
|
||||||
|
fy = (90 - lat) * self.rlatres
|
||||||
|
fx = lon * self.rlonres
|
||||||
|
iy = int(fy)
|
||||||
|
ix = int(fx)
|
||||||
|
fx -= ix
|
||||||
|
fy -= iy
|
||||||
|
if iy == self.height - 1:
|
||||||
|
iy -= 1
|
||||||
|
|
||||||
|
if ix != self.ix or iy != self.iy:
|
||||||
|
self.ix = ix
|
||||||
|
self.iy = iy
|
||||||
|
if not cubic:
|
||||||
|
self.v00 = self._rawval(ix, iy)
|
||||||
|
self.v01 = self._rawval(ix+1, iy)
|
||||||
|
self.v10 = self._rawval(ix, iy+1)
|
||||||
|
self.v11 = self._rawval(ix+1, iy+1)
|
||||||
|
else:
|
||||||
|
v = (
|
||||||
|
self._rawval(ix , iy - 1),
|
||||||
|
self._rawval(ix + 1, iy - 1),
|
||||||
|
self._rawval(ix - 1, iy ),
|
||||||
|
self._rawval(ix , iy ),
|
||||||
|
self._rawval(ix + 1, iy ),
|
||||||
|
self._rawval(ix + 2, iy ),
|
||||||
|
self._rawval(ix - 1, iy + 1),
|
||||||
|
self._rawval(ix , iy + 1),
|
||||||
|
self._rawval(ix + 1, iy + 1),
|
||||||
|
self._rawval(ix + 2, iy + 1),
|
||||||
|
self._rawval(ix , iy + 2),
|
||||||
|
self._rawval(ix + 1, iy + 2)
|
||||||
|
)
|
||||||
|
if iy == 0:
|
||||||
|
c3x = GeoidHeight.c3n
|
||||||
|
c0x = GeoidHeight.c0n
|
||||||
|
elif iy == self.height - 2:
|
||||||
|
c3x = GeoidHeight.c3s
|
||||||
|
c0x = GeoidHeight.c0s
|
||||||
|
else:
|
||||||
|
c3x = GeoidHeight.c3
|
||||||
|
c0x = GeoidHeight.c0
|
||||||
|
self.t = [
|
||||||
|
sum([ v[j] * c3x[j][i] for j in range(12) ]) / float(c0x)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
if not cubic:
|
||||||
|
a = (1 - fx) * self.v00 + fx * self.v01
|
||||||
|
b = (1 - fx) * self.v10 + fx * self.v11
|
||||||
|
h = (1 - fy) * a + fy * b
|
||||||
|
else:
|
||||||
|
h = (
|
||||||
|
self.t[0] +
|
||||||
|
fx * (self.t[1] + fx * (self.t[3] + fx * self.t[6])) +
|
||||||
|
fy * (
|
||||||
|
self.t[2] + fx * (self.t[4] + fx * self.t[7]) +
|
||||||
|
fy * (self.t[5] + fx * self.t[8] + fy * self.t[9])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return self.offset + self.scale * h
|
||||||
|
|
||||||
# Get the geoid height
|
|
||||||
single_position=LatLon(lat, lon)
|
|
||||||
h = ginterpolator(single_position)
|
|
||||||
print(h)
|
|
||||||
|
|
||||||
# def tests():
|
# def tests():
|
||||||
# import RNS
|
# import RNS
|
||||||
@ -345,3 +546,19 @@ def ghtest():
|
|||||||
# print("Euclidian = "+RNS.prettydistance(ed_own)+f" {fac*ed_own}")
|
# print("Euclidian = "+RNS.prettydistance(ed_own)+f" {fac*ed_own}")
|
||||||
# print("AzAlt = "+f" {aa[0]} / {aa[1]}")
|
# print("AzAlt = "+f" {aa[0]} / {aa[1]}")
|
||||||
# print("")
|
# print("")
|
||||||
|
|
||||||
|
# def ghtest():
|
||||||
|
# import pygeodesy
|
||||||
|
# from pygeodesy.ellipsoidalKarney import LatLon
|
||||||
|
# ginterpolator = pygeodesy.GeoidKarney("./assets/geoids/egm2008-5.pgm")
|
||||||
|
# # Make an example location
|
||||||
|
# lat=51.416422
|
||||||
|
# lon=-116.217151
|
||||||
|
# if geoid_height == None:
|
||||||
|
# geoid_height = GeoidHeight()
|
||||||
|
# h2 = geoid_height.get(lat, lon)
|
||||||
|
# # Get the geoid height
|
||||||
|
# single_position=LatLon(lat, lon)
|
||||||
|
# h1 = ginterpolator(single_position)
|
||||||
|
# print(h1)
|
||||||
|
# print(h2)
|
||||||
|
62
sbapp/sideband/plugins.py
Normal file
62
sbapp/sideband/plugins.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
class SidebandPlugin():
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SidebandCommandPlugin(SidebandPlugin):
|
||||||
|
def __init__(self, sideband_core):
|
||||||
|
self.__sideband = sideband_core
|
||||||
|
self.__started = False
|
||||||
|
self.command_name = type(self).command_name
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.__started = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.__started = False
|
||||||
|
|
||||||
|
def is_running(self):
|
||||||
|
return self.__started == True
|
||||||
|
|
||||||
|
def get_sideband(self):
|
||||||
|
return self.__sideband
|
||||||
|
|
||||||
|
def handle_command(self, arguments):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
class SidebandServicePlugin(SidebandPlugin):
|
||||||
|
def __init__(self, sideband_core):
|
||||||
|
self.__sideband = sideband_core
|
||||||
|
self.__started = False
|
||||||
|
self.service_name = type(self).service_name
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.__started = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.__started = False
|
||||||
|
|
||||||
|
def is_running(self):
|
||||||
|
return self.__started == True
|
||||||
|
|
||||||
|
def get_sideband(self):
|
||||||
|
return self.__sideband
|
||||||
|
|
||||||
|
class SidebandTelemetryPlugin(SidebandPlugin):
|
||||||
|
def __init__(self, sideband_core):
|
||||||
|
self.__sideband = sideband_core
|
||||||
|
self.__started = False
|
||||||
|
self.plugin_name = type(self).plugin_name
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.__started = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.__started = False
|
||||||
|
|
||||||
|
def is_running(self):
|
||||||
|
return self.__started == True
|
||||||
|
|
||||||
|
def get_sideband(self):
|
||||||
|
return self.__sideband
|
||||||
|
|
||||||
|
def update_telemetry(self, telemeter):
|
||||||
|
raise NotImplementedError
|
@ -5,10 +5,11 @@ import struct
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
from RNS.vendor import umsgpack as umsgpack
|
from RNS.vendor import umsgpack as umsgpack
|
||||||
from .geo import orthodromic_distance, euclidian_distance
|
from .geo import orthodromic_distance, euclidian_distance, altitude_to_aamsl
|
||||||
from .geo import azalt, angle_to_horizon, radio_horizon, shared_radio_horizon
|
from .geo import azalt, angle_to_horizon, radio_horizon, shared_radio_horizon
|
||||||
|
|
||||||
class Commands():
|
class Commands():
|
||||||
|
PLUGIN_COMMAND = 0x00
|
||||||
TELEMETRY_REQUEST = 0x01
|
TELEMETRY_REQUEST = 0x01
|
||||||
PING = 0x02
|
PING = 0x02
|
||||||
ECHO = 0x03
|
ECHO = 0x03
|
||||||
@ -42,38 +43,28 @@ class Telemeter():
|
|||||||
|
|
||||||
def __init__(self, from_packed=False, android_context=None, service=False, location_provider=None):
|
def __init__(self, from_packed=False, android_context=None, service=False, location_provider=None):
|
||||||
self.sids = {
|
self.sids = {
|
||||||
Sensor.SID_TIME: Time,
|
Sensor.SID_TIME: Time, Sensor.SID_RECEIVED: Received,
|
||||||
Sensor.SID_RECEIVED: Received,
|
Sensor.SID_INFORMATION: Information, Sensor.SID_BATTERY: Battery,
|
||||||
Sensor.SID_INFORMATION: Information,
|
Sensor.SID_PRESSURE: Pressure, Sensor.SID_LOCATION: Location,
|
||||||
Sensor.SID_BATTERY: Battery,
|
Sensor.SID_PHYSICAL_LINK: PhysicalLink, Sensor.SID_TEMPERATURE: Temperature,
|
||||||
Sensor.SID_PRESSURE: Pressure,
|
Sensor.SID_HUMIDITY: Humidity, Sensor.SID_MAGNETIC_FIELD: MagneticField,
|
||||||
Sensor.SID_LOCATION: Location,
|
Sensor.SID_AMBIENT_LIGHT: AmbientLight, Sensor.SID_GRAVITY: Gravity,
|
||||||
Sensor.SID_PHYSICAL_LINK: PhysicalLink,
|
Sensor.SID_ANGULAR_VELOCITY: AngularVelocity, Sensor.SID_ACCELERATION: Acceleration,
|
||||||
Sensor.SID_TEMPERATURE: Temperature,
|
Sensor.SID_PROXIMITY: Proximity, Sensor.SID_POWER_CONSUMPTION: PowerConsumption,
|
||||||
Sensor.SID_HUMIDITY: Humidity,
|
Sensor.SID_POWER_PRODUCTION: PowerProduction, Sensor.SID_PROCESSOR: Processor,
|
||||||
Sensor.SID_MAGNETIC_FIELD: MagneticField,
|
Sensor.SID_RAM: RandomAccessMemory, Sensor.SID_NVM: NonVolatileMemory,
|
||||||
Sensor.SID_AMBIENT_LIGHT: AmbientLight,
|
|
||||||
Sensor.SID_GRAVITY: Gravity,
|
|
||||||
Sensor.SID_ANGULAR_VELOCITY: AngularVelocity,
|
|
||||||
Sensor.SID_ACCELERATION: Acceleration,
|
|
||||||
Sensor.SID_PROXIMITY: Proximity,
|
|
||||||
}
|
}
|
||||||
self.available = {
|
self.available = {
|
||||||
"time": Sensor.SID_TIME,
|
"time": Sensor.SID_TIME,
|
||||||
"information": Sensor.SID_INFORMATION,
|
"information": Sensor.SID_INFORMATION, "received": Sensor.SID_RECEIVED,
|
||||||
"received": Sensor.SID_RECEIVED,
|
"battery": Sensor.SID_BATTERY, "pressure": Sensor.SID_PRESSURE,
|
||||||
"battery": Sensor.SID_BATTERY,
|
"location": Sensor.SID_LOCATION, "physical_link": Sensor.SID_PHYSICAL_LINK,
|
||||||
"pressure": Sensor.SID_PRESSURE,
|
"temperature": Sensor.SID_TEMPERATURE, "humidity": Sensor.SID_HUMIDITY,
|
||||||
"location": Sensor.SID_LOCATION,
|
"magnetic_field": Sensor.SID_MAGNETIC_FIELD, "ambient_light": Sensor.SID_AMBIENT_LIGHT,
|
||||||
"physical_link": Sensor.SID_PHYSICAL_LINK,
|
"gravity": Sensor.SID_GRAVITY, "angular_velocity": Sensor.SID_ANGULAR_VELOCITY,
|
||||||
"temperature": Sensor.SID_TEMPERATURE,
|
"acceleration": Sensor.SID_ACCELERATION, "proximity": Sensor.SID_PROXIMITY,
|
||||||
"humidity": Sensor.SID_HUMIDITY,
|
"power_consumption": Sensor.SID_POWER_CONSUMPTION, "power_production": Sensor.SID_POWER_PRODUCTION,
|
||||||
"magnetic_field": Sensor.SID_MAGNETIC_FIELD,
|
"processor": Sensor.SID_PROCESSOR, "ram": Sensor.SID_RAM, "nvm": Sensor.SID_NVM,
|
||||||
"ambient_light": Sensor.SID_AMBIENT_LIGHT,
|
|
||||||
"gravity": Sensor.SID_GRAVITY,
|
|
||||||
"angular_velocity": Sensor.SID_ANGULAR_VELOCITY,
|
|
||||||
"acceleration": Sensor.SID_ACCELERATION,
|
|
||||||
"proximity": Sensor.SID_PROXIMITY,
|
|
||||||
}
|
}
|
||||||
self.from_packed = from_packed
|
self.from_packed = from_packed
|
||||||
self.sensors = {}
|
self.sensors = {}
|
||||||
@ -195,6 +186,11 @@ class Sensor():
|
|||||||
SID_PROXIMITY = 0x0E
|
SID_PROXIMITY = 0x0E
|
||||||
SID_INFORMATION = 0x0F
|
SID_INFORMATION = 0x0F
|
||||||
SID_RECEIVED = 0x10
|
SID_RECEIVED = 0x10
|
||||||
|
SID_POWER_CONSUMPTION = 0x11
|
||||||
|
SID_POWER_PRODUCTION = 0x12
|
||||||
|
SID_PROCESSOR = 0x13
|
||||||
|
SID_RAM = 0x14
|
||||||
|
SID_NVM = 0x15
|
||||||
|
|
||||||
def __init__(self, sid = None, stale_time = None):
|
def __init__(self, sid = None, stale_time = None):
|
||||||
self._telemeter = None
|
self._telemeter = None
|
||||||
@ -624,6 +620,7 @@ class Location(Sensor):
|
|||||||
self._min_distance = Location.MIN_DISTANCE
|
self._min_distance = Location.MIN_DISTANCE
|
||||||
self._accuracy_target = Location.ACCURACY_TARGET
|
self._accuracy_target = Location.ACCURACY_TARGET
|
||||||
self._query_method = None
|
self._query_method = None
|
||||||
|
self._synthesized_updates = False
|
||||||
|
|
||||||
self.latitude = None
|
self.latitude = None
|
||||||
self.longitude = None
|
self.longitude = None
|
||||||
@ -683,12 +680,23 @@ class Location(Sensor):
|
|||||||
self._raw = kwargs
|
self._raw = kwargs
|
||||||
self._last_update = time.time()
|
self._last_update = time.time()
|
||||||
|
|
||||||
|
def get_aamsl(self):
|
||||||
|
if self.data["altitude"] == None or self.data["latitude"] == None or self.data["longitude"] == None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return altitude_to_aamsl(self.data["altitude"], self.data["latitude"], self.data["longitude"])
|
||||||
|
|
||||||
|
def set_update_time(self, update_time):
|
||||||
|
self._synthesized_updates = True
|
||||||
|
self._last_update = update_time
|
||||||
|
|
||||||
def update_data(self):
|
def update_data(self):
|
||||||
try:
|
try:
|
||||||
if self.synthesized:
|
if self.synthesized:
|
||||||
if self.latitude != None and self.longitude != None:
|
if self.latitude != None and self.longitude != None:
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
if not self._synthesized_updates:
|
||||||
if self._last_update == None:
|
if self._last_update == None:
|
||||||
self._last_update = now
|
self._last_update = now
|
||||||
elif now > self._last_update + self._stale_time:
|
elif now > self._last_update + self._stale_time:
|
||||||
@ -786,10 +794,12 @@ class Location(Sensor):
|
|||||||
|
|
||||||
obj_ath = None
|
obj_ath = None
|
||||||
obj_rh = None
|
obj_rh = None
|
||||||
|
aamsl = None
|
||||||
if self.data["altitude"] != None and self.data["latitude"] != None and self.data["longitude"] != None:
|
if self.data["altitude"] != None and self.data["latitude"] != None and self.data["longitude"] != None:
|
||||||
coords = (self.data["latitude"], self.data["longitude"], self.data["altitude"])
|
aamsl = self.get_aamsl()
|
||||||
|
coords = (self.data["latitude"], self.data["longitude"], aamsl)
|
||||||
obj_ath = angle_to_horizon(coords)
|
obj_ath = angle_to_horizon(coords)
|
||||||
obj_rh = radio_horizon(self.data["altitude"])
|
obj_rh = radio_horizon(aamsl)
|
||||||
|
|
||||||
rendered = {
|
rendered = {
|
||||||
"icon": "map-marker",
|
"icon": "map-marker",
|
||||||
@ -797,7 +807,7 @@ class Location(Sensor):
|
|||||||
"values": {
|
"values": {
|
||||||
"latitude": self.data["latitude"],
|
"latitude": self.data["latitude"],
|
||||||
"longitude": self.data["longitude"],
|
"longitude": self.data["longitude"],
|
||||||
"altitude": self.data["altitude"],
|
"altitude": aamsl,
|
||||||
"speed": self.data["speed"],
|
"speed": self.data["speed"],
|
||||||
"heading": self.data["bearing"],
|
"heading": self.data["bearing"],
|
||||||
"accuracy": self.data["accuracy"],
|
"accuracy": self.data["accuracy"],
|
||||||
@ -809,13 +819,13 @@ class Location(Sensor):
|
|||||||
|
|
||||||
if relative_to != None and "location" in relative_to.sensors:
|
if relative_to != None and "location" in relative_to.sensors:
|
||||||
slat = self.data["latitude"]; slon = self.data["longitude"]
|
slat = self.data["latitude"]; slon = self.data["longitude"]
|
||||||
salt = self.data["altitude"];
|
salt = aamsl
|
||||||
if salt == None: salt = 0
|
if salt == None: salt = 0
|
||||||
if slat != None and slon != None:
|
if slat != None and slon != None:
|
||||||
s = relative_to.sensors["location"]
|
s = relative_to.sensors["location"]
|
||||||
d = s.data
|
d = s.data
|
||||||
if d != None and "latitude" in d and "longitude" in d and "altitude" in d:
|
if d != None and "latitude" in d and "longitude" in d and "altitude" in d:
|
||||||
lat = d["latitude"]; lon = d["longitude"]; alt = d["altitude"]
|
lat = d["latitude"]; lon = d["longitude"]; alt = altitude_to_aamsl(d["altitude"], lat, lon)
|
||||||
if lat != None and lon != None:
|
if lat != None and lon != None:
|
||||||
if alt == None: alt = 0
|
if alt == None: alt = 0
|
||||||
cs = (slat, slon, salt); cr = (lat, lon, alt)
|
cs = (slat, slon, salt); cr = (lat, lon, alt)
|
||||||
@ -832,7 +842,7 @@ class Location(Sensor):
|
|||||||
above_horizon = False
|
above_horizon = False
|
||||||
|
|
||||||
srh = shared_radio_horizon(cs, cr)
|
srh = shared_radio_horizon(cs, cr)
|
||||||
if self.data["altitude"] != None and d["altitude"] != None:
|
if salt != None and alt != None:
|
||||||
dalt = salt-alt
|
dalt = salt-alt
|
||||||
else:
|
else:
|
||||||
dalt = None
|
dalt = None
|
||||||
@ -1311,3 +1321,175 @@ class Proximity(Sensor):
|
|||||||
return packed
|
return packed
|
||||||
except:
|
except:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
class PowerConsumption(Sensor):
|
||||||
|
SID = Sensor.SID_POWER_CONSUMPTION
|
||||||
|
STALE_TIME = 5
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(type(self).SID, type(self).STALE_TIME)
|
||||||
|
|
||||||
|
def setup_sensor(self):
|
||||||
|
self.update_data()
|
||||||
|
|
||||||
|
def teardown_sensor(self):
|
||||||
|
self.data = None
|
||||||
|
|
||||||
|
def update_consumer(self, power, type_label=None):
|
||||||
|
if type_label == None:
|
||||||
|
type_label = 0x00
|
||||||
|
elif type(type_label) != str:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.data == None:
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
self.data[type_label] = power
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_consumer(self, type_label=None):
|
||||||
|
if type_label == None:
|
||||||
|
type_label = 0x00
|
||||||
|
|
||||||
|
if type_label in self.data:
|
||||||
|
self.data.pop(type_label)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_data(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pack(self):
|
||||||
|
d = self.data
|
||||||
|
if d == None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
packed = []
|
||||||
|
for type_label in self.data:
|
||||||
|
packed.append([type_label, self.data[type_label]])
|
||||||
|
return packed
|
||||||
|
|
||||||
|
def unpack(self, packed):
|
||||||
|
try:
|
||||||
|
if packed == None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
unpacked = {}
|
||||||
|
for entry in packed:
|
||||||
|
unpacked[entry[0]] = entry[1]
|
||||||
|
return unpacked
|
||||||
|
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def render(self, relative_to=None):
|
||||||
|
if self.data == None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
consumers = []
|
||||||
|
for type_label in self.data:
|
||||||
|
if type_label == 0x00:
|
||||||
|
label = "Power consumption"
|
||||||
|
else:
|
||||||
|
label = type_label
|
||||||
|
consumers.append({"label": label, "w": self.data[type_label]})
|
||||||
|
|
||||||
|
rendered = {
|
||||||
|
"icon": "power-plug-outline",
|
||||||
|
"name": "Power Consumption",
|
||||||
|
"values": consumers,
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
class PowerProduction(Sensor):
|
||||||
|
SID = Sensor.SID_POWER_PRODUCTION
|
||||||
|
STALE_TIME = 5
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(type(self).SID, type(self).STALE_TIME)
|
||||||
|
|
||||||
|
def setup_sensor(self):
|
||||||
|
self.update_data()
|
||||||
|
|
||||||
|
def teardown_sensor(self):
|
||||||
|
self.data = None
|
||||||
|
|
||||||
|
def update_producer(self, power, type_label=None):
|
||||||
|
if type_label == None:
|
||||||
|
type_label = 0x00
|
||||||
|
elif type(type_label) != str:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.data == None:
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
self.data[type_label] = power
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_producer(self, type_label=None):
|
||||||
|
if type_label == None:
|
||||||
|
type_label = 0x00
|
||||||
|
|
||||||
|
if type_label in self.data:
|
||||||
|
self.data.pop(type_label)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_data(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pack(self):
|
||||||
|
d = self.data
|
||||||
|
if d == None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
packed = []
|
||||||
|
for type_label in self.data:
|
||||||
|
packed.append([type_label, self.data[type_label]])
|
||||||
|
return packed
|
||||||
|
|
||||||
|
def unpack(self, packed):
|
||||||
|
try:
|
||||||
|
if packed == None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
unpacked = {}
|
||||||
|
for entry in packed:
|
||||||
|
unpacked[entry[0]] = entry[1]
|
||||||
|
return unpacked
|
||||||
|
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def render(self, relative_to=None):
|
||||||
|
if self.data == None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
producers = []
|
||||||
|
for type_label in self.data:
|
||||||
|
if type_label == 0x00:
|
||||||
|
label = "Power Production"
|
||||||
|
else:
|
||||||
|
label = type_label
|
||||||
|
producers.append({"label": label, "w": self.data[type_label]})
|
||||||
|
|
||||||
|
rendered = {
|
||||||
|
"icon": "lightning-bolt",
|
||||||
|
"name": "Power Production",
|
||||||
|
"values": producers,
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
# TODO: Implement
|
||||||
|
class Processor(Sensor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class RandomAccessMemory(Sensor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class NonVolatileMemory(Sensor):
|
||||||
|
pass
|
@ -133,6 +133,15 @@ MDNavigationLayout:
|
|||||||
on_release: root.ids.screen_manager.app.keys_action(self)
|
on_release: root.ids.screen_manager.app.keys_action(self)
|
||||||
|
|
||||||
|
|
||||||
|
OneLineIconListItem:
|
||||||
|
text: "Plugins"
|
||||||
|
on_release: root.ids.screen_manager.app.plugins_action(self)
|
||||||
|
|
||||||
|
IconLeftWidget:
|
||||||
|
icon: "google-circles-extended"
|
||||||
|
on_release: root.ids.screen_manager.app.keys_action(self)
|
||||||
|
|
||||||
|
|
||||||
OneLineIconListItem:
|
OneLineIconListItem:
|
||||||
text: "Guide"
|
text: "Guide"
|
||||||
on_release: root.ids.screen_manager.app.guide_action(self)
|
on_release: root.ids.screen_manager.app.guide_action(self)
|
||||||
@ -1125,6 +1134,84 @@ MDScreen:
|
|||||||
on_release: root.app.identity_restore_action(self)
|
on_release: root.app.identity_restore_action(self)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
layout_plugins_screen = """
|
||||||
|
MDScreen:
|
||||||
|
name: "plugins_screen"
|
||||||
|
|
||||||
|
BoxLayout:
|
||||||
|
orientation: "vertical"
|
||||||
|
|
||||||
|
MDTopAppBar:
|
||||||
|
title: "Plugins & Services"
|
||||||
|
anchor_title: "left"
|
||||||
|
elevation: 0
|
||||||
|
left_action_items:
|
||||||
|
[['menu', lambda x: root.app.nav_drawer.set_state("open")]]
|
||||||
|
right_action_items:
|
||||||
|
[
|
||||||
|
['close', lambda x: root.app.close_plugins_action(self)],
|
||||||
|
]
|
||||||
|
|
||||||
|
ScrollView:
|
||||||
|
id:plugins_scrollview
|
||||||
|
|
||||||
|
MDBoxLayout:
|
||||||
|
orientation: "vertical"
|
||||||
|
spacing: "24dp"
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
padding: [dp(35), dp(35), dp(35), dp(35)]
|
||||||
|
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
id: plugins_info
|
||||||
|
markup: True
|
||||||
|
text: ""
|
||||||
|
size_hint_y: None
|
||||||
|
text_size: self.width, None
|
||||||
|
height: self.texture_size[1]
|
||||||
|
|
||||||
|
MDBoxLayout:
|
||||||
|
orientation: "horizontal"
|
||||||
|
size_hint_y: None
|
||||||
|
padding: [0,0,dp(26),dp(0)]
|
||||||
|
height: dp(24)
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: "Enable Plugins"
|
||||||
|
font_style: "H6"
|
||||||
|
|
||||||
|
MDSwitch:
|
||||||
|
id: settings_service_plugins_enabled
|
||||||
|
pos_hint: {"center_y": 0.3}
|
||||||
|
active: False
|
||||||
|
|
||||||
|
MDBoxLayout:
|
||||||
|
orientation: "horizontal"
|
||||||
|
size_hint_y: None
|
||||||
|
padding: [0,0,dp(26),dp(0)]
|
||||||
|
height: dp(24)
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: "Enable Command Plugins"
|
||||||
|
font_style: "H6"
|
||||||
|
|
||||||
|
MDSwitch:
|
||||||
|
id: settings_command_plugins_enabled
|
||||||
|
pos_hint: {"center_y": 0.3}
|
||||||
|
active: False
|
||||||
|
|
||||||
|
MDRectangleFlatIconButton:
|
||||||
|
id: plugins_display
|
||||||
|
icon: "folder-cog-outline"
|
||||||
|
text: "Select Plugins Directory"
|
||||||
|
padding: [dp(0), dp(14), dp(0), dp(14)]
|
||||||
|
icon_size: dp(24)
|
||||||
|
font_size: dp(16)
|
||||||
|
size_hint: [1.0, None]
|
||||||
|
on_release: root.app.plugins_select_directory_action(self)
|
||||||
|
"""
|
||||||
|
|
||||||
layout_settings_screen = """
|
layout_settings_screen = """
|
||||||
MDScreen:
|
MDScreen:
|
||||||
name: "settings_screen"
|
name: "settings_screen"
|
||||||
|
@ -254,6 +254,9 @@ class Messages():
|
|||||||
extra_content = "[font=RobotoMono-Regular]> ping[/font]\n"
|
extra_content = "[font=RobotoMono-Regular]> ping[/font]\n"
|
||||||
if Commands.SIGNAL_REPORT in command:
|
if Commands.SIGNAL_REPORT in command:
|
||||||
extra_content = "[font=RobotoMono-Regular]> sig[/font]\n"
|
extra_content = "[font=RobotoMono-Regular]> sig[/font]\n"
|
||||||
|
if Commands.PLUGIN_COMMAND in command:
|
||||||
|
cmd_content = command[Commands.PLUGIN_COMMAND]
|
||||||
|
extra_content = "[font=RobotoMono-Regular]> "+str(cmd_content)+"[/font]\n"
|
||||||
extra_content = extra_content[:-1]
|
extra_content = extra_content[:-1]
|
||||||
force_markup = True
|
force_markup = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -408,7 +411,7 @@ class Messages():
|
|||||||
|
|
||||||
def check_textures(w, val):
|
def check_textures(w, val):
|
||||||
try:
|
try:
|
||||||
if w.texture_size[1] > 360 and w.texture_size[1] >= self.max_texture_size:
|
if w.texture_size[0] > 360 and w.texture_size[1] >= self.max_texture_size:
|
||||||
w.text = "[i]The content of this message is too large to display in the message stream. You can copy the message content into another program by using the context menu of this message, and selecting [b]Copy[/b].[/i]"
|
w.text = "[i]The content of this message is too large to display in the message stream. You can copy the message content into another program by using the context menu of this message, and selecting [b]Copy[/b].[/i]"
|
||||||
|
|
||||||
if w.owner.has_image:
|
if w.owner.has_image:
|
||||||
|
@ -415,6 +415,78 @@ class RVDetails(MDRecycleView):
|
|||||||
if q != None or rssi != None: snr_str = ", "+snr_str
|
if q != None or rssi != None: snr_str = ", "+snr_str
|
||||||
if q_str or rssi_str or snr_str:
|
if q_str or rssi_str or snr_str:
|
||||||
formatted_values = q_str+rssi_str+snr_str
|
formatted_values = q_str+rssi_str+snr_str
|
||||||
|
elif name == "Power Consumption":
|
||||||
|
cs = s["values"]
|
||||||
|
if cs != None:
|
||||||
|
for c in cs:
|
||||||
|
label = c["label"]
|
||||||
|
watts = c["w"]
|
||||||
|
prefix = ""
|
||||||
|
if watts < 1/1e6:
|
||||||
|
watts *= 1e9
|
||||||
|
prefix = "n"
|
||||||
|
elif watts < 1/1e3:
|
||||||
|
watts *= 1e6
|
||||||
|
prefix = "µ"
|
||||||
|
elif watts < 1:
|
||||||
|
watts *= 1e3
|
||||||
|
prefix = "m"
|
||||||
|
elif watts >= 1e15:
|
||||||
|
watts /= 1e15
|
||||||
|
prefix = "E"
|
||||||
|
elif watts >= 1e12:
|
||||||
|
watts /= 1e12
|
||||||
|
prefix = "T"
|
||||||
|
elif watts >= 1e9:
|
||||||
|
watts /= 1e9
|
||||||
|
prefix = "G"
|
||||||
|
elif watts >= 1e6:
|
||||||
|
watts /= 1e6
|
||||||
|
prefix = "M"
|
||||||
|
elif watts >= 1e3:
|
||||||
|
watts /= 1e3
|
||||||
|
prefix = "K"
|
||||||
|
|
||||||
|
watts = round(watts, 2)
|
||||||
|
p_text = f"{label} [b]{watts} {prefix}W[/b]"
|
||||||
|
extra_entries.append({"icon": s["icon"], "text": p_text})
|
||||||
|
|
||||||
|
elif name == "Power Production":
|
||||||
|
cs = s["values"]
|
||||||
|
if cs != None:
|
||||||
|
for c in cs:
|
||||||
|
label = c["label"]
|
||||||
|
watts = c["w"]
|
||||||
|
prefix = ""
|
||||||
|
if watts < 1/1e6:
|
||||||
|
watts *= 1e9
|
||||||
|
prefix = "n"
|
||||||
|
elif watts < 1/1e3:
|
||||||
|
watts *= 1e6
|
||||||
|
prefix = "µ"
|
||||||
|
elif watts < 1:
|
||||||
|
watts *= 1e3
|
||||||
|
prefix = "m"
|
||||||
|
elif watts >= 1e15:
|
||||||
|
watts /= 1e15
|
||||||
|
prefix = "E"
|
||||||
|
elif watts >= 1e12:
|
||||||
|
watts /= 1e12
|
||||||
|
prefix = "T"
|
||||||
|
elif watts >= 1e9:
|
||||||
|
watts /= 1e9
|
||||||
|
prefix = "G"
|
||||||
|
elif watts >= 1e6:
|
||||||
|
watts /= 1e6
|
||||||
|
prefix = "M"
|
||||||
|
elif watts >= 1e3:
|
||||||
|
watts /= 1e3
|
||||||
|
prefix = "K"
|
||||||
|
|
||||||
|
watts = round(watts, 2)
|
||||||
|
p_text = f"{label} [b]{watts} {prefix}W[/b]"
|
||||||
|
extra_entries.append({"icon": s["icon"], "text": p_text})
|
||||||
|
|
||||||
elif name == "Location":
|
elif name == "Location":
|
||||||
lat = s["values"]["latitude"]
|
lat = s["values"]["latitude"]
|
||||||
lon = s["values"]["longitude"]
|
lon = s["values"]["longitude"]
|
||||||
|
3
setup.py
3
setup.py
@ -51,6 +51,7 @@ package_data = {
|
|||||||
"": [
|
"": [
|
||||||
"assets/*",
|
"assets/*",
|
||||||
"assets/fonts/*",
|
"assets/fonts/*",
|
||||||
|
"assets/geoids/*",
|
||||||
"kivymd/fonts/*",
|
"kivymd/fonts/*",
|
||||||
"kivymd/images/*",
|
"kivymd/images/*",
|
||||||
"kivymd/*",
|
"kivymd/*",
|
||||||
@ -83,7 +84,7 @@ setuptools.setup(
|
|||||||
'sideband=sbapp:main.run',
|
'sideband=sbapp:main.run',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
install_requires=["rns>=0.7.3", "lxmf>=0.4.2", "kivy>=2.3.0", "plyer", "pillow>=10.2.0", "qrcode", "materialyoucolor>=2.0.7"],
|
install_requires=["rns>=0.7.3", "lxmf>=0.4.3", "kivy>=2.3.0", "plyer", "pillow>=10.2.0", "qrcode", "materialyoucolor>=2.0.7"],
|
||||||
extras_require={
|
extras_require={
|
||||||
"macos": ["pyobjus"],
|
"macos": ["pyobjus"],
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user