diff --git a/MeshStation.py b/MeshStation.py index 478a502..c667bc8 100644 --- a/MeshStation.py +++ b/MeshStation.py @@ -234,6 +234,33 @@ def _fetch_latest_github_release(timeout_sec: float = 10.0) -> dict | None: print(f"Error fetching latest release: {e}") return None +# --- MeshCore Constants --- +MESHCORE_PRESETS = { + "USA_CANADA": { + "RECOMMENDED": {"freq": 910.525, "sf": 7, "bw": 62.5, "cr": 5, "sync": 0x12, "preamble": 16, "description_key": "meshcore.preset.recommended"}, + "LEGACY_WIDE": {"freq": 915.8, "sf": 10, "bw": 250.0, "cr": 5, "sync": 0x12, "preamble": 16, "description_key": "meshcore.preset.legacy_wide"}, + }, +} + +PAYLOAD_TYPE_REQ = 0x00 +PAYLOAD_TYPE_RESPONSE = 0x01 +PAYLOAD_TYPE_TXT_MSG = 0x02 +PAYLOAD_TYPE_ACK = 0x03 +PAYLOAD_TYPE_ADVERT = 0x04 +PAYLOAD_TYPE_GRP_TXT = 0x05 +PAYLOAD_TYPE_GRP_DATA = 0x06 +PAYLOAD_TYPE_ANON_REQ = 0x07 +PAYLOAD_TYPE_PATH = 0x08 +PAYLOAD_TYPE_TRACE = 0x09 +PAYLOAD_TYPE_MULTIPART = 0x0A +PAYLOAD_TYPE_CONTROL = 0x0B +PAYLOAD_TYPE_RAW_CUSTOM = 0x0F + +ROUTE_TYPE_TRANSPORT_FLOOD = 0x00 +ROUTE_TYPE_FLOOD = 0x01 +ROUTE_TYPE_DIRECT = 0x02 +ROUTE_TYPE_TRANSPORT_DIRECT = 0x03 + # --- Meshtastic Region Definitions --- # Fields: freq_start (MHz), freq_end (MHz), dutycycle, spacing (MHz), power_limit (dBm), wide_lora, name MESHTASTIC_REGIONS = { @@ -924,6 +951,7 @@ def score_regions(key: str, s: dict) -> int: class AppState: def __init__(self): + self.network_mode = "meshtastic" # "meshtastic" | "meshcore" self.connect_mode = None # None | "direct" | "external" self.engine_proc = None self.last_rx_ts = 0.0 @@ -943,6 +971,16 @@ def __init__(self): self.direct_port = "20002" self.direct_key_b64 = "AQ==" + # MeshCore Settings + self.meshcore_region = "USA_CANADA" + self.meshcore_preset = "RECOMMENDED" + self.meshcore_custom_freq = 910.525 + self.meshcore_custom_sf = 7 + self.meshcore_custom_bw = 62.5 + self.meshcore_custom_cr = 5 + self.meshcore_custom_sync_word = 0x12 + self.meshcore_custom_preamble_len = 16 + self.external_ip = "127.0.0.1" self.external_port = "20002" self.external_key_b64 = "AQ==" @@ -1763,6 +1801,24 @@ def load_user_config(): if isinstance(v, list): s.channels_order = [x for x in v if isinstance(x, str)] + v = data.get("network_mode") + if v in ("meshtastic", "meshcore"): + s.network_mode = v + v = data.get("meshcore_region") + if isinstance(v, str): + s.meshcore_region = v + v = data.get("meshcore_preset") + if isinstance(v, str): + s.meshcore_preset = v + for field in ["meshcore_custom_freq", "meshcore_custom_sf", "meshcore_custom_bw", + "meshcore_custom_cr", "meshcore_custom_sync_word", "meshcore_custom_preamble_len"]: + v = data.get(field) + if v is not None: + try: + setattr(s, field, type(getattr(s, field))(v)) + except Exception: + pass + def save_user_config(): try: path = get_config_path() @@ -1789,6 +1845,15 @@ def save_user_config(): "map_zoom": getattr(state, "map_zoom", None), "extra_channels": getattr(state, 'extra_channels', []), "channels_order": getattr(state, 'channels_order', []), + "network_mode": state.network_mode, + "meshcore_region": state.meshcore_region, + "meshcore_preset": state.meshcore_preset, + "meshcore_custom_freq": state.meshcore_custom_freq, + "meshcore_custom_sf": state.meshcore_custom_sf, + "meshcore_custom_bw": state.meshcore_custom_bw, + "meshcore_custom_cr": state.meshcore_custom_cr, + "meshcore_custom_sync_word": state.meshcore_custom_sync_word, + "meshcore_custom_preamble_len": state.meshcore_custom_preamble_len, } with open(path, "w") as f: json.dump(data, f, indent=2) @@ -2156,6 +2221,142 @@ def update_node(node_id, **kwargs): if changed and ('short_name' in kwargs or 'long_name' in kwargs): state.chat_force_refresh = True +def resolveMeshCoreIdentity(node_hash): + # node_hash is 1 byte int + target_hex = f"{node_hash:02x}" + for nid, n in state.nodes.items(): + if nid.startswith(target_hex): + return n.get('long_name', nid), nid + return f"Node {target_hex}", target_hex + +def decodeMeshCore(packet_bytes, snr=None, rssi=None): + if not packet_bytes or len(packet_bytes) < 2: + return None + + header = packet_bytes[0] + # version = (header >> 6) & 0x03 + payload_type = (header >> 2) & 0x0F + route_type = header & 0x03 + + offset = 1 + if route_type in (ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_TRANSPORT_DIRECT): + if len(packet_bytes) < offset + 4: return None + # transport_codes = packet_bytes[offset:offset+4] + offset += 4 + + if len(packet_bytes) < offset + 1: return None + path_len_byte = packet_bytes[offset] + offset += 1 + + hop_count = path_len_byte & 0x3F + hash_size = ((path_len_byte >> 6) & 0x03) + 1 + + path_bytes_len = hop_count * hash_size + if len(packet_bytes) < offset + path_bytes_len: return None + path_data = packet_bytes[offset:offset+path_bytes_len] + offset += path_bytes_len + + payload = packet_bytes[offset:] + + # Process Payloads + if payload_type == PAYLOAD_TYPE_ADVERT: + if len(payload) < 32 + 4 + 64: return None + pubkey = payload[0:32] + # timestamp = int.from_bytes(payload[32:36], 'little') + # signature = payload[36:100] + appdata = payload[100:] + + node_id = pubkey.hex() + short_id = node_id[:2] + + name = "Unknown" + lat = None + lon = None + role_label = translate("meshcore.role.companion", "Companion") + role_internal = "Companion" + relay_capable = False + + if len(appdata) > 0: + flags = appdata[0] + ptr = 1 + if flags & 0x10: # has location + if len(appdata) >= ptr + 8: + lat = int.from_bytes(appdata[ptr:ptr+4], 'little', signed=True) / 1_000_000.0 + lon = int.from_bytes(appdata[ptr+4:ptr+8], 'little', signed=True) / 1_000_000.0 + ptr += 8 + if flags & 0x20: ptr += 2 # feature 1 + if flags & 0x40: ptr += 2 # feature 2 + if flags & 0x80: # has name + name = appdata[ptr:].decode('utf-8', errors='ignore') + + type_bits = flags & 0x07 + if type_bits == 0x02: + role_label = translate("meshcore.role.repeater", "Repeater") + role_internal = "Repeater" + relay_capable = True + elif type_bits == 0x03: + role_label = translate("meshcore.role.room_server", "Room Server") + role_internal = "Room Server" + relay_capable = True + elif type_bits == 0x04: + role_label = translate("meshcore.role.sensor", "Sensor") + role_internal = "Sensor" + + # Store path info if this advert came through repeaters + path_list = [] + if hop_count > 0: + for i in range(hop_count): + h = path_data[i*hash_size : (i+1)*hash_size] + path_list.append(h.hex()) + + update_node(node_id, short_name=short_id, long_name=name, hw_model=role_label, role=role_label, lat=lat, lon=lon, public_key=node_id, role_id=role_internal) + state.nodes[node_id]["relay_capable"] = relay_capable + state.nodes[node_id]["last_path"] = path_list + + log_to_console(f"[MESHCORE] ADVERT from {name} ({short_id}) - Role: {role_internal}") + + elif payload_type == PAYLOAD_TYPE_TXT_MSG: + if len(payload) >= 4: + dest_hash = payload[0] + src_hash = payload[1] + + src_name, src_id = resolveMeshCoreIdentity(src_hash) + dest_name, dest_id = resolveMeshCoreIdentity(dest_hash) + + now_dt = datetime.now() + msg_obj = { + "time": now_dt.strftime("%H:%M"), + "date": now_dt.strftime("%d/%m/%Y"), + "from": src_name, + "from_id": src_id, + "to": dest_id, + "text": translate("meshcore.message.encrypted", "[Encrypted Message]"), + "is_me": False, + } + state.messages.append(msg_obj) + state.new_messages.append(msg_obj) + + log_to_console(f"[MESHCORE] TXT from {src_id} to {dest_id} ({msg_obj['text']})") + + elif payload_type == PAYLOAD_TYPE_GRP_TXT: + if len(payload) >= 1: + # channel_hash = payload[0] + now_dt = datetime.now() + msg_obj = { + "time": now_dt.strftime("%H:%M"), + "date": now_dt.strftime("%d/%m/%Y"), + "from": "Group", + "from_id": "group", + "to": "All", + "text": translate("meshcore.message.group_encrypted", "[Encrypted Group Message]"), + "is_me": False, + } + state.messages.append(msg_obj) + state.new_messages.append(msg_obj) + log_to_console(f"[MESHCORE] GRP_TXT ({msg_obj['text']})") + + return True + def decodeProtobuf(packetData, sourceID, destID, cryptplainprefix, *, count_invalid: bool = True, preset_name: str | None = None, channel_hash: int | None = None, forced_channel_id: str | None = None, packet_id: bytes | None = None): try: data = mesh_pb2.Data() @@ -2520,6 +2721,10 @@ def parse_framed_stream_bytes(rx_buf: bytearray): frame_preset_id = body[preset_id_off] if len(body) > preset_id_off else 0 frame_preset_name = PRESET_ID_MAP.get(frame_preset_id) + if state.network_mode == "meshcore": + decodeMeshCore(payload, snr=snr_val, rssi=rssi_val) + continue + # 1) Extract Meshtastic fields extracted = dataExtractor(payload.hex()) @@ -3113,7 +3318,36 @@ def start_engine_direct(): env["CONDA_PREFIX"] = runtime env["PYTHONNOUSERSITE"] = "1" - region = getattr(state, "direct_region", "EU_868") + if state.network_mode == "meshcore": + # MeshCore direct engine start + cmd = [ + py, "-m", "meshtastic_engine.run_engine", + "--host", "127.0.0.1", + "--port", str(state.port), + "--center-freq", str(int(round(state.meshcore_custom_freq * 1_000_000))), + "--samp-rate", "1000000", + "--lora-bw", str(int(round(state.meshcore_custom_bw * 1000))), + "--sf", str(int(state.meshcore_custom_sf)), + "--gain", str(int(state.direct_gain)), + "--ppm", str(int(state.direct_ppm)), + "--sync-word", str(int(state.meshcore_custom_sync_word)), + "--preamble-len", str(int(state.meshcore_custom_preamble_len)), + ] + + raw_device_args = str(getattr(state, "direct_device_args", "") or "").strip() + if raw_device_args: + cmd.extend(["--device-args", raw_device_args]) + if getattr(state, 'direct_bias_tee', False): + cmd.append("--bias-tee") + + log_to_console(f"[ENGINE][MESHCORE] Radio settings: freq={state.meshcore_custom_freq} MHz, bw={state.meshcore_custom_bw} kHz, sf={state.meshcore_custom_sf}") + + primary_calc = {"center_freq_hz": int(round(state.meshcore_custom_freq * 1_000_000)), "bw_khz": state.meshcore_custom_bw, "sf": state.meshcore_custom_sf} + valid_configs = [] + primary_key = "MESHCORE" + primary_preset_id = 0 + else: + region = getattr(state, "direct_region", "EU_868") preset_name = state.direct_preset freq_slot = getattr(state, "direct_frequency_slot", 0) ch_name = getattr(state, "direct_channel_name", "") or None @@ -3157,27 +3391,29 @@ def start_engine_direct(): else: valid_configs.append(entry) - if primary is None: - msg = f"No valid preset found for region {region}." - log_to_console(f"[ENGINE] {msg}") - show_engine_error_dialog(msg) - return + if state.network_mode != "meshcore": + if primary is None: + msg = f"No valid preset found for region {region}." + log_to_console(f"[ENGINE] {msg}") + show_engine_error_dialog(msg) + return - primary_key, primary_preset_id, primary_calc, _ = primary + primary_key, primary_preset_id, primary_calc, _ = primary log_to_console(f"[ENGINE] Primary: {primary_key} (preset_id={primary_preset_id}), extra chains: {len(valid_configs)}") - cmd = [ - py, "-m", "meshtastic_engine.run_engine", - "--host", "127.0.0.1", - "--port", str(state.port), - "--center-freq", str(primary_calc["center_freq_hz"]), - "--samp-rate", "1000000", - "--lora-bw", str(int(round(primary_calc["bw_khz"] * 1000))), - "--sf", str(primary_calc["sf"]), - "--gain", str(int(state.direct_gain)), - "--ppm", str(int(state.direct_ppm)), - "--preset-id", str(primary_preset_id), - ] + if state.network_mode != "meshcore": + cmd = [ + py, "-m", "meshtastic_engine.run_engine", + "--host", "127.0.0.1", + "--port", str(state.port), + "--center-freq", str(primary_calc["center_freq_hz"]), + "--samp-rate", "1000000", + "--lora-bw", str(int(round(primary_calc["bw_khz"] * 1000))), + "--sf", str(primary_calc["sf"]), + "--gain", str(int(state.direct_gain)), + "--ppm", str(int(state.direct_ppm)), + "--preset-id", str(primary_preset_id), + ] if valid_configs: import json as _json cmd.extend(["--extra-demod-configs", _json.dumps(valid_configs)]) @@ -3936,6 +4172,32 @@ def main_page(): } catch (e) { } } catch (e) { } } + window.meshEnsurePathLayer = (map) => { + if (!window.L || !map) return; + if (map._meshPathLayer) return; + map._meshPathLayer = window.L.layerGroup().addTo(map); + }; + + window.meshDrawPaths = (mapElementId, paths) => { + try { + const el = (typeof getElement === 'function') ? getElement(mapElementId) : null; + const map = el && el.map; + if (!window.L || !map) return; + window.meshEnsurePathLayer(map); + map._meshPathLayer.clearLayers(); + + paths.forEach(p => { + if (!p.coords || p.coords.length < 2) return; + window.L.polyline(p.coords, { + color: '#3b82f6', + weight: 2, + opacity: 0.6, + dashArray: '5, 10' + }).addTo(map._meshPathLayer); + }); + } catch (e) { } + }; + window.meshApplyThemeToMapWhenReady = (tries) => { let remaining = Number.isFinite(Number(tries)) ? Number(tries) : 40; const tick = () => { @@ -4223,11 +4485,13 @@ def main_page(): } catch (e) { } }; - window.meshMarkerHtml = (label, cls, hopCls) => { + window.meshMarkerHtml = (label, cls, hopCls, roleCls, relayCls) => { const text = window.meshEscapeHtml(label ?? ''); const c = window.meshEscapeHtml(cls ?? ''); const h = window.meshEscapeHtml(hopCls ?? 'mesh-node-unknown'); - return '
' + text + '
'; + const r = window.meshEscapeHtml(roleCls ?? ''); + const rl = window.meshEscapeHtml(relayCls ?? ''); + return '
' + text + '
'; }; window.meshGetNodeMarkerDims = (map) => { @@ -4446,7 +4710,15 @@ def main_page(): const hops = n.hops; const hopCls = (hops === 0) ? 'mesh-node-direct' : (hops === null || hops === undefined) ? 'mesh-node-unknown' : 'mesh-node-indirect'; - const html = window.meshMarkerHtml(label, cls, hopCls); + + let roleCls = ''; + if (n.role_id === 'Repeater') roleCls = 'mesh-node-repeater'; + else if (n.role_id === 'Room Server') roleCls = 'mesh-node-room-server'; + else if (n.role_id === 'Companion') roleCls = 'mesh-node-companion'; + + const relayCls = n.relay_capable ? 'mesh-node-relay-capable' : ''; + + const html = window.meshMarkerHtml(label, cls, hopCls, roleCls, relayCls); const icon = window.L.divIcon({ className: 'mesh-node-divicon', html: html, @@ -4663,6 +4935,13 @@ def main_page(): .mesh-node-indirect { border: 1px solid rgba(116,116,116,0.92) !important; } .mesh-node-unknown { border: 1.5px dashed rgba(200,200,200,0.72) !important; } + /* MeshCore Marker Shapes */ + .mesh-node-companion { border-radius: 999px; } + .mesh-node-repeater { border-radius: 0; clip-path: polygon(50% 0%, 0% 100%, 100% 100%); width: 40px !important; height: 40px !important; } + .mesh-node-room-server { border-radius: 4px; width: 38px !important; height: 38px !important; } + + .mesh-node-relay-capable { border: 4px double #ffffff !important; } + .mesh-node-marker.mesh-pulse::after { content: ''; position: absolute; @@ -5275,12 +5554,88 @@ def _rtlsdr_error_ui_tick(): with ui.scroll_area().style('height: 100%;'): with ui.column().classes('w-full'): ui.label(translate("panel.connection.settings.title", "Connection Settings")).classes('text-lg font-bold mb-2') + + with ui.row().classes('w-full items-center mb-2'): + ui.label(translate("panel.connection.settings.network_mode", "Network Ecosystem:")).classes('mr-2 font-bold') + network_mode_toggle = ui.toggle( + {"meshtastic": "Meshtastic", "meshcore": "MeshCore"}, + value=state.network_mode, + on_change=lambda e: setattr(state, 'network_mode', e.value) or save_user_config() or connection_dialog.update() + ).props('dense rounded unelevated color=blue') + with ui.tabs().classes('w-full mb-2') as tabs: tab_direct = ui.tab(translate("panel.connection.settings.internaltab", "Internal")) tab_ext = ui.tab(translate("panel.connection.settings.externaltab", "External")) with ui.tab_panels(tabs, value=tab_direct).classes('w-full'): with ui.tab_panel(tab_direct): + if state.network_mode == "meshcore": + ui.label(translate("panel.connection.settings.meshcore.internal.title", "MeshCore SDR Engine")).classes('font-bold mb-0') + ui.markdown(translate("panel.connection.settings.meshcore.internal.help", "Configure MeshCore radio parameters for the internal engine.")).classes('text-sm text-gray-600') + + # MeshCore Region/Preset select + mc_region_options = {k: k.replace("_", "/") for k in MESHCORE_PRESETS.keys()} + mc_region_options["CUSTOM"] = translate("ui.custom", "Custom") + + def _on_mc_region_change(e): + state.meshcore_region = e.value + if e.value != "CUSTOM": + first_preset = list(MESHCORE_PRESETS[e.value].keys())[0] + state.meshcore_preset = first_preset + # Sync custom fields with preset + p = MESHCORE_PRESETS[e.value][first_preset] + state.meshcore_custom_freq = p["freq"] + state.meshcore_custom_sf = p["sf"] + state.meshcore_custom_bw = p["bw"] + state.meshcore_custom_cr = p["cr"] + state.meshcore_custom_sync_word = p["sync"] + state.meshcore_custom_preamble_len = p["preamble"] + save_user_config() + connection_dialog.update() + + ui.select( + options=mc_region_options, + value=state.meshcore_region, + on_change=_on_mc_region_change, + label=translate("panel.connection.settings.internal.label.region", "Region") + ).props('dense').classes('w-full mb-1') + + if state.meshcore_region != "CUSTOM": + mc_preset_options = {k: translate(v["description_key"]) for k, v in MESHCORE_PRESETS[state.meshcore_region].items()} + + def _on_mc_preset_change(e): + state.meshcore_preset = e.value + p = MESHCORE_PRESETS[state.meshcore_region][e.value] + state.meshcore_custom_freq = p["freq"] + state.meshcore_custom_sf = p["sf"] + state.meshcore_custom_bw = p["bw"] + state.meshcore_custom_cr = p["cr"] + state.meshcore_custom_sync_word = p["sync"] + state.meshcore_custom_preamble_len = p["preamble"] + save_user_config() + connection_dialog.update() + + ui.select( + options=mc_preset_options, + value=state.meshcore_preset, + on_change=_on_mc_preset_change, + label=translate("panel.connection.settings.internal.label.modempreset", "Preset") + ).props('dense').classes('w-full mb-1') + + with ui.row().classes('w-full gap-2'): + ui.number(translate("panel.connection.settings.meshcore.label.freq", "Freq (MHz)"), value=state.meshcore_custom_freq, format="%.3f", on_change=lambda e: setattr(state, 'meshcore_custom_freq', e.value) or save_user_config()).props('dense').classes('flex-1') + ui.number(translate("panel.connection.settings.meshcore.label.sf", "SF"), value=state.meshcore_custom_sf, precision=0, on_change=lambda e: setattr(state, 'meshcore_custom_sf', e.value) or save_user_config()).props('dense').classes('w-16') + + with ui.row().classes('w-full gap-2'): + ui.number(translate("panel.connection.settings.meshcore.label.bw", "BW (kHz)"), value=state.meshcore_custom_bw, format="%.1f", on_change=lambda e: setattr(state, 'meshcore_custom_bw', e.value) or save_user_config()).props('dense').classes('flex-1') + ui.number(translate("panel.connection.settings.meshcore.label.cr", "CR"), value=state.meshcore_custom_cr, precision=0, on_change=lambda e: setattr(state, 'meshcore_custom_cr', e.value) or save_user_config()).props('dense').classes('w-16') + + mc_advanced = ui.checkbox(translate("panel.connection.settings.meshcore.label.advanced", "Advanced LoRa Settings")).props('dense').classes('text-xs mt-1') + with ui.row().classes('w-full gap-2').bind_visibility_from(mc_advanced, 'value'): + ui.number(translate("panel.connection.settings.meshcore.label.sync_word", "Sync Word (hex)"), value=state.meshcore_custom_sync_word, format="0x%02X", on_change=lambda e: setattr(state, 'meshcore_custom_sync_word', int(e.value)) or save_user_config()).props('dense').classes('flex-1') + ui.number(translate("panel.connection.settings.meshcore.label.preamble", "Preamble"), value=state.meshcore_custom_preamble_len, precision=0, on_change=lambda e: setattr(state, 'meshcore_custom_preamble_len', e.value) or save_user_config()).props('dense').classes('w-20') + + else: ui.label(translate("panel.connection.settings.internal.title", "Internal SDR Engine")).classes('font-bold mb-0') ui.markdown(translate("panel.connection.settings.internal.help", 'The app manages the internal SDR engine for you.
Just select Region, Channel, PPM for your device and a suitable RF Gain.')).classes('text-sm text-gray-600') _saved_device_args = str(getattr(state, "direct_device_args", "rtl=0") or "").strip() @@ -6365,7 +6720,7 @@ def process_node_filter(e): tile_internet = has_tile_internet() ui.run_javascript( - f"window.mesh_main_map_id = {json.dumps(m.id)}; window.mesh_tile_internet = {json.dumps(tile_internet)};" + f"window.mesh_main_map_id = {json.dumps(m.id)}; window.mesh_tile_internet = {json.dumps(tile_internet)}; window.mesh_network_mode = {json.dumps(state.network_mode)};" ) ui.run_javascript(""" (function() { @@ -6914,12 +7269,26 @@ async def update_map(): popup_content = f"
" popup_content += f"{name_display}" - if short_display: - popup_content += f"label Short Name: {short_display}
" + if state.network_mode == "meshcore": + popup_content += f"fingerprint {translate('ui.public_key', 'Public key')}: {nid}
" + popup_content += f"label {translate('ui.short_id', 'Short ID')}: {n['short_name']}
" + popup_content += f"category {translate('ui.type', 'Type')}: {n['role']}
" + popup_content += f"history {translate('ui.freshness', 'Freshness')}: {n['last_seen']}
" + + # Radio Params for MeshCore + popup_content += "
" + popup_content += f"{translate('ui.radio_parameters', 'Radio parameters')}:
" + popup_content += f"{translate('ui.frequency', 'Frequency')}: {state.meshcore_custom_freq}MHz
" + popup_content += f"{translate('ui.bandwidth', 'Bandwidth')}: {state.meshcore_custom_bw}kHz
" + popup_content += f"{translate('ui.coding_rate', 'Coding rate')}: {state.meshcore_custom_cr}
" + popup_content += f"{translate('ui.spreading_factor', 'Spreading factor')}: {state.meshcore_custom_sf}
" + else: + if short_display: + popup_content += f"label Short Name: {short_display}
" - popup_content += f"fingerprint ID: {nid}
" - popup_content += f"memory {translate('ui.model', 'Model')}: {n['hw_model']}
" - popup_content += f"admin_panel_settings {translate('ui.role', 'Role')}: {n['role']}
" + popup_content += f"fingerprint ID: {nid}" + popup_content += f"memory {translate('ui.model', 'Model')}: {n['hw_model']}
" + popup_content += f"admin_panel_settings {translate('ui.role', 'Role')}: {n['role']}
" if n.get('temperature') is not None: popup_content += f"thermostat {n['temperature']:.1f}°C
" @@ -7010,6 +7379,9 @@ async def update_map(): "marker_label": label.upper(), "popup": popup_content, "hops": n.get("hops"), + "role": n.get("role"), + "role_id": n.get("role_id"), + "relay_capable": n.get("relay_capable", False), }) if nodes_payload: @@ -7020,6 +7392,35 @@ async def update_map(): ) map_markers_ready['value'] = True + # MeshCore Path Drawing + if state.network_mode == "meshcore": + paths_payload = [] + for nid, n in list(state.nodes.items()): + last_path = n.get("last_path") + if last_path and n.get("lat") and n.get("lon"): + coords = [[float(n["lat"]), float(n["lon"])]] + # Trace path through repeaters + for hop_hash in last_path: + # Try to find repeater by hash + found_rep = False + for rnid, rn in state.nodes.items(): + if rnid.startswith(hop_hash) and rn.get("lat") and rn.get("lon"): + coords.append([float(rn["lat"]), float(rn["lon"])]) + found_rep = True + break + if not found_rep: + # If repeater location is unknown, we stop drawing this path + break + if len(coords) > 1: + paths_payload.append({"coords": coords}) + + if paths_payload: + with m: + await ui.run_javascript( + "try { window.meshDrawPaths(%s, %s); } catch (e) {}" + % (json.dumps(m.id), json.dumps(paths_payload)) + ) + state.nodes_updated = False except Exception as e: @@ -7077,10 +7478,11 @@ def reset_filters(): {'headerName': 'Long Name', 'field': 'long_name', 'width': 180, 'minWidth': 180, ':cellRenderer': 'window.meshCopyCellRenderer'}, {'headerName': 'ID', 'field': 'id', 'width': 150, 'minWidth': 150, ':cellRenderer': 'window.meshCopyCellRenderer'}, {'headerName': 'MAC', 'field': 'macaddr', 'width': 160, 'minWidth': 160, ':cellRenderer': 'window.meshCopyCellRenderer'}, - {'headerName': 'Public Key', 'field': 'public_key', 'width': 240, 'minWidth': 240, ':cellRenderer': 'window.meshCopyCellRenderer'}, + {'headerName': translate('ui.public_key', 'Public Key'), 'field': 'public_key', 'width': 240, 'minWidth': 240, ':cellRenderer': 'window.meshCopyCellRenderer'}, {'headerName': 'Unmessagable', 'field': 'is_unmessagable', 'width': 120, ':valueFormatter': '(p) => (p.value === true ? \"true\" : \"false\")'}, - {'headerName': 'Model', 'field': 'hw_model', 'width': 160, 'minWidth': 160, ':cellRenderer': 'window.meshCopyCellRenderer'}, - {'headerName': 'Role', 'field': 'role', 'width': 140, 'minWidth': 140, ':cellRenderer': 'window.meshCopyCellRenderer'}, + {'headerName': translate('ui.model', 'Model'), 'field': 'hw_model', 'width': 160, 'minWidth': 160, ':cellRenderer': 'window.meshCopyCellRenderer'}, + {'headerName': translate('ui.role', 'Role'), 'field': 'role', 'width': 140, 'minWidth': 140, ':cellRenderer': 'window.meshCopyCellRenderer'}, + {'headerName': translate('ui.relay_capable', 'Relay Capable'), 'field': 'relay_capable', 'width': 130, ':valueFormatter': '(p) => (p.value === true ? \"true\" : \"false\")'}, { 'headerName': 'Hops', 'field': 'hops', diff --git a/README.md b/README.md index 4ef3665..2239e6e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

📡 MeshStation 📡

-

Meshtastic SDR Analyzer & Desktop GUI

+

Meshtastic & MeshCore SDR Analyzer & Desktop GUI


Ko-fi  @@ -29,12 +29,12 @@ ## 🇬🇧 English -**MeshStation** is an open‑source Meshtastic SDR Analyzer and Desktop GUI. -It decodes Meshtastic packets using any SDR, displays nodes on a map, +**MeshStation** is an open‑source Meshtastic and MeshCore SDR Analyzer and Desktop GUI. +It decodes Meshtastic and MeshCore packets using any SDR, displays nodes on a map, shows chat messages, node database/list, mesh network overview and quality, raw console data, and will support TX in the future. -In fact this is a real-time Meshtastic network observatory powered by **Software Defined Radio (SDR)**. -It passively listens to the RF spectrum and decodes live Meshtastic traffic directly from the air — **no Meshtastic device required**. +In fact this is a real-time mesh network observatory powered by **Software Defined Radio (SDR)**. +It passively listens to the RF spectrum and decodes live Meshtastic or MeshCore traffic directly from the air — **no mesh device required**. This project is designed for **network discovery, coverage analysis, network density, network quality, research, and real-world mapping of Meshtastic nodes**, using only a simple RTL-SDR receiver. @@ -61,6 +61,12 @@ This enables use cases that are currently hard or impossible with standard Mesht - **Scan all modem presets at once (LongFast, MediumFast etc.)** - **Scan all the MeshTastic frequencies in the world** +#### 🕸️ MeshCore Ecosystem Support +- **Repeaters & Companions**: Full visualization of MeshCore's structured, repeater-centric hierarchy. +- **Role-based Markers**: Instant visual identification of Repeaters (Triangle), Companions (Circle), and Room Servers (Square). +- **Multi-hop Pathing**: Real-time dashed-line visualization of the repeater chain a packet traveled through. +- **Regional Presets**: One-click configuration for USA/Canada (910.525 MHz) and Legacy Wide (915.8 MHz) deployments. + #### 🧠 Network Mapping & Research - Map the **real Meshtastic network as it actually exists** - Build a **database of nodes**, including: @@ -111,6 +117,8 @@ If adequate **financial and community support** is reached, MeshStation aims to - Supported SDR drivers - A system capable of real-time SDR processing +**Are you on Windows 11?** Check out the [Windows 11 Setup Guide](WINDOWS_SETUP.md) for step-by-step instructions on hardware, drivers, and startup. + **Visit the wiki if you're having trouble with drivers or to learn how to "install" the app:** [https://github.com/IronGiu/MeshStation/wiki](https://github.com/IronGiu/MeshStation/wiki) --- @@ -137,12 +145,12 @@ If you find this project useful or exciting: ## 🇮🇹 Italiano -**MeshStation** è un analizzatore SDR Meshtastic open source e un'interfaccia grafica desktop. -Decodifica i pacchetti Meshtastic utilizzando qualsiasi SDR, visualizza i nodi su una mappa, +**MeshStation** è un analizzatore SDR Meshtastic e MeshCore open source e un'interfaccia grafica desktop. +Decodifica i pacchetti Meshtastic e MeshCore utilizzando qualsiasi SDR, visualizza i nodi su una mappa, mostra messaggi di chat, database/lista nodi, Panoramica rete mesh e qualità, dati grezzi della console e supporterà la trasmissione in futuro. -Infatti questo è un osservatorio della rete Meshtastic in tempo reale basato su **Software Defined Radio (SDR)**. -Ascolta passivamente lo spettro RF e decodifica il traffico Meshtastic **direttamente dall’etere**, senza bisogno di alcun dispositivo Meshtastic. +Infatti questo è un osservatorio della rete mesh in tempo reale basato su **Software Defined Radio (SDR)**. +Ascolta passivamente lo spettro RF e decodifica il traffico Meshtastic o MeshCore **direttamente dall’etere**, senza bisogno di alcun dispositivo mesh. Il progetto è pensato per **scoperta della rete, analisi di copertura, traffico della rete, qualità della rete, studio e mappatura reale dei nodi Meshtastic**, utilizzando solo un semplice ricevitore RTL-SDR. @@ -169,6 +177,12 @@ Questo permette casi d’uso difficili o impossibili con le configurazioni Mesht - **Scansionare contemporaneamente tutti i modem-preset (LongFast, MediumFast ecc)** - **Scansionare tutte le frequenze MeshTastic del mondo** +#### 🕸️ Supporto per l'Ecosistema MeshCore +- **Ripetitori e Companion**: Visualizzazione completa della gerarchia strutturata e incentrata sui ripetitori di MeshCore. +- **Marcatori basati sui ruoli**: Identificazione visiva istantanea di Ripetitori (Triangolo), Companion (Cerchio) e Server di Stanza (Quadrato). +- **Percorsi Multi-hop**: Visualizzazione in tempo reale (linea tratteggiata) della catena di ripetitori attraversata da un pacchetto. +- **Preset Regionali**: Configurazione con un clic per i paesi USA/Canada (910.525 MHz) e Legacy Wide (915.8 MHz). + #### 🧠 Mappatura & Studio della Rete - Mappare la **rete Meshtastic reale**, così com’è davvero - Creare un **database dei nodi**, includendo: @@ -218,6 +232,8 @@ Con un adeguato **supporto economico e della community**, MeshStation potrà evo - Driver SDR supportati - Sistema in grado di gestire SDR in tempo reale +**Sei su Windows 11?** Consulta la [Guida all'installazione per Windows 11](WINDOWS_SETUP.md) per istruzioni dettagliate su hardware, driver e avvio. + **Vai nella wiki se hai problemi con driver o per sapere come "installare" l'app:** [https://github.com/IronGiu/MeshStation/wiki](https://github.com/IronGiu/MeshStation/wiki) --- diff --git a/WINDOWS_SETUP.md b/WINDOWS_SETUP.md new file mode 100644 index 0000000..9f04687 --- /dev/null +++ b/WINDOWS_SETUP.md @@ -0,0 +1,68 @@ +# 🪟 Windows 11 Setup Guide for MeshStation + +Follow these steps to get MeshStation running on your Windows 11 machine. + +## 1. Prerequisites +### Hardware +- A compatible **RTL-SDR dongle** (e.g., RTL-SDR Blog V3/V4, Noelec, HackRF). +- An appropriate antenna for the frequency you intend to monitor (e.g., 868MHz or 915MHz). + +### Software +- **Python 3.10 or 3.11**: [Download from python.org](https://www.python.org/downloads/windows/). Ensure you check **"Add Python to PATH"** during installation. +- **Git**: [Download from git-scm.com](https://git-scm.com/download/win) (if you are cloning the repository). + +--- + +## 2. Installation Steps + +### Step A: Clone or Download +Clone the repository or download the source code as a ZIP and extract it to a folder (e.g., `C:\MeshStation`). + +### Step B: Install Python Dependencies +Open **PowerShell** or **Command Prompt** in the project folder and run: +```powershell +pip install -r requirements.txt +``` + +### Step C: Build the SDR Radio Engine +MeshStation uses a high-performance GNU Radio-based engine. On Windows, you need to build the portable runtime: +1. Navigate to the folder: `engine\os\win_x86_64\` +2. Double-click on `auto-engine-builder.bat`. +3. Wait for the process to complete. It will download the necessary components and create a `runtime` folder. + +--- + +## 3. SDR Driver Setup (Zadig) +Windows often installs a default "DVB-T" driver for RTL-SDR dongles, which prevents them from being used as general-purpose SDRs. + +1. Plug in your RTL-SDR dongle. +2. Download and run **Zadig**: [https://zadig.akeo.ie/](https://zadig.akeo.ie/) +3. Go to `Options` -> `List All Devices`. +4. Select your SDR from the dropdown (often called `Bulk-In, Interface (Interface 0)` or `RTL2838UHIDIR`). +5. Ensure the target driver (right side) is **WinUSB**. +6. Click **Replace Driver** (or **Install Driver**). + +--- + +## 4. Starting MeshStation +Go back to the root project folder and run: +```powershell +python MeshStation.py +``` + +--- + +## 5. Basic Usage +1. **Connect**: Click the **Connect** button in the top menu. +2. **Choose Ecosystem**: In the Connection Settings, use the **Network Ecosystem** toggle to select **Meshtastic** or **MeshCore**. +3. **Configure Settings**: + - **Meshtastic**: Select your Region (e.g., US, EU_868) and Modem Preset. + - **MeshCore**: Select a Regional Preset (e.g., USA/Canada) or choose "Custom" to set your own frequency and SF. +4. **Start**: Click **Connect** inside the settings panel. You should see the console log starting the engine and, eventually, nodes appearing on the map! + +--- + +## 🛠️ Troubleshooting +- **No device found**: Ensure you performed the Zadig driver replacement. Try a different USB port. +- **Engine won't start**: Re-run the `auto-engine-builder.bat` script. Ensure no other SDR software (like SDR# or CubicSDR) is using the dongle. +- **Visual Glitches**: If the map looks strange, try running with the `--nogpu` flag: `python MeshStation.py --nogpu`. diff --git a/engine/meshtastic_engine/flowgraphs/rx_lora_base_engine.py b/engine/meshtastic_engine/flowgraphs/rx_lora_base_engine.py index cfe97ba..12e6444 100644 --- a/engine/meshtastic_engine/flowgraphs/rx_lora_base_engine.py +++ b/engine/meshtastic_engine/flowgraphs/rx_lora_base_engine.py @@ -51,7 +51,8 @@ def __init__( payload_length=237, impl_head=False, has_crc=True, - sync_word=(0, 0), + sync_word=None, + preamble_len=None, device_args="", bias_tee=False, extra_demod_configs=None, @@ -99,7 +100,20 @@ def _apply_bias_tee_args(device_args: str, enable: bool, dev: str): samp_rate = int(samp_rate) self.samp_rate = samp_rate lora_bw = int(lora_bw) + + # Sync word processing: MeshCore often uses 0x12 (18) + if sync_word is None: + sync_word = [0, 0] # default for meshtastic + elif isinstance(sync_word, int): + sync_word = [0x10, sync_word] # gr-lora style + sync_word = [int(x) for x in sync_word] + + if preamble_len is None: + preamble_length = 17 # default for meshtastic + else: + preamble_length = int(preamble_len) + dc_shift = 0 dc_iq_mode = 0 # Pre-detect from args (may be "unknown" if args is empty = auto mode) @@ -337,6 +351,15 @@ def _add_demod_chain(self, cfg: dict): chain_bw = int(cfg['bw']) chain_freq = int(cfg['center_freq']) chain_preset_id = int(cfg.get('preset_id', 0)) + chain_sync_word = cfg.get('sync_word') + if chain_sync_word is None: + chain_sync_word = self.sync_word + elif isinstance(chain_sync_word, int): + chain_sync_word = [0x10, chain_sync_word] + + chain_preamble_len = cfg.get('preamble_len') + if chain_preamble_len is None: + chain_preamble_len = self.preamble_length # BW-specific bandpass filter taps chain_taps = firdes.complex_band_pass( @@ -349,7 +372,7 @@ def _add_demod_chain(self, cfg: dict): xlat = filter.freq_xlating_fir_filter_ccc(decimation, chain_taps, chain_freq - self.center_freq, self.samp_rate) xlat.set_min_output_buffer(17000) - frame_sync = lora_sdr_extra.frame_sync(chain_freq, chain_bw, chain_sf, self.impl_head, list(self.sync_word), 4, 17) + frame_sync = lora_sdr_extra.frame_sync(chain_freq, chain_bw, chain_sf, self.impl_head, list(chain_sync_word), 4, chain_preamble_len) fft_demod = lora_sdr_extra.fft_demod(self.soft_decoding, True) gray_map = lora_sdr_extra.gray_mapping(self.soft_decoding) deinterleave = lora_sdr_extra.deinterleaver(self.soft_decoding) diff --git a/engine/meshtastic_engine/run_engine.py b/engine/meshtastic_engine/run_engine.py index 356507d..f0018ce 100644 --- a/engine/meshtastic_engine/run_engine.py +++ b/engine/meshtastic_engine/run_engine.py @@ -34,6 +34,9 @@ def main(argv=None) -> int: ap.add_argument("--preset-id", type=int, default=0, help="Preset ID byte embedded in each frame (0=unset)") ap.add_argument("--extra-demod-configs", default=None, help="JSON list of extra demod chains, e.g. '[{\"sf\":9,\"bw\":250000,...}]'") + ap.add_argument("--sync-word", type=int, default=None, help="Override LoRa sync word") + ap.add_argument("--preamble-len", type=int, default=None, help="Override LoRa preamble length") + args = ap.parse_args(argv) q = queue.Queue(maxsize=4000) @@ -60,6 +63,8 @@ def main(argv=None) -> int: device_args=args.device_args, bias_tee=args.bias_tee, extra_demod_configs=extra_demod_configs, + sync_word=args.sync_word, + preamble_len=args.preamble_len, ) # Optional: tweak aggregator timings from CLI diff --git a/languages.json b/languages.json index 4d6e56b..8d853cc 100644 --- a/languages.json +++ b/languages.json @@ -62,6 +62,25 @@ "ui.totalnodes": "Total Nodes", "ui.model": "Model", "ui.role": "Role", + "ui.public_key": "Public Key", + "ui.short_id": "Short ID", + "ui.type": "Type", + "ui.freshness": "Freshness", + "ui.radio_parameters": "Radio Parameters", + "ui.frequency": "Frequency", + "ui.bandwidth": "Bandwidth", + "ui.coding_rate": "Coding Rate", + "ui.spreading_factor": "Spreading Factor", + "ui.relay_capable": "Relay Capable", + "ui.custom": "Custom", + "meshcore.preset.recommended": "USA/Canada (Recommended)", + "meshcore.preset.legacy_wide": "Legacy 915-region Wide", + "meshcore.role.companion": "Companion", + "meshcore.role.repeater": "Repeater", + "meshcore.role.room_server": "Room Server", + "meshcore.role.sensor": "Sensor", + "meshcore.message.encrypted": "[Encrypted Message]", + "meshcore.message.group_encrypted": "[Encrypted Group Message]", "ui.resetfilters": "Reset Filters", "chat.load_older": "Load older messages", "chat.stored_in_memory": "(all messages kept in memory)", @@ -111,6 +130,16 @@ "panel.connection.settings.internal.label.port.tooltip": "Only change this if the internal engine cannot bind the default port,\nor if you are running multiple parallel instances of this application from different folders with different SDR devices.", "panel.connection.settings.internal.label.aes_key": "AES Public Key (Base64) (default: AQ==)", "panel.connection.settings.internal.label.aes_key.hint": "Key size is auto-detected from the Base64 length (16 bytes = 128-bit, 32 bytes = 256-bit). 'AQ==' = Meshtastic default.", + "panel.connection.settings.network_mode": "Network Ecosystem:", + "panel.connection.settings.meshcore.internal.title": "MeshCore SDR Engine", + "panel.connection.settings.meshcore.internal.help": "Configure MeshCore radio parameters for the internal engine.", + "panel.connection.settings.meshcore.label.advanced": "Advanced LoRa Settings", + "panel.connection.settings.meshcore.label.sync_word": "Sync Word (hex)", + "panel.connection.settings.meshcore.label.preamble": "Preamble", + "panel.connection.settings.meshcore.label.freq": "Freq (MHz)", + "panel.connection.settings.meshcore.label.sf": "SF", + "panel.connection.settings.meshcore.label.bw": "BW (kHz)", + "panel.connection.settings.meshcore.label.cr": "CR", "panel.connection.settings.externaltab": "External", "panel.connection.settings.external.title": "External GNU Radio / ZMQ Stream", "panel.connection.settings.external.help1": "Requires an external specific (our custom frame) GNU Radio flowgraph with a ZMQ PUB block.", @@ -300,6 +329,25 @@ "ui.totalnodes": "Totale Nodi", "ui.model": "Modello", "ui.role": "Ruolo", + "ui.public_key": "Chiave Pubblica", + "ui.short_id": "ID Breve", + "ui.type": "Tipo", + "ui.freshness": "Freschezza", + "ui.radio_parameters": "Parametri Radio", + "ui.frequency": "Frequenza", + "ui.bandwidth": "Larghezza di Banda", + "ui.coding_rate": "Coding Rate", + "ui.spreading_factor": "Spreading Factor", + "ui.relay_capable": "Capacità Relay", + "ui.custom": "Personalizzato", + "meshcore.preset.recommended": "USA/Canada (Raccomandato)", + "meshcore.preset.legacy_wide": "Legacy 915-regione Wide", + "meshcore.role.companion": "Companion", + "meshcore.role.repeater": "Ripetitore", + "meshcore.role.room_server": "Server di Stanza", + "meshcore.role.sensor": "Sensore", + "meshcore.message.encrypted": "[Messaggio Criptato]", + "meshcore.message.group_encrypted": "[Messaggio di Gruppo Criptato]", "ui.resetfilters": "Resetta Filtri", "chat.load_older": "Carica messaggi precedenti", "chat.stored_in_memory": "(tutti i messaggi sono mantenuti in memoria)", @@ -349,6 +397,16 @@ "panel.connection.settings.internal.label.aes_key": "Chiave Pubblica AES (Base64) (default: AQ==)", "panel.connection.settings.internal.label.aes_key.hint": "Dimensione della chiave è auto-determinata dalla lunghezza Base64 (16 bytes = 128-bit, 32 bytes = 256-bit). 'AQ==' = Chiave predefinita Meshtastic.", "panel.connection.settings.internal.label.rf_gain": "Guadagno RF", + "panel.connection.settings.network_mode": "Ecosistema Rete:", + "panel.connection.settings.meshcore.internal.title": "Motore SDR MeshCore", + "panel.connection.settings.meshcore.internal.help": "Configura i parametri radio MeshCore per il motore interno.", + "panel.connection.settings.meshcore.label.advanced": "Impostazioni LoRa Avanzate", + "panel.connection.settings.meshcore.label.sync_word": "Parola di Sincronizzazione (hex)", + "panel.connection.settings.meshcore.label.preamble": "Preambolo", + "panel.connection.settings.meshcore.label.freq": "Freq (MHz)", + "panel.connection.settings.meshcore.label.sf": "SF", + "panel.connection.settings.meshcore.label.bw": "BW (kHz)", + "panel.connection.settings.meshcore.label.cr": "CR", "panel.connection.settings.externaltab": "Esterno", "panel.connection.settings.external.title": "GNU Radio Esterno / Stream ZMQ", "panel.connection.settings.external.help1": "Richiede un flusso GNU Radio specifico (nostro frame personalizzato) con un blocco ZMQ PUB.",