Merge branch 'markqvist:main' into main

This commit is contained in:
faragher 2024-03-27 21:04:58 -05:00 committed by GitHub
commit ab9daceeee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 39719 additions and 109 deletions

View File

@ -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].

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
<PAMDataset>
<SRS>GEOGCS[&quot;WGS 84&quot;,DATUM[&quot;WGS_1984&quot;,SPHEROID[&quot;WGS 84&quot;,6378137,298.257223563,AUTHORITY[&quot;EPSG&quot;,&quot;7030&quot;]],AUTHORITY[&quot;EPSG&quot;,&quot;6326&quot;]],PRIMEM[&quot;Greenwich&quot;,0],UNIT[&quot;degree&quot;,0.017453292519943295769],AUTHORITY[&quot;EPSG&quot;,&quot;4326&quot;]]</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>

View File

@ -0,0 +1,6 @@
0.08333333333333333
0
0
-0.08333333333333333
0
90

View File

@ -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

View File

@ -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()
toast("No file access, check permissions!") if RNS.vendor.platformutils.get_platform() == "android":
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()
toast("Using \""+os.path.basename(path)+"\" as offline map")
if RNS.vendor.platformutils.is_android():
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()
toast("Error reading directory, check permissions!") 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: else:
self.sideband.config["map_storage_path"] = None self.sideband.config["map_storage_path"] = None
self.sideband.save_configuration() self.sideband.save_configuration()
toast("No file access, check permissions!") 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()
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)

View File

@ -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,23 +770,24 @@ 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 self.config["notifications_on"]: if not self.is_daemon:
if RNS.vendor.platformutils.get_platform() == "android": if self.config["notifications_on"]:
if self.getpersistent("permissions.notifications"):
notifications_permitted = True
else:
notifications_permitted = False
else:
notifications_permitted = True
if notifications_permitted:
if RNS.vendor.platformutils.get_platform() == "android": if RNS.vendor.platformutils.get_platform() == "android":
if self.is_service: if self.getpersistent("permissions.notifications"):
self.owner_service.android_notification(title, content, group=group, context_id=context_id) notifications_permitted = True
else: else:
plyer.notification.notify(title, content, notification_icon=self.notification_icon, context_override=None) notifications_permitted = False
else: else:
plyer.notification.notify(title, content, app_icon=self.icon_32) notifications_permitted = True
if notifications_permitted:
if RNS.vendor.platformutils.get_platform() == "android":
if self.is_service:
self.owner_service.android_notification(title, content, group=group, context_id=context_id)
else:
plyer.notification.notify(title, content, notification_icon=self.notification_icon, context_override=None)
else:
plyer.notification.notify(title, content, app_icon=self.icon_32)
def log_announce(self, dest, app_data, dest_type): def log_announce(self, dest, app_data, dest_type):
try: try:
@ -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)

View File

@ -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
View 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

View File

@ -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 = {}
@ -179,22 +170,27 @@ class Telemeter():
class Sensor(): class Sensor():
SID_NONE = 0x00 SID_NONE = 0x00
SID_TIME = 0x01 SID_TIME = 0x01
SID_LOCATION = 0x02 SID_LOCATION = 0x02
SID_PRESSURE = 0x03 SID_PRESSURE = 0x03
SID_BATTERY = 0x04 SID_BATTERY = 0x04
SID_PHYSICAL_LINK = 0x05 SID_PHYSICAL_LINK = 0x05
SID_ACCELERATION = 0x06 SID_ACCELERATION = 0x06
SID_TEMPERATURE = 0x07 SID_TEMPERATURE = 0x07
SID_HUMIDITY = 0x08 SID_HUMIDITY = 0x08
SID_MAGNETIC_FIELD = 0x09 SID_MAGNETIC_FIELD = 0x09
SID_AMBIENT_LIGHT = 0x0A SID_AMBIENT_LIGHT = 0x0A
SID_GRAVITY = 0x0B SID_GRAVITY = 0x0B
SID_ANGULAR_VELOCITY = 0x0C SID_ANGULAR_VELOCITY = 0x0C
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,16 +680,27 @@ 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 self._last_update == None: if not self._synthesized_updates:
self._last_update = now if self._last_update == None:
elif now > self._last_update + self._stale_time: self._last_update = now
self._last_update = now elif now > self._last_update + self._stale_time:
self._last_update = now
if self.altitude == None: self.altitude = 0.0 if self.altitude == None: self.altitude = 0.0
if self.accuracy == None: self.accuracy = 0.01 if self.accuracy == None: self.accuracy = 0.01
@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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"]

View File

@ -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"],
}, },