Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
118 lines
4.5 KiB
Python
118 lines
4.5 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The ``map`` tool: plot the mesh's located nodes on an OpenStreetMap terminal map.
|
|
|
|
Nodes that share their location in adverts are collected from the passive-monitor history
|
|
(:meth:`~meshterm.persistence.repository.Repository.heard_nodes`), our own node is added when
|
|
its position is known, and everything is drawn over a real street basemap rendered as Unicode
|
|
braille (streets, rivers, place names — the same terminal-map idea as ``mapscii``).
|
|
|
|
Menu-only: it opens a full-screen, pannable/zoomable map (see
|
|
:mod:`meshterm.ui.map_screen`) and registers no CLI subcommand, because a map is a picture
|
|
and the scripted CLI deals in facts (see :meth:`MapTool.register_cli`). Repeaters are
|
|
prioritised over ordinary nodes: a distinct marker, drawn on top. The basemap is
|
|
best-effort — with no network (and no cached tiles) the nodes are plotted on a blank grid
|
|
instead, and the tool still works offline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import typer
|
|
|
|
from ..context import AppContext
|
|
from ..services.markers import gather_markers
|
|
from .base import Tool, ToolResult, register
|
|
|
|
|
|
def _fraction(ctx: AppContext, params: dict[str, Any]) -> float:
|
|
"""How much of the mesh a map opens framed on: the flag if one was passed, else the preference.
|
|
|
|
``--fraction`` is resolved here rather than as the Typer option's default because the
|
|
options are declared at CLI *registration* time — before a context, and so before the
|
|
preferences file has been read.
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
params: This invocation's parameters.
|
|
|
|
Returns:
|
|
The fraction to frame.
|
|
"""
|
|
fraction = params.get("fraction")
|
|
return ctx.preferences.map_view_fraction if fraction is None else float(fraction)
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from ..core.models import MapMarker
|
|
|
|
|
|
@register
|
|
class MapTool(Tool):
|
|
"""Show the mesh's location-sharing nodes on a braille OpenStreetMap map."""
|
|
|
|
name = "map"
|
|
title = "Map"
|
|
icon = "🌍"
|
|
help = "Mesh nodes on a pannable street map"
|
|
category = "Explore"
|
|
order = 10
|
|
|
|
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
|
"""Gather the located nodes and open the interactive map.
|
|
|
|
Menu-only (see :meth:`register_cli`), so there is one path through here.
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
params: Optional ``fraction`` — how much of the mesh to open framed on.
|
|
|
|
Returns:
|
|
A :class:`ToolResult` summarizing how many nodes were plotted.
|
|
"""
|
|
from ..ui.map_screen import open_map
|
|
|
|
markers = await self._gather(ctx)
|
|
if not markers:
|
|
ctx.ui.note(
|
|
"[muted]no contacts or heard nodes have shared a location yet — a node "
|
|
"appears here once it advertises coordinates[/muted]"
|
|
)
|
|
return ToolResult(summary={"located": 0})
|
|
|
|
await open_map(ctx, markers, fraction=_fraction(ctx, params))
|
|
|
|
repeaters = sum(1 for m in markers if m.is_repeater and not m.is_self)
|
|
self_located = any(m.is_self for m in markers)
|
|
return ToolResult(
|
|
summary={
|
|
"located": len(markers),
|
|
"repeaters": repeaters,
|
|
"nodes": len(markers) - repeaters - (1 if self_located else 0),
|
|
"self_located": self_located,
|
|
},
|
|
)
|
|
|
|
# -- marker gathering -------------------------------------------------------
|
|
|
|
async def _gather(self, ctx: AppContext) -> list[MapMarker]:
|
|
"""Collect every located node to plot (see :func:`gather_markers`)."""
|
|
return await gather_markers(ctx)
|
|
|
|
def register_cli(self, app: typer.Typer) -> None:
|
|
"""Register no CLI command — a map is a picture, and pictures are menu-only.
|
|
|
|
Every other feature has a scripted face because its answer is a set of facts a
|
|
script can act on. A map's answer is a *drawing*: braille cells whose meaning is
|
|
their position on a grid and whose nodes are told apart by colour, which is
|
|
exactly what the scripted CLI does not have (see :mod:`meshterm.ui.script`). A
|
|
one-shot render there would be an unparseable block of glyphs, and stripping its
|
|
colour to match the rest of the CLI would make it unreadable as well.
|
|
|
|
The located nodes themselves are still scriptable — ``meshterm contacts`` lists
|
|
every one of them, coordinates included.
|
|
|
|
Args:
|
|
app: The Typer application (untouched).
|
|
"""
|