Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## [Unreleased]

## 0.5.4
### Neu
- **Begrüßungsseite** für neue Besucher (#44): was TerraRF ist, die fünf Bereiche, wie man anfängt – auch wie man den
eigenen Repeater aus MeshCore übernimmt. Jederzeit wieder über „?“ oben. Abschaltbar unter Administration → Zugang.
- **„Aus MeshCore übernehmen“** in „Planen“ (#45): Repeater nahe der Kartenmitte oder per Suche auswählen und auf einmal
als Stationen übernehmen. Auch Benutzer und Mitarbeitende können das in Projekten, die sie bearbeiten dürfen.
- **Repeater-Höhe vom Dach** (#43): Steht ein Repeater laut GPS auf oder direkt an einem Gebäude (höchstes Objekt im
Umkreis von 8 m im Oberflächenmodell, mindestens 2,5 m), gilt diese Höhe + 1 m als Vorgabe – beim Übernehmen, im
Profil von Strecken und Messpunkten und im CoreScope-Abgleich (🏠). Sonst bleibt es bei 10 m.
- **Hinweis-Banner** (#46): Der Admin schaltet unter Administration → Zugang einen Hinweis für alle ein (Beta, Baustelle,
Wartung, Updates kommen oder eigener Text).
- Datenschutzerklärung: erwähnt den Browser-Eintrag, dass die Begrüßung schon gesehen wurde.

## 0.5.3
### Behoben
- Projektmenü: Die Einträge waren weiß auf weiß und damit unsichtbar (die Schriftfarbe der oberen Leiste galt auch im Menü).
Expand Down
5 changes: 3 additions & 2 deletions app/analysis/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,11 @@ def _calibrate_one(project, station_id, partner, stations_by_pk, overrides, rx_h
row.update(rx_src="station", rx_station_id=as_station["id"])
prof = cov.profile(project, station_id, 0, 0, rx_station_id=as_station["id"])
else:
height = entered.get("height_m", rx_height)
guess = None if "height_m" in entered else cov.mast_height_guess(project, float(partner["lat"]), float(partner["lon"]))
height = entered.get("height_m", guess if guess is not None else rx_height)
gain = entered.get("gain_dbi", rx_gain)
cable = entered.get("cable_db", rx_cable)
row.update(rx_src="entered" if entered else "standard")
row.update(rx_src="entered" if entered else "building" if guess is not None else "standard")
prof = cov.profile(project, station_id, float(partner["lat"]), float(partner["lon"]), rx_height=float(height),
rx_gain=float(gain), rx_cable=float(cable), rx_extra_loss=0.0)
except (ValueError, KeyError, terrain.SourceError) as e:
Expand Down
3 changes: 2 additions & 1 deletion app/api/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async def profile(request: Request, body: dict = Body(...)):
@router.post("/api/elevation", dependencies=MEMBER)
async def elevation(body: dict = Body(...)):
try:
return await run_in_threadpool(cov.point_elevation, body.get("project") or {}, float(body["lat"]), float(body["lon"]))
radius = min(30.0, max(0.0, float(body.get("radius_m") or 0)))
return await run_in_threadpool(cov.point_elevation, body.get("project") or {}, float(body["lat"]), float(body["lon"]), radius)
except (ValueError, terrain.SourceError, KeyError) as e:
fail(e)
34 changes: 31 additions & 3 deletions app/model/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ def profile(project, station_id, lat, lon, rx_height=None, rx_gain=None, rx_stat
res_true = cs * kscale
n = int(min(6000, max(50, math.ceil(D / (res_true / 2)))))
if float(s.get("near_m") or 0) > 0 and res_true > float(s.get("near_res_m") or 5.0) * 1.01:
n = int(min(6000, max(n, math.ceil(D / (float(s.get("near_res_m") or 5.0) / 2))))) # Nahfeld fein (#23)
n = int(min(6000, max(n, math.ceil(D / (float(s.get("near_res_m") or 5.0) / 2))))) # fine near field (#23)
t = np.linspace(0, 1, n + 1)
fxs = ((x0 + (x1 - x0) * t) - grid.x0) / cs - 0.5
fys = (grid.y1 - (y0 + (y1 - y0) * t)) / cs - 0.5
Expand Down Expand Up @@ -603,15 +603,43 @@ def profile(project, station_id, lat, lon, rx_height=None, rx_gain=None, rx_stat
}


def point_elevation(project, lat, lon):
# #43: a repeater whose GPS position lies on or right next to a building is assumed to stand on its roof.
MAST_SEARCH_M = 8.0 # how far from the position to look for a roof (consumer GPS is off by a few metres)
MIN_BUILDING_M = 2.5 # objects lower than this are not taken as a building (cars, hedges, noise)
ABOVE_ROOF_M = 1.0 # antenna this much above the highest roof point nearby (wish in #43)


def point_elevation(project, lat, lon, radius_m=0.0):
"""Ground and surface height at a point. With radius_m also the highest object (surface − ground) within that
radius on the fine near-field grid, and a mast height guess for a repeater there (#43)."""
s = merged_settings(project)
cs = project_cs(project, s)
x, y = lonlat_to_merc(lon, lat)
grid = Grid.around(x, y, 3 * cs, cs)
G, S, _ = terrain_pair(s, grid)
fx = (x - grid.x0) / cs - 0.5
fy = (grid.y1 - y) / cs - 0.5
return {"ground_m": round(float(prop.sample(G, fx, fy)), 1), "surface_m": round(float(prop.sample(S, fx, fy)), 1)}
out = {"ground_m": round(float(prop.sample(G, fx, fy)), 1), "surface_m": round(float(prop.sample(S, fx, fy)), 1)}
if radius_m > 0:
fine = min(cs, float(s.get("near_res_m") or 5.0))
g = Grid.around(x, y, radius_m + 2 * fine, fine)
G2, S2, _ = terrain_pair(s, g)
xs = g.x0 + (np.arange(G2.shape[1]) + 0.5) * fine
ys = g.y1 - (np.arange(G2.shape[0]) + 0.5) * fine
inside = ((xs[None, :] - x) ** 2 + (ys[:, None] - y) ** 2) <= (radius_m + fine / 2) ** 2
heights = np.where(inside & np.isfinite(S2 - G2), S2 - G2, 0.0)
highest = float(heights.max()) if heights.size else 0.0
out["object_max_m"] = round(highest, 1)
out["mast_guess_m"] = round(highest + ABOVE_ROOF_M, 1) if highest >= MIN_BUILDING_M else None
return out


def mast_height_guess(project, lat, lon):
"""Antenna height above ground for a repeater that sits on a roof (#43), or None if no building is near."""
try:
return point_elevation(project, lat, lon, MAST_SEARCH_M).get("mast_guess_m")
except Exception: # noqa: BLE001 – no elevation data there: no guess
return None


def clear_memory_caches():
Expand Down
5 changes: 3 additions & 2 deletions app/site/legal.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ def datenschutz_body():
<p>Besucher erhalten keine Cookies. Nur nach der Anmeldung des Administrators wird das Cookie
<code>terrarf_session</code> gesetzt. Es ist für den angemeldeten Bereich unbedingt erforderlich (§ 25 Abs. 2 Nr. 2
TDDDG) und wird beim Schließen des Browsers gelöscht, sofern nicht „angemeldet bleiben“ gewählt wurde (dann höchstens
{days} Tage). Wählen Sie oben die Sprache der Oberfläche, speichert Ihr Browser nur diese Wahl im lokalen Speicher (localStorage,
Schlüssel „terrarf.lang“); sie verlässt Ihr Gerät nicht. Darüber hinaus wird lokaler Browserspeicher nicht verwendet.</p>
{days} Tage). Im lokalen Speicher Ihres Browsers (localStorage) legt die Seite nur zwei Einträge ab, die Ihr Gerät nicht verlassen:
„terrarf.lang“ – die oben gewählte Sprache – und „terrarf.welcome“ – dass Sie die Begrüßungsseite schon gesehen haben,
damit sie nicht bei jedem Besuch erscheint. Darüber hinaus wird lokaler Browserspeicher nicht verwendet.</p>
<h2>Kartendarstellung</h2>{maps}
{cs}
<h2>Messfahrten</h2><p>Messwerte von Messfahrten (z. B. aus der App MeshMapper) werden mit Ort, Zeit, Signalwerten,
Expand Down
3 changes: 3 additions & 0 deletions app/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
</div>
<div class="spacer"></div>
<select id="langSelect" aria-label="Language"><option value="de">DE</option><option value="en">EN</option></select>
<button id="btnHelp" class="iconbtn" data-i18n-title="What is TerraRF?">?</button>
<button id="btnAdmin" class="iconbtn admin-only" data-i18n-title="Administration">⚙</button>
<button id="btnLogin" class="login"></button>
</header>
Expand Down Expand Up @@ -61,6 +62,7 @@
<div id="map"></div>
<button id="btnLayers" class="mapbtn" data-i18n-title="Layers">☰ <span data-i18n="Layers"></span></button>
<div id="layersPanel" class="floatcard hidden"></div>
<div id="noticeBar" class="hidden" role="status"></div>
<div id="soloChip" class="hidden"></div>
<div id="placingBanner" class="hidden"></div>
<div id="toast" role="status"></div>
Expand Down Expand Up @@ -89,6 +91,7 @@ <h3 data-i18n="Log in"></h3>
<dialog id="adminDlg" class="fullscreen" aria-label="Administration"></dialog>
<dialog id="installDlg"></dialog>
<dialog id="membersDlg"></dialog>
<dialog id="welcomeDlg"></dialog>

<script src="/static/vendor/leaflet/leaflet.js"></script>
<script type="module" src="/assets/__VERSION__/js/main.js"></script>
Expand Down
11 changes: 9 additions & 2 deletions app/static/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ async function reloadConfig() {

// ---------------------------------------------------------------- access & website
const ACCESS_KEYS = ["user_enabled", "user_name", "user_password", "parent_name", "parent_url", "show_github", "imprint_mode", "imprint_url",
"imprint_name", "imprint_email", "imprint_address", "imprint_extra", "privacy_mode", "privacy_url", "privacy_extra", "measurements_public"];
"imprint_name", "imprint_email", "imprint_address", "imprint_extra", "privacy_mode", "privacy_url", "privacy_extra", "measurements_public",
"notice_enabled", "notice_kind", "notice_text", "welcome_enabled"];

async function accessPage(body) {
const c = await api("/api/admin/appconfig");
Expand All @@ -51,7 +52,13 @@ async function accessPage(body) {
${check("user_enabled", "enabled")}<div class="grid2">${input("user_name", "User name")}${input("user_password", "Password", "password")}</div></section>
<section><h3>${t("Visitors")}</h3>
<p class="muted">${t("Visitors without login see projects marked “public” (read only).")}</p>
${check("measurements_public", "Visitors may see measurements (without time and notes)")}</section>
${check("measurements_public", "Visitors may see measurements (without time and notes)")}
${check("welcome_enabled", "Show the welcome page to first-time visitors")}</section>
<section><h3>${t("Notice banner")}</h3>
<p class="muted">${t("A strip below the top bar for everybody – e.g. while you are working on the site. Visitors can close it until the next visit.")}</p>
${check("notice_enabled", "Show the notice")}
${select("notice_kind", "Kind", [["beta", "Beta – things may change"], ["construction", "Under construction – I am working on it right now"], ["maintenance", "Maintenance – the site may be gone for a while"], ["updates", "Updates are coming"], ["custom", "Own text only"]])}
<label>${t("Own text (optional, added to the notice)")}<textarea data-k="notice_text" rows="2" maxlength="500">${escapeHtml(c.notice_text || "")}</textarea></label></section>
<section><h3>${t("Website")}</h3>
<div class="grid2">${input("parent_name", "Belongs to (name)")}${input("parent_url", "Belongs to (link)")}</div>
${check("show_github", "Show link to the source code")}
Expand Down
37 changes: 23 additions & 14 deletions app/static/js/inspector.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { map } from "./map/map.js";
import { RADIO_PRESETS, LORA_REQUIRED_SNR, sensitivityDbm, noiseFloorDbm, estimatedVerticalBeamwidth, applyCatalogAntenna, drawStation, removeStationMarker, startPlacing, newStation, withoutLabel } from "./map/stations.js";
import { compute, resultsByStation, sampleStation, setSolo, view, requiredMargin } from "./map/coverage.js";
import { showProfile } from "./profile.js";
import { net, nodeByPk, linkTo, snrText, showNeighbours, clearNetworkSelection, setFocus, loadMeta, saveOverride, nearbyNodes, repeaterName, enabled as networkEnabled, nodePosition } from "./network.js";
import { net, nodeByPk, linkTo, snrText, mastHeightGuess, showNeighbours, clearNetworkSelection, setFocus, loadMeta, saveOverride, nearbyNodes, repeaterName, enabled as networkEnabled, nodePosition } from "./network.js";
import { meas, seriesById, seriesName, receiverText, receiverFor, loadMeasurements, focusSeries } from "./measurements.js";
import { exportPdf } from "./workspaces/report.js";

Expand Down Expand Up @@ -234,7 +234,7 @@ function repeaterOverview(body, pk) {
: `<p class="muted">${t("MeshCore does not announce height, antenna and power – enter them below if known.")}</p>`}
<div class="actions">
<button data-a="nb" class="primary">${isCentre ? t("Hide neighbours") : t("Show neighbours")}</button>
${st ? `<button data-a="station">${t("Open station “{name}”", { name: escapeHtml(st.name) })}</button>` : isAdmin() ? `<button data-a="adopt">${t("Add as station")}</button>` : ""}
${st ? `<button data-a="station">${t("Open station “{name}”", { name: escapeHtml(st.name) })}</button>` : canEditProject() ? `<button data-a="adopt">${t("Add as station")}</button>` : ""}
</div>
${!st && isAdmin() ? `<h4>${t("Values for the comparison")}</h4><div class="grid2">
<label>${t("Height (m)")}<input data-o="height_m" type="number" step="1" value="${own.height_m ?? ""}"></label>
Expand Down Expand Up @@ -267,21 +267,27 @@ function repeaterMeasurements(body, pk) {
if (b) b.onclick = () => { emit("compare-station", { stationId: stationOfRepeater(pk).id }); openWorkspace("compare"); };
}

function adoptRepeater(pk) {
/** Take a CoreScope repeater over as a station of the project (#45: also several at once from "Plan"). */
export async function adoptRepeater(pk, { open = true } = {}) {
const existing = stationOfRepeater(pk);
if (existing) { inspectStation(existing); return; }
if (existing) { if (open) inspectStation(existing); return existing; }
const n = nodeByPk(pk) || linkTo(pk);
if (!n) return;
if (!n) return null;
const own = net.overrides[pk] || {};
const roof = own.height_m == null ? await mastHeightGuess(n.lat, n.lon) : null;
const st = newStation(n.lat, n.lon);
Object.assign(st, { name: n.name || st.name, corescope_pk: pk, height_m: own.height_m ?? 10, height_ref: "ground",
notes: t("Taken over from CoreScope. Height, antenna and power are assumptions – please check.") });
Object.assign(st, { name: n.name || st.name, corescope_pk: pk, height_m: own.height_m ?? roof ?? 10, height_ref: "ground",
notes: (roof != null ? t("Height: roof in the surface model + 1 m.") + " " : "")
+ t("Taken over from CoreScope. Height, antenna and power are assumptions – please check.") });
if (own.gain_dbi != null) st.antenna = { ...st.antenna, gain_dbi: own.gain_dbi, catalog_id: undefined };
state.project.stations.push(st); drawStation(st);
if (net.focusPk === pk) setFocus(null);
emit("stations-changed"); emit("project-changed", { station: st });
inspectStation(st, "settings");
status(t("“{name}” added – check height, antenna and power, then save.", { name: st.name }));
if (open) {
inspectStation(st, "settings");
status(t("“{name}” added – check height, antenna and power, then save.", { name: st.name }));
}
return st;
}

// ================================================================ link between two repeaters (#26)
Expand Down Expand Up @@ -315,10 +321,11 @@ export function inspectLink(centrePk, partnerPk) {
if (centreStation) openLinkProfile(centreStation, partnerStation, link); // #26: profile right away
}

function openLinkProfile(centreStation, partnerStation, link) {
async function openLinkProfile(centreStation, partnerStation, link) {
if (partnerStation) { showProfile(centreStation.id, null, { rxStationId: partnerStation.id }); return; }
const own = net.overrides[link.pk] || {};
showProfile(centreStation.id, L.latLng(link.lat, link.lon), { rx: { h: own.height_m ?? 10, g: own.gain_dbi ?? 3, c: 1, label: link.name || link.pk.slice(0, 8) }, repeaterPk: link.pk });
const roof = own.height_m == null && isMember() ? await mastHeightGuess(link.lat, link.lon) : null;
showProfile(centreStation.id, L.latLng(link.lat, link.lon), { rx: { h: own.height_m ?? roof ?? 10, g: own.gain_dbi ?? 3, c: 1, label: link.name || link.pk.slice(0, 8) }, repeaterPk: link.pk });
}

// ================================================================ measurement ping (#27)
Expand Down Expand Up @@ -373,9 +380,10 @@ const receiverSummary = (r) => `${fmt(r.h, 1)} m · ${fmt(r.g, 1)} dBi${r.c ? `
/**
* Profile from the repeater that heard a ping to the place of the ping, with the receiver of its series.
* A repeater that is not a station of the project is computed as a temporary station (members only):
* radio values like the last station, height and gain from the values entered for the repeater, else assumptions.
* radio values like the last station, height and gain from the values entered for the repeater, else the roof
* height nearby + 1 m (#43) or 10 m.
*/
function openPingProfile(ping, row) {
async function openPingProfile(ping, row) {
if (!row?.rep_pk) return;
const rx = { ...receiverFor(row, seriesById(row.series_id)), label: t("measurement point") };
const station = stationOfRepeater(row.rep_pk);
Expand All @@ -384,8 +392,9 @@ function openPingProfile(ping, row) {
if (!node || !isMember()) { status(t("A profile needs the repeater as a station of the project.")); return; }
const own = net.overrides[row.rep_pk] || {};
const temporary = newStation(node.lat, node.lon);
const roof = own.height_m == null ? await mastHeightGuess(node.lat, node.lon) : null;
Object.assign(temporary, { id: "tmp-" + row.rep_pk.slice(0, 12), name: node.name || row.rep_pk.slice(0, 8), corescope_pk: row.rep_pk,
height_m: own.height_m ?? 10, height_ref: "ground" });
height_m: own.height_m ?? roof ?? 10, height_ref: "ground" });
if (own.gain_dbi != null) temporary.antenna = { ...temporary.antenna, gain_dbi: own.gain_dbi, catalog_id: undefined };
showProfile(temporary.id, L.latLng(ping.lat, ping.lon), { rx, txStation: temporary });
}
Expand Down
14 changes: 11 additions & 3 deletions app/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { renderCompare } from "./workspaces/compare.js";
import { renderReport } from "./workspaces/report.js";
import { openAdmin } from "./admin.js";
import { openMembers } from "./members.js";
import { openWelcome, maybeWelcome, renderNotice } from "./welcome.js";

// ---------------------------------------------------------------- projects
function emptyProject() { return { id: null, name: t("New project"), public: false, owner: state.role || "admin", stations: [] }; }
Expand Down Expand Up @@ -215,12 +216,13 @@ function wire() {
$("btnProjectMenu").onclick = (e) => { e.stopPropagation(); openProjectMenu(); };
document.addEventListener("click", (e) => { if (!e.target.closest("#projectMenu, #btnProjectMenu")) $("projectMenu").classList.add("hidden"); });
$("btnAdmin").onclick = () => openAdmin();
$("btnHelp").onclick = () => openWelcome();

const langSelect = $("langSelect");
langSelect.value = lang();
langSelect.onchange = async () => {
await setLanguage(langSelect.value, { remember: true });
applyRole(); renderSiteLinks(); refreshLayersPanel(); redrawAllStations();
applyRole(); renderSiteLinks(); renderNotice(); refreshLayersPanel(); redrawAllStations();
for (const render of [renderPlan, renderMeasure, renderCompare, renderReport]) render();
initNetworkWorkspace();
closeInspector();
Expand All @@ -236,10 +238,10 @@ on("project-changed", (payload = {}) => {
setDirty(true);
if (payload.station && !payload.keepCoverage) stale.add(payload.station.id);
});
on("config-reloaded", () => { applyRole(); renderSiteLinks(); });
on("config-reloaded", () => { applyRole(); renderSiteLinks(); renderNotice(); });
on("settings-changed", () => { resetCoverage(); emit("project-loaded"); });

async function init() {
async function start() {
await setLanguage(detectLanguage());
state.config = await api("/api/config");
state.role = state.config.auth?.role || null;
Expand All @@ -248,6 +250,7 @@ async function init() {
applyRole();
wire();
renderSiteLinks();
renderNotice();
openWorkspace("plan", { openSheet: false });
state.project = emptyProject();
try { state.catalog = await api("/api/catalog"); emit("catalog-loaded"); } catch (e) { console.warn("catalog", e); }
Expand Down Expand Up @@ -275,4 +278,9 @@ async function init() {
else status(t("No public projects yet."));
}

async function init() {
await start();
maybeWelcome();
}

init().catch((e) => { console.error(e); status(t("Start failed: {msg}", { msg: e.message }), "err"); });
Loading
Loading