forked from iarv/contact
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b2a3abee | ||
|
|
a26804b8b6 | ||
|
|
b225d5fe51 | ||
|
|
ea33b78af0 | ||
|
|
c7f3f47ac2 | ||
|
|
8d41a1e060 | ||
|
|
c6d760650f | ||
|
|
3f12eca2ad | ||
|
|
12bc87dd46 | ||
|
|
bd4469f708 | ||
|
|
b9a1c9d9a7 | ||
|
|
18d743c599 | ||
|
|
c156211df8 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,4 +8,5 @@ client.log
|
||||
settings.log
|
||||
config.json
|
||||
default_config.log
|
||||
dist/
|
||||
dist/
|
||||
.vscode/launch.json
|
||||
|
||||
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@@ -1,13 +1,22 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"name": "Python Debugger: main",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": []
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: tcp",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": ["--host","192.168.86.69"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ def main(stdscr: curses.window) -> None:
|
||||
initialize_globals()
|
||||
logging.info("Starting main UI")
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
main_ui(stdscr)
|
||||
|
||||
@@ -14,13 +14,13 @@ id, "", ""
|
||||
uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT server configured on this node."
|
||||
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
|
||||
module_settings, "Module settings", "Position precision and Client Mute."
|
||||
position_precision, "Position precision", "The precision level of location data sent on this channel."
|
||||
is_client_muted, "", ""
|
||||
module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel."
|
||||
module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable."
|
||||
|
||||
[config.device]
|
||||
title, "Device"
|
||||
role, "Role", "For the vast majority of users, the correct choice is CLIENT. See Meshtastic docs for more information."
|
||||
serial_enabled, "Enable serial console", ""
|
||||
serial_enabled, "Enable serial console", "Serial Console over the Stream API."
|
||||
button_gpio, "Button GPIO", "GPIO pin for user button."
|
||||
buzzer_gpio, "Buzzer GPIO", "GPIO pin for user buzzer."
|
||||
rebroadcast_mode, "Rebroadcast mode", "This setting defines the device's behavior for how messages are rebroadcast."
|
||||
@@ -30,6 +30,7 @@ is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps
|
||||
disable_triple_click, "Disable triple button press", ""
|
||||
tzdef, "Timezone", "Uses the TZ Database format to display the correct local time on the device display and in its logs."
|
||||
led_heartbeat_disabled, "Disable LED heartbeat", "On certain hardware models, this disables the blinking heartbeat LED."
|
||||
buzzer_mode, "Buzzer Mode", "Controls buzzer behavior for audio feedback."
|
||||
|
||||
[config.position]
|
||||
title, "Position"
|
||||
@@ -77,6 +78,7 @@ subnet, "IPv4 subnet", ""
|
||||
dns, "IPv4 DNS server", ""
|
||||
rsyslog_server, "RSyslog server", ""
|
||||
enabled_protocols, "Enabled protocols", ""
|
||||
ipv6_enabled, "IPv6 enabled", "Enables or Disables IPv6 networking."
|
||||
|
||||
[config.network.ipv4_config]
|
||||
title, "IPv4 Config", ""
|
||||
@@ -158,7 +160,7 @@ channel_num, "Frequency slot", "Determines the exact frequency the radio transmi
|
||||
override_duty_cycle, "Override duty cycle", "Override the legal transmit time limit to allow unlimited transmit time. [warning]May have legal ramifications.[/warning]"
|
||||
sx126x_rx_boosted_gain, "Enable SX126X RX boosted gain", "This is an option specific to the SX126x chip series which allows the chip to consume a small amount of additional power to increase RX (receive) sensitivity."
|
||||
override_frequency, "Override frequency in MHz", "Overrides frequency slot. May have legal ramifications."
|
||||
pa_fan_disabled, "", ""
|
||||
pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN"
|
||||
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
|
||||
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
|
||||
|
||||
@@ -190,8 +192,10 @@ tls_enabled, "TLS enabled", "If true, we attempt to establish a secure connectio
|
||||
root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs."
|
||||
proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings."
|
||||
map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet."
|
||||
map_report_settings, "Map report settings", "Settings for the map report module."
|
||||
map_report_settings.publish_interval_secs, "Map report publish interval", "How often we should publish the map report to the MQTT server in seconds. Defaults to 900 seconds (15 minutes)."
|
||||
map_report_settings.position_precision, "Map report position precision", "The precision to use for the position in the map report. Defaults to a maximum deviation of around 1459m."
|
||||
map_report_settings.should_report_location, "Should report location", "Whether we have opted-in to report our location to the map."
|
||||
|
||||
[module.serial]
|
||||
title, "Serial"
|
||||
@@ -208,9 +212,9 @@ override_console_serial_port, "Override console serial port", "If set to true, t
|
||||
title, "External Notification"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
|
||||
output, "", ""
|
||||
output_vibra, "", ""
|
||||
output_buzzer, "", ""
|
||||
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
|
||||
output_vibra, "Vibra GPIO", "Optional: Define a secondary output pin for a vibra motor. This is used in standalone devices to match the UI."
|
||||
output_buzzer, "Buzzer GPIO", "Optional: Define a tertiary output pin for an active buzze. This is used in standalone devices to to match the UI."
|
||||
active, "Active (general / LED only)", "Specifies whether the external circuit is active when the device's GPIO is low or high. If this is set true, the pin will be pulled active high, false means active low."
|
||||
alert_message, "Alert when receiving a message (general)", "Specifies if an alert should be triggered when receiving an incoming message."
|
||||
alert_message_vibra, "Alert vibration on message", "Specifies if a vibration alert should be triggered when receiving an incoming message."
|
||||
@@ -280,7 +284,8 @@ i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface."
|
||||
[module.remote_hardware]
|
||||
title, "Remote Hardware"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
allow_undefined_pin_access, "Allow undefined pin access", ""
|
||||
allow_undefined_pin_access, "Allow undefined pin access", "Whether the Module allows consumers to read / write to pins not defined in available_pins"
|
||||
available_pins, "Available pins", "Exposes the available pins to the mesh for reading and writing."
|
||||
|
||||
[module.neighbor_info]
|
||||
title, "Neighbor Info"
|
||||
@@ -311,5 +316,5 @@ use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. O
|
||||
title, "Paxcounter"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
|
||||
Wi-Fi_threshold, "", ""
|
||||
ble_threshold, "", ""
|
||||
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80"
|
||||
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
|
||||
@@ -7,6 +7,7 @@ from typing import Union
|
||||
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
|
||||
from contact.settings import settings_menu
|
||||
from contact.message_handlers.tx_handler import send_message, send_traceroute
|
||||
from contact.utilities.utils import parse_protobuf
|
||||
from contact.ui.colors import get_color
|
||||
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
@@ -15,8 +16,9 @@ import contact.ui.dialog
|
||||
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
|
||||
from contact.utilities.singleton import ui_state, interface_state, menu_state
|
||||
|
||||
|
||||
MIN_COL = 1 # "effectively zero" without breaking curses
|
||||
root_win = None # set in main_ui
|
||||
root_win = None
|
||||
|
||||
|
||||
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
||||
@@ -832,14 +834,14 @@ def draw_packetlog_win() -> None:
|
||||
else get_name_from_database(packet["to"], "short").ljust(columns[1])
|
||||
)
|
||||
if "decoded" in packet:
|
||||
port = packet["decoded"]["portnum"].ljust(columns[2])
|
||||
payload = (packet["decoded"]["payload"]).ljust(columns[3])
|
||||
port = str(packet["decoded"].get("portnum", "")).ljust(columns[2])
|
||||
parsed_payload = parse_protobuf(packet)
|
||||
else:
|
||||
port = "NO KEY".ljust(columns[2])
|
||||
payload = "NO KEY".ljust(columns[3])
|
||||
parsed_payload = "NO KEY"
|
||||
|
||||
# Combine and truncate if necessary
|
||||
logString = f"{from_id} {to_id} {port} {payload}"
|
||||
logString = f"{from_id} {to_id} {port} {parsed_payload}"
|
||||
logString = logString[: width - 3]
|
||||
|
||||
# Add to the window
|
||||
|
||||
@@ -183,6 +183,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
menu_state.need_redraw = True
|
||||
menu_state.show_save_option = False
|
||||
new_value_name = None
|
||||
|
||||
while True:
|
||||
if menu_state.need_redraw:
|
||||
@@ -515,8 +516,16 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
for key in menu_state.menu_path[3:]: # Skip "Main Menu"
|
||||
modified_settings = modified_settings.setdefault(key, {})
|
||||
|
||||
# Add the new value to the appropriate level
|
||||
modified_settings[selected_option] = new_value
|
||||
# For comparison, normalize enum numbers to names
|
||||
compare_value = new_value
|
||||
if field and field.enum_type and isinstance(new_value, int):
|
||||
enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
|
||||
if enum_value_descriptor:
|
||||
compare_value = enum_value_descriptor.name
|
||||
|
||||
if compare_value != current_value:
|
||||
# Save the raw protobuf number, not the name
|
||||
modified_settings[selected_option] = new_value
|
||||
|
||||
# Convert enum string to int
|
||||
if field and field.enum_type:
|
||||
|
||||
@@ -22,6 +22,7 @@ def draw_splash(stdscr: object) -> None:
|
||||
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
|
||||
stdscr.move(start_y + 5, start_x2)
|
||||
|
||||
stdscr.attrset(get_color("window_frame"))
|
||||
stdscr.box()
|
||||
|
||||
@@ -4,6 +4,12 @@ import curses
|
||||
import ipaddress
|
||||
from typing import Any, Optional, List
|
||||
|
||||
from contact.ui.colors import get_color
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.validation_rules import get_validation_for
|
||||
from contact.utilities.singleton import menu_state
|
||||
|
||||
# Dialogs should be at most 80 cols, but shrink on small terminals
|
||||
MAX_DIALOG_WIDTH = 80
|
||||
MIN_DIALOG_WIDTH = 20
|
||||
@@ -18,13 +24,6 @@ def get_dialog_width() -> int:
|
||||
return MAX_DIALOG_WIDTH
|
||||
|
||||
|
||||
from contact.ui.colors import get_color
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.validation_rules import get_validation_for
|
||||
from contact.utilities.singleton import menu_state
|
||||
|
||||
|
||||
def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None:
|
||||
"""Displays an invalid input message in the given window and redraws if needed."""
|
||||
cursor_y, cursor_x = window.getyx()
|
||||
|
||||
90
contact/utilities/telemetry_beautifier.py
Normal file
90
contact/utilities/telemetry_beautifier.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import datetime
|
||||
|
||||
sensors = {
|
||||
'temperature': {'icon':'🌡️ ','unit':'°'},
|
||||
'relative_humidity': {'icon':'💧','unit':'%'},
|
||||
'barometric_pressure': {'icon':'⮇ ','unit': 'hPa'},
|
||||
'lux': {'icon':'🔦 ','unit': 'lx'},
|
||||
'uv_lux': {'icon':'uv🔦 ','unit': 'lx'},
|
||||
'wind_speed': {'icon':'💨 ','unit': 'm/s'},
|
||||
'wind_direction': {'icon':'⮆ ','unit': ''},
|
||||
'battery_level': {'icon':'🔋 ', 'unit':'%'},
|
||||
'voltage': {'icon':'', 'unit':'V'},
|
||||
'channel_utilization': {'icon':'ChUtil:', 'unit':'%'},
|
||||
'air_util_tx': {'icon':'AirUtil:', 'unit':'%'},
|
||||
'uptime_seconds': {'icon':'🆙 ', 'unit':'h'},
|
||||
'latitude_i': {'icon':'🌍 ', 'unit':''},
|
||||
'longitude_i': {'icon':'', 'unit':''},
|
||||
'altitude': {'icon':'⬆️ ', 'unit':'m'},
|
||||
'time': {'icon':'🕔 ', 'unit':''}
|
||||
}
|
||||
|
||||
def humanize_wind_direction(degrees):
|
||||
""" Convert degrees to Eest-West-Nnoth-Ssouth directions """
|
||||
if not 0 <= degrees <= 360:
|
||||
return None
|
||||
|
||||
directions = [
|
||||
("N", 337.5, 22.5),
|
||||
("NE", 22.5, 67.5),
|
||||
("E", 67.5, 112.5),
|
||||
("SE", 112.5, 157.5),
|
||||
("S", 157.5, 202.5),
|
||||
("SW", 202.5, 247.5),
|
||||
("W", 247.5, 292.5),
|
||||
("NW", 292.5, 337.5),
|
||||
]
|
||||
|
||||
if degrees >= directions[0][1] or degrees < directions[0][2]:
|
||||
return directions[0][0]
|
||||
|
||||
# Check for all other directions
|
||||
for direction, lower_bound, upper_bound in directions[1:]:
|
||||
if lower_bound <= degrees < upper_bound:
|
||||
return direction
|
||||
|
||||
# This part should ideally not be reached with valid input
|
||||
return None
|
||||
|
||||
def get_chunks(data):
|
||||
""" Breakdown telemetry data and assign emojis for more visual appeal of the payloads """
|
||||
reading = data.split('\n')
|
||||
|
||||
# remove empty list lefover from the split
|
||||
reading = list(filter(None, reading))
|
||||
parsed=""
|
||||
|
||||
for item in reading:
|
||||
key, value = item.split(":")
|
||||
|
||||
# If value is float, round it to the 1 digit after point
|
||||
# else make it int
|
||||
if "." in value:
|
||||
value = round(float(value.strip()),1)
|
||||
else:
|
||||
try:
|
||||
value = int(value.strip())
|
||||
except Exception:
|
||||
# Leave it string as last resort
|
||||
value = value
|
||||
|
||||
match key:
|
||||
# convert seconds to hours, for our sanity
|
||||
case "uptime_seconds":
|
||||
value = round(value / 60 / 60, 1)
|
||||
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
|
||||
# truncate to 6th digit after floating point, which would be still accurate
|
||||
case "longitude_i" | "latitude_i":
|
||||
value = round(value * 1e-7, 6)
|
||||
# Convert wind direction from degrees to abbreviation
|
||||
case "wind_direction":
|
||||
value = humanize_wind_direction(value)
|
||||
case "time":
|
||||
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
|
||||
|
||||
if key in sensors:
|
||||
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "
|
||||
else:
|
||||
# just pass through if we haven't added the particular telemetry key:value to the sensor dict
|
||||
parsed+=f"{key}:{value} "
|
||||
return parsed
|
||||
@@ -1,9 +1,13 @@
|
||||
import datetime
|
||||
import time
|
||||
from meshtastic.protobuf import config_pb2
|
||||
import contact.ui.default_config as config
|
||||
from typing import Optional, Union
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from meshtastic import protocols
|
||||
from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import ui_state, interface_state
|
||||
import contact.utilities.telemetry_beautifier as tb
|
||||
|
||||
|
||||
def get_channels():
|
||||
@@ -136,6 +140,7 @@ def get_time_ago(timestamp):
|
||||
return f"{value} {unit} ago"
|
||||
return "now"
|
||||
|
||||
|
||||
def add_new_message(channel_id, prefix, message):
|
||||
if channel_id not in ui_state.all_messages:
|
||||
ui_state.all_messages[channel_id] = []
|
||||
@@ -162,4 +167,55 @@ def add_new_message(channel_id, prefix, message):
|
||||
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
|
||||
|
||||
# Add the message
|
||||
ui_state.all_messages[channel_id].append((prefix,message))
|
||||
ui_state.all_messages[channel_id].append((prefix, message))
|
||||
|
||||
|
||||
def parse_protobuf(packet: dict) -> Union[str, dict]:
|
||||
"""Attempt to parse a decoded payload using the registered protobuf handler."""
|
||||
try:
|
||||
decoded = packet.get("decoded") or {}
|
||||
portnum = decoded.get("portnum")
|
||||
payload = decoded.get("payload")
|
||||
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
|
||||
# These portnumbers carry information visible elswhere in the app, so we just note them in the logs
|
||||
match portnum:
|
||||
case "TEXT_MESSAGE_APP":
|
||||
return "✉️"
|
||||
case "NODEINFO_APP":
|
||||
return "Name identification payload"
|
||||
case "TRACEROUTE_APP":
|
||||
return "Traceroute payload"
|
||||
case _:
|
||||
pass
|
||||
|
||||
handler = protocols.get(portnums_pb2.PortNum.Value(portnum)) if portnum is not None else None
|
||||
if handler is not None and handler.protobufFactory is not None:
|
||||
try:
|
||||
pb = handler.protobufFactory()
|
||||
pb.ParseFromString(bytes(payload))
|
||||
|
||||
# If we have position payload
|
||||
if portnum == "POSITION_APP":
|
||||
return tb.get_chunks(str(pb))
|
||||
|
||||
# Part of TELEMETRY_APP portnum
|
||||
if hasattr(pb, "device_metrics") and pb.HasField("device_metrics"):
|
||||
return tb.get_chunks(str(pb.device_metrics))
|
||||
|
||||
# Part of TELEMETRY_APP portnum
|
||||
if hasattr(pb, "environment_metrics") and pb.HasField("environment_metrics"):
|
||||
return tb.get_chunks(str(pb.environment_metrics))
|
||||
|
||||
# For other data, without implemented beautification, fallback to just printing the object
|
||||
return str(pb).replace("\n", " ").replace("\r", " ").strip()
|
||||
|
||||
except DecodeError:
|
||||
return payload
|
||||
|
||||
# return payload
|
||||
|
||||
except Exception:
|
||||
return payload
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.4.0"
|
||||
version = "1.4.2"
|
||||
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
||||
authors = [
|
||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||
|
||||
Reference in New Issue
Block a user