diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..4a82287 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,143 @@ +name: release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + +env: + PACKAGE_NAME: "mcontact" + OWNER: "pdxlocations" + +jobs: + details: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.release.outputs.new_version }} + suffix: ${{ steps.release.outputs.suffix }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: actions/checkout@v2 + + - name: Extract tag and Details + id: release + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') + SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "Version is $NEW_VERSION" + echo "Suffix is $SUFFIX" + echo "Tag name is $TAG_NAME" + else + echo "No tag found" + exit 1 + fi + + check_pypi: + needs: details + runs-on: ubuntu-latest + steps: + - name: Fetch information from PyPI + run: | + response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") + latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last") + if [ -z "$latest_previous_version" ]; then + echo "Package not found on PyPI." + latest_previous_version="0.0.0" + fi + echo "Latest version on PyPI: $latest_previous_version" + echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV + + - name: Compare versions and exit if not newer + run: | + NEW_VERSION=${{ needs.details.outputs.new_version }} + LATEST_VERSION=$latest_previous_version + if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then + echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." + exit 1 + else + echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." + fi + + setup_and_build: + needs: [details, check_pypi] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Set project version with Poetry + run: | + poetry version ${{ needs.details.outputs.new_version }} + + - name: Install dependencies + run: poetry install --sync --no-interaction + + - name: Build source and wheel distribution + run: | + poetry build + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ + + pypi_publish: + name: Upload release to PyPI + needs: [setup_and_build, details] + runs-on: ubuntu-latest + environment: + name: release + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github_release: + name: Create GitHub Release + needs: [setup_and_build, details] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + id: create_release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes diff --git a/mtcontact/message_handlers/tx_handler.py b/mtcontact/message_handlers/tx_handler.py new file mode 100644 index 0000000..7fe9ed1 --- /dev/null +++ b/mtcontact/message_handlers/tx_handler.py @@ -0,0 +1,181 @@ +from datetime import datetime +import google.protobuf.json_format +from meshtastic import BROADCAST_NUM +from meshtastic.protobuf import mesh_pb2, portnums_pb2 + +from mcontact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db +import mcontact.ui.default_config as config +import mcontact.globals as globals + +ack_naks = {} + +# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention +# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462 +def onAckNak(packet): + from mcontact.ui.curses_ui import draw_messages_window + request = packet['decoded']['requestId'] + if(request not in ack_naks): + return + + acknak = ack_naks.pop(request) + message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1] + + confirm_string = " " + ack_type = None + if(packet['decoded']['routing']['errorReason'] == "NONE"): + if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK + confirm_string = config.ack_implicit_str + ack_type = "Implicit" + else: + confirm_string = config.ack_str + ack_type = "Ack" + else: + confirm_string = config.nak_str + ack_type = "Nak" + + globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message) + + update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type) + + channel_number = globals.channel_list.index(acknak['channel']) + if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]: + draw_messages_window() + +def on_response_traceroute(packet): + """on response for trace route""" + from mcontact.ui.curses_ui import draw_channel_list, draw_messages_window, add_notification + + refresh_channels = False + refresh_messages = False + + UNK_SNR = -128 # Value representing unknown SNR + + route_discovery = mesh_pb2.RouteDiscovery() + route_discovery.ParseFromString(packet["decoded"]["payload"]) + msg_dict = google.protobuf.json_format.MessageToDict(route_discovery) + + msg_str = "Traceroute to:\n" + + route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response + + # SNR list should have one more entry than the route, as the final destination adds its SNR also + lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"]) + snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1 + if lenTowards > 0: # Loop through hops in route and add SNR if available + for idx, node_num in enumerate(msg_dict["route"]): + route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \ + + " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)" + + # End with origin of response + route_str += " --> " + (get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}") \ + + " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)" + + msg_str += route_str + "\n" # Print the route towards destination + + # Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection) + lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"]) + backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1 + if backValid: + msg_str += "Back:\n" + route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response + + if lenBack > 0: # Loop through hops in routeBack and add SNR if available + for idx, node_num in enumerate(msg_dict["routeBack"]): + route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \ + + " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)" + + # End with destination of response (us) + route_str += " --> " + (get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}") \ + + " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)" + + msg_str += route_str + "\n" # Print the route back to us + + if(packet['from'] not in globals.channel_list): + globals.channel_list.append(packet['from']) + refresh_channels = True + + if(is_chat_archived(packet['from'])): + update_node_info_in_db(packet['from'], chat_archived=False) + + channel_number = globals.channel_list.index(packet['from']) + + if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]: + refresh_messages = True + else: + add_notification(channel_number) + refresh_channels = True + + message_from_string = get_name_from_database(packet['from'], type='short') + ":\n" + + if globals.channel_list[channel_number] not in globals.all_messages: + globals.all_messages[globals.channel_list[channel_number]] = [] + globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string}", msg_str)) + + if refresh_channels: + draw_channel_list() + if refresh_messages: + draw_messages_window(True) + save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str) + + +def send_message(message, destination=BROADCAST_NUM, channel=0): + myid = globals.myNodeNum + send_on_channel = 0 + channel_id = globals.channel_list[channel] + if isinstance(channel_id, int): + send_on_channel = 0 + destination = channel_id + elif isinstance(channel_id, str): + send_on_channel = channel + + sent_message_data = globals.interface.sendText( + text=message, + destinationId=destination, + wantAck=True, + wantResponse=False, + onResponse=onAckNak, + channelIndex=send_on_channel, + ) + + # Add sent message to the messages dictionary + if channel_id not in globals.all_messages: + globals.all_messages[channel_id] = [] + + # Handle timestamp logic + current_timestamp = int(datetime.now().timestamp()) # Get current timestamp + current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00') + + # Retrieve the last timestamp if available + channel_messages = globals.all_messages[channel_id] + if channel_messages: + # Check the last entry for a timestamp + for entry in reversed(channel_messages): + if entry[0].startswith("--"): + last_hour = entry[0].strip("- ").strip() + break + else: + last_hour = None + else: + last_hour = None + + # Add a new timestamp if it's a new hour + if last_hour != current_hour: + globals.all_messages[channel_id].append((f"-- {current_hour} --", "")) + + globals.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message)) + + timestamp = save_message_to_db(channel_id, myid, message) + + ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp} + +def send_traceroute(): + r = mesh_pb2.RouteDiscovery() + globals.interface.sendData( + r, + destinationId=globals.node_list[globals.selected_node], + portNum=portnums_pb2.PortNum.TRACEROUTE_APP, + wantResponse=True, + onResponse=on_response_traceroute, + channelIndex=0, + hopLimit=3, + )