Compare commits

..

14 Commits

Author SHA1 Message Date
l5y 96a3bb86e9 Add telemetry formatting module and overlay metrics (#387) 2025-10-19 12:13:32 +02:00
l5y 6775de3cca Prune blank values from API responses (#386) 2025-10-18 20:16:14 +02:00
l5y 8143fbd8f7 Add full support to telemetry schema and API (#385)
* feat: auto-upgrade telemetry schema

* Ensure numeric metrics fallback to valid values

* Format data processing numeric metric lookup
2025-10-18 15:19:33 +02:00
l5y cf3949ef95 Respect PORT environment override (#384) 2025-10-18 13:01:48 +02:00
l5y 32d9da2865 Add instance selector dropdown for federation deployments (#382)
* Add instance selector for federation regions

* Avoid HTML insertion when seeding instance selector
2025-10-18 10:53:26 +02:00
l5y 61e8c92f62 Harden federation announcements (#381) 2025-10-18 10:38:28 +02:00
l5y d954df6294 Ensure private mode disables federation (#380) 2025-10-18 09:48:40 +02:00
l5y 30d535bd43 Ensure private mode disables chat messaging (#378) 2025-10-17 22:47:54 +02:00
l5y d06aa42ab2 Respect FEDERATION flag for federation endpoints (#379) 2025-10-17 22:47:41 +02:00
l5y 108fc93ca1 Expose PRIVATE environment configuration (#377) 2025-10-17 22:43:42 +02:00
l5y 427479c1e6 Fix frontend coverage export for Codecov (#376)
* fix: export frontend coverage for codecov

* Merge V8 file coverages across workers
2025-10-17 22:43:23 +02:00
l5y ee05f312e8 Restrict instance API to recent updates (#374) 2025-10-17 22:17:49 +02:00
l5y c4193e38dc Document and expose federation configuration (#375) 2025-10-17 22:17:32 +02:00
l5y cb9b081606 Chore: bump version to 0.5.3 (#372) 2025-10-17 19:47:18 +00:00
36 changed files with 3256 additions and 254 deletions
+6
View File
@@ -49,6 +49,12 @@ MAX_DISTANCE=42
# Matrix aliases (e.g. #meshtastic-berlin:matrix.org) will be linked via matrix.to automatically.
CONTACT_LINK='#potatomesh:dod.ngo'
# Enable or disable PotatoMesh federation features (1=enabled, 0=disabled)
FEDERATION=1
# Hide public mesh messages from unauthenticated visitors (1=hidden, 0=public)
PRIVATE=0
# =============================================================================
# ADVANCED SETTINGS
+16
View File
@@ -1,5 +1,21 @@
# CHANGELOG
## v0.5.2
* Align theme and info controls by @l5yth in <https://github.com/l5yth/potato-mesh/pull/371>
* Fixes POST request 403 errors on instances behind Cloudflare proxy by @varna9000 in <https://github.com/l5yth/potato-mesh/pull/368>
* Delay initial federation announcements by @l5yth in <https://github.com/l5yth/potato-mesh/pull/366>
* Ensure well-known document stays in sync on startup by @l5yth in <https://github.com/l5yth/potato-mesh/pull/365>
* Guard federation DNS resolution against restricted networks by @l5yth in <https://github.com/l5yth/potato-mesh/pull/362>
* Add federation ingestion limits and tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/364>
* Prefer reported primary channel names by @l5yth in <https://github.com/l5yth/potato-mesh/pull/363>
* Decouple message API node hydration by @l5yth in <https://github.com/l5yth/potato-mesh/pull/360>
* Fix ingestor reconnection detection by @l5yth in <https://github.com/l5yth/potato-mesh/pull/361>
* Harden instance domain validation by @l5yth in <https://github.com/l5yth/potato-mesh/pull/359>
* Ensure INSTANCE_DOMAIN propagates to containers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/358>
* Chore: bump version to 0.5.2 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/356>
* Gracefully retry federation announcements over HTTP by @l5yth in <https://github.com/l5yth/potato-mesh/pull/355>
## v0.5.1
* Recursively ingest federated instances by @l5yth in <https://github.com/l5yth/potato-mesh/pull/353>
+12
View File
@@ -78,6 +78,7 @@ The web app can be configured with environment variables (defaults shown):
* `CONTACT_LINK` - chat link or Matrix alias for footer and overlay (default: `#potatomesh:dod.ngo`)
* `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset)
* `INSTANCE_DOMAIN` - public hostname (optionally with port) used for metadata, federation, and API links (default: auto-detected)
* `FEDERATION` - set to `1` to announce your instance and crawl peers, or `0` to disable federation (default: `1`)
The application derives SEO-friendly document titles, descriptions, and social
preview tags from these existing configuration values and reuses the bundled
@@ -101,6 +102,17 @@ well-known document is staged in
The database can be found in `$XDG_DATA_HOME/potato-mesh`.
### Federation
PotatoMesh instances can optionally federate by publishing signed metadata and
discovering peers. Federation is enabled by default and controlled with the
`FEDERATION` environment variable. Set `FEDERATION=1` (default) to announce your
instance, respond to remote crawlers, and crawl the wider network. Set
`FEDERATION=0` to keep your deployment isolated—federation requests will be
ignored and the ingestor will skip discovery tasks. Private mode still takes
precedence; when `PRIVATE=1`, federation features remain disabled regardless of
the `FEDERATION` value.
### API
The web app contains an API:
+24
View File
@@ -70,6 +70,8 @@ update_env() {
SITE_NAME=$(grep "^SITE_NAME=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "PotatoMesh Demo")
CHANNEL=$(grep "^CHANNEL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#LongFast")
FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "915MHz")
FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1")
PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833")
MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42")
CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo")
@@ -94,6 +96,20 @@ echo "💬 Optional Settings"
echo "-------------------"
read_with_default "Chat link or Matrix room (optional)" "$CONTACT_LINK" CONTACT_LINK
echo ""
echo "🤝 Federation Settings"
echo "----------------------"
echo "Federation shares instance metadata with other PotatoMesh deployments."
echo "Set to 1 to enable discovery or 0 to keep your instance isolated."
read_with_default "Enable federation (1=yes, 0=no)" "$FEDERATION" FEDERATION
echo ""
echo "🙈 Privacy Settings"
echo "-------------------"
echo "Private mode hides public mesh messages from unauthenticated visitors."
echo "Set to 1 to hide public feeds or 0 to keep them visible."
read_with_default "Enable private mode (1=yes, 0=no)" "$PRIVATE" PRIVATE
echo ""
echo "🛠 Docker Settings"
echo "------------------"
@@ -150,6 +166,8 @@ update_env "MAX_DISTANCE" "$MAX_DISTANCE"
update_env "CONTACT_LINK" "\"$CONTACT_LINK\""
update_env "API_TOKEN" "$API_TOKEN"
update_env "POTATOMESH_IMAGE_ARCH" "$POTATOMESH_IMAGE_ARCH"
update_env "FEDERATION" "$FEDERATION"
update_env "PRIVATE" "$PRIVATE"
if [ -n "$INSTANCE_DOMAIN" ]; then
update_env "INSTANCE_DOMAIN" "$INSTANCE_DOMAIN"
else
@@ -189,7 +207,13 @@ echo " Frequency: $FREQUENCY"
echo " Chat: ${CONTACT_LINK:-'Not set'}"
echo " API Token: ${API_TOKEN:0:8}..."
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
echo " Private Mode: ${PRIVATE}"
echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}"
if [ "${FEDERATION:-1}" = "0" ]; then
echo " Federation: Disabled"
else
echo " Federation: Enabled"
fi
echo ""
echo "🚀 You can now start PotatoMesh with:"
echo " docker-compose up -d"
+219
View File
@@ -460,6 +460,189 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None:
)
)
current = _coerce_float(
_first(
telemetry_section,
"current",
"deviceMetrics.current",
"deviceMetrics.current_ma",
"deviceMetrics.currentMa",
"environmentMetrics.current",
default=None,
)
)
gas_resistance = _coerce_float(
_first(
telemetry_section,
"gasResistance",
"gas_resistance",
"environmentMetrics.gasResistance",
"environmentMetrics.gas_resistance",
default=None,
)
)
iaq = _coerce_int(
_first(
telemetry_section,
"iaq",
"environmentMetrics.iaq",
"environmentMetrics.iaqIndex",
"environmentMetrics.iaq_index",
default=None,
)
)
distance = _coerce_float(
_first(
telemetry_section,
"distance",
"environmentMetrics.distance",
"environmentMetrics.range",
"environmentMetrics.rangeMeters",
default=None,
)
)
lux = _coerce_float(
_first(
telemetry_section,
"lux",
"environmentMetrics.lux",
"environmentMetrics.illuminance",
default=None,
)
)
white_lux = _coerce_float(
_first(
telemetry_section,
"whiteLux",
"white_lux",
"environmentMetrics.whiteLux",
"environmentMetrics.white_lux",
default=None,
)
)
ir_lux = _coerce_float(
_first(
telemetry_section,
"irLux",
"ir_lux",
"environmentMetrics.irLux",
"environmentMetrics.ir_lux",
default=None,
)
)
uv_lux = _coerce_float(
_first(
telemetry_section,
"uvLux",
"uv_lux",
"environmentMetrics.uvLux",
"environmentMetrics.uv_lux",
"environmentMetrics.uvIndex",
default=None,
)
)
wind_direction = _coerce_int(
_first(
telemetry_section,
"windDirection",
"wind_direction",
"environmentMetrics.windDirection",
"environmentMetrics.wind_direction",
default=None,
)
)
wind_speed = _coerce_float(
_first(
telemetry_section,
"windSpeed",
"wind_speed",
"environmentMetrics.windSpeed",
"environmentMetrics.wind_speed",
"environmentMetrics.windSpeedMps",
default=None,
)
)
wind_gust = _coerce_float(
_first(
telemetry_section,
"windGust",
"wind_gust",
"environmentMetrics.windGust",
"environmentMetrics.wind_gust",
default=None,
)
)
wind_lull = _coerce_float(
_first(
telemetry_section,
"windLull",
"wind_lull",
"environmentMetrics.windLull",
"environmentMetrics.wind_lull",
default=None,
)
)
weight = _coerce_float(
_first(
telemetry_section,
"weight",
"environmentMetrics.weight",
"environmentMetrics.mass",
default=None,
)
)
radiation = _coerce_float(
_first(
telemetry_section,
"radiation",
"environmentMetrics.radiation",
"environmentMetrics.radiationLevel",
default=None,
)
)
rainfall_1h = _coerce_float(
_first(
telemetry_section,
"rainfall1h",
"rainfall_1h",
"environmentMetrics.rainfall1h",
"environmentMetrics.rainfall_1h",
"environmentMetrics.rainfallOneHour",
default=None,
)
)
rainfall_24h = _coerce_float(
_first(
telemetry_section,
"rainfall24h",
"rainfall_24h",
"environmentMetrics.rainfall24h",
"environmentMetrics.rainfall_24h",
"environmentMetrics.rainfallTwentyFourHour",
default=None,
)
)
soil_moisture = _coerce_int(
_first(
telemetry_section,
"soilMoisture",
"soil_moisture",
"environmentMetrics.soilMoisture",
"environmentMetrics.soil_moisture",
default=None,
)
)
soil_temperature = _coerce_float(
_first(
telemetry_section,
"soilTemperature",
"soil_temperature",
"environmentMetrics.soilTemperature",
"environmentMetrics.soil_temperature",
default=None,
)
)
telemetry_payload = {
"id": pkt_id,
"node_id": node_id,
@@ -494,6 +677,42 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None:
telemetry_payload["relative_humidity"] = relative_humidity
if barometric_pressure is not None:
telemetry_payload["barometric_pressure"] = barometric_pressure
if current is not None:
telemetry_payload["current"] = current
if gas_resistance is not None:
telemetry_payload["gas_resistance"] = gas_resistance
if iaq is not None:
telemetry_payload["iaq"] = iaq
if distance is not None:
telemetry_payload["distance"] = distance
if lux is not None:
telemetry_payload["lux"] = lux
if white_lux is not None:
telemetry_payload["white_lux"] = white_lux
if ir_lux is not None:
telemetry_payload["ir_lux"] = ir_lux
if uv_lux is not None:
telemetry_payload["uv_lux"] = uv_lux
if wind_direction is not None:
telemetry_payload["wind_direction"] = wind_direction
if wind_speed is not None:
telemetry_payload["wind_speed"] = wind_speed
if wind_gust is not None:
telemetry_payload["wind_gust"] = wind_gust
if wind_lull is not None:
telemetry_payload["wind_lull"] = wind_lull
if weight is not None:
telemetry_payload["weight"] = weight
if radiation is not None:
telemetry_payload["radiation"] = radiation
if rainfall_1h is not None:
telemetry_payload["rainfall_1h"] = rainfall_1h
if rainfall_24h is not None:
telemetry_payload["rainfall_24h"] = rainfall_24h
if soil_moisture is not None:
telemetry_payload["soil_moisture"] = soil_moisture
if soil_temperature is not None:
telemetry_payload["soil_temperature"] = soil_temperature
_queue_post_json(
"/api/telemetry",
@@ -0,0 +1,35 @@
-- Copyright (C) 2025 l5yth
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Extend the telemetry table with additional environment metrics.
BEGIN;
ALTER TABLE telemetry ADD COLUMN gas_resistance REAL;
ALTER TABLE telemetry ADD COLUMN current REAL;
ALTER TABLE telemetry ADD COLUMN iaq INTEGER;
ALTER TABLE telemetry ADD COLUMN distance REAL;
ALTER TABLE telemetry ADD COLUMN lux REAL;
ALTER TABLE telemetry ADD COLUMN white_lux REAL;
ALTER TABLE telemetry ADD COLUMN ir_lux REAL;
ALTER TABLE telemetry ADD COLUMN uv_lux REAL;
ALTER TABLE telemetry ADD COLUMN wind_direction INTEGER;
ALTER TABLE telemetry ADD COLUMN wind_speed REAL;
ALTER TABLE telemetry ADD COLUMN weight REAL;
ALTER TABLE telemetry ADD COLUMN wind_gust REAL;
ALTER TABLE telemetry ADD COLUMN wind_lull REAL;
ALTER TABLE telemetry ADD COLUMN radiation REAL;
ALTER TABLE telemetry ADD COLUMN rainfall_1h REAL;
ALTER TABLE telemetry ADD COLUMN rainfall_24h REAL;
ALTER TABLE telemetry ADD COLUMN soil_moisture INTEGER;
ALTER TABLE telemetry ADD COLUMN soil_temperature REAL;
COMMIT;
+19 -1
View File
@@ -35,7 +35,25 @@ CREATE TABLE IF NOT EXISTS telemetry (
uptime_seconds INTEGER,
temperature REAL,
relative_humidity REAL,
barometric_pressure REAL
barometric_pressure REAL,
gas_resistance REAL,
current REAL,
iaq INTEGER,
distance REAL,
lux REAL,
white_lux REAL,
ir_lux REAL,
uv_lux REAL,
wind_direction INTEGER,
wind_speed REAL,
weight REAL,
wind_gust REAL,
wind_lull REAL,
radiation REAL,
rainfall_1h REAL,
rainfall_24h REAL,
soil_moisture INTEGER,
soil_temperature REAL
);
CREATE INDEX IF NOT EXISTS idx_telemetry_rx_time ON telemetry(rx_time);
+4
View File
@@ -9,6 +9,8 @@ x-web-base: &web-base
MAP_CENTER: ${MAP_CENTER:-38.761944,-27.090833}
MAX_DISTANCE: ${MAX_DISTANCE:-42}
CONTACT_LINK: ${CONTACT_LINK:-#potatomesh:dod.ngo}
FEDERATION: ${FEDERATION:-1}
PRIVATE: ${PRIVATE:-0}
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
DEBUG: ${DEBUG:-0}
@@ -36,6 +38,8 @@ x-ingestor-base: &ingestor-base
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
DEBUG: ${DEBUG:-0}
FEDERATION: ${FEDERATION:-1}
PRIVATE: ${PRIVATE:-0}
volumes:
- potatomesh_data:/app/.local/share/potato-mesh
- potatomesh_config:/app/.config/potato-mesh
+54 -3
View File
@@ -12,12 +12,31 @@
"battery_level": 101,
"bitfield": 1,
"payload_b64": "DTVr0mgSFQhlFQIrh0AdJb8YPyXYFSA9KJTPEg==",
"current": 0.0715,
"gas_resistance": 1456.0,
"iaq": 83,
"distance": 12.5,
"lux": 100.25,
"white_lux": 64.5,
"ir_lux": 12.75,
"uv_lux": 1.6,
"wind_direction": 270,
"wind_speed": 5.9,
"wind_gust": 7.4,
"wind_lull": 4.8,
"weight": 32.7,
"radiation": 0.45,
"rainfall_1h": 0.18,
"rainfall_24h": 1.42,
"soil_moisture": 3100,
"soil_temperature": 18.9,
"device_metrics": {
"batteryLevel": 101,
"voltage": 4.224,
"channelUtilization": 0.59666663,
"airUtilTx": 0.03908333,
"uptimeSeconds": 305044
"uptimeSeconds": 305044,
"current": 0.0715
},
"raw": {
"device_metrics": {
@@ -43,7 +62,24 @@
"environment_metrics": {
"temperature": 21.98,
"relativeHumidity": 39.475586,
"barometricPressure": 1017.8353
"barometricPressure": 1017.8353,
"gasResistance": 1456.0,
"iaq": 83,
"distance": 12.5,
"lux": 100.25,
"whiteLux": 64.5,
"irLux": 12.75,
"uvLux": 1.6,
"windDirection": 270,
"windSpeed": 5.9,
"windGust": 7.4,
"windLull": 4.8,
"weight": 32.7,
"radiation": 0.45,
"rainfall1h": 0.18,
"rainfall24h": 1.42,
"soilMoisture": 3100,
"soilTemperature": 18.9
},
"raw": {
"environment_metrics": {
@@ -70,7 +106,22 @@
"voltage": 3.92,
"channel_utilization": 0.284,
"air_util_tx": 0.051,
"uptime_seconds": 86400
"uptime_seconds": 86400,
"current": 0.033
},
"environment_metrics": {
"temperature": 19.5,
"relative_humidity": 48.2,
"barometric_pressure": 1013.1,
"distance": 7.25,
"lux": 75.5,
"whiteLux": 40.0,
"windDirection": 180,
"windSpeed": 4.3,
"weight": 28.4,
"rainfall24h": 0.75,
"soilMoisture": 2850,
"soilTemperature": 17.1
},
"local_stats": {
"numPacketsTx": 1280,
+36
View File
@@ -1575,6 +1575,7 @@ def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch):
"channelUtilization": 0.59666663,
"airUtilTx": 0.03908333,
"uptimeSeconds": 305044,
"current": 0.0715,
},
"localStats": {
"numPacketsTx": 1280,
@@ -1606,6 +1607,7 @@ def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch):
assert payload["channel_utilization"] == pytest.approx(0.59666663)
assert payload["air_util_tx"] == pytest.approx(0.03908333)
assert payload["uptime_seconds"] == 305044
assert payload["current"] == pytest.approx(0.0715)
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
@@ -1634,6 +1636,23 @@ def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatc
"temperature": 21.98,
"relativeHumidity": 39.475586,
"barometricPressure": 1017.8353,
"gasResistance": 1456.0,
"iaq": 83,
"distance": 12.5,
"lux": 100.25,
"whiteLux": 64.5,
"irLux": 12.75,
"uvLux": 1.6,
"windDirection": 270,
"windSpeed": 5.9,
"windGust": 7.4,
"windLull": 4.8,
"weight": 32.7,
"radiation": 0.45,
"rainfall1h": 0.18,
"rainfall24h": 1.42,
"soilMoisture": 3100,
"soilTemperature": 18.9,
},
},
},
@@ -1651,6 +1670,23 @@ def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatc
assert payload["temperature"] == pytest.approx(21.98)
assert payload["relative_humidity"] == pytest.approx(39.475586)
assert payload["barometric_pressure"] == pytest.approx(1017.8353)
assert payload["gas_resistance"] == pytest.approx(1456.0)
assert payload["iaq"] == 83
assert payload["distance"] == pytest.approx(12.5)
assert payload["lux"] == pytest.approx(100.25)
assert payload["white_lux"] == pytest.approx(64.5)
assert payload["ir_lux"] == pytest.approx(12.75)
assert payload["uv_lux"] == pytest.approx(1.6)
assert payload["wind_direction"] == 270
assert payload["wind_speed"] == pytest.approx(5.9)
assert payload["wind_gust"] == pytest.approx(7.4)
assert payload["wind_lull"] == pytest.approx(4.8)
assert payload["weight"] == pytest.approx(32.7)
assert payload["radiation"] == pytest.approx(0.45)
assert payload["rainfall_1h"] == pytest.approx(0.18)
assert payload["rainfall_24h"] == pytest.approx(1.42)
assert payload["soil_moisture"] == 3100
assert payload["soil_temperature"] == pytest.approx(18.9)
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
+21 -3
View File
@@ -100,12 +100,30 @@ module PotatoMesh
logger.level = PotatoMesh::Config.debug? ? Logger::DEBUG : Logger::WARN
end
# Determine the port the application should listen on.
# Determine the port the application should listen on by honouring the
# conventional +PORT+ environment variable used by hosting platforms. Any
# non-numeric or out-of-range values fall back to the provided default to
# keep the application bootable in misconfigured environments.
#
# @param default_port [Integer] fallback port when ENV['PORT'] is absent or invalid.
# @param default_port [Integer] fallback port when +ENV['PORT']+ is absent or invalid.
# @return [Integer] port number for the HTTP server.
def self.resolve_port(default_port: DEFAULT_PORT)
default_port
raw_port = ENV["PORT"]
return default_port if raw_port.nil?
trimmed = raw_port.to_s.strip
return default_port if trimmed.empty?
begin
port = Integer(trimmed, 10)
rescue ArgumentError
return default_port
end
return default_port unless port.positive?
return default_port unless PotatoMesh::Sanitizer.valid_port?(trimmed)
port
end
configure do
@@ -731,6 +731,50 @@ module PotatoMesh
end
end
# Resolve a telemetry metric from the provided data sources.
#
# @param key_map [Hash{Symbol=>Array<String>}] ordered mapping of source names to candidate keys.
# @param sources [Hash{Symbol=>Hash}] data structures to search for metric values.
# @param type [Symbol] coercion strategy, ``:float`` or ``:integer``.
# @return [Numeric, nil] coerced metric value or nil when no candidates exist.
def resolve_numeric_metric(key_map, sources, type)
key_map.each do |source, keys|
next if keys.nil? || keys.empty?
data = sources[source]
next unless data.is_a?(Hash)
keys.each do |name|
next if name.nil?
key = name.to_s
value = if data.key?(key)
data[key]
else
sym_key = key.to_sym
data.key?(sym_key) ? data[sym_key] : nil
end
next if value.nil?
coerced = case type
when :float
coerce_float(value)
when :integer
coerce_integer(value)
else
value
end
return coerced unless coerced.nil?
end
end
nil
end
private :resolve_numeric_metric
def insert_telemetry(db, payload)
return unless payload.is_a?(Hash)
@@ -776,54 +820,285 @@ module PotatoMesh
environment_metrics = normalize_json_object(payload["environment_metrics"] || payload["environmentMetrics"])
environment_metrics ||= normalize_json_object(telemetry_section["environmentMetrics"]) if telemetry_section&.key?("environmentMetrics")
fetch_metric = lambda do |map, *names|
next nil unless map.is_a?(Hash)
names.each do |name|
next unless name
key = name.to_s
return map[key] if map.key?(key)
end
nil
sources = {
payload: payload,
telemetry: telemetry_section,
device: device_metrics,
environment: environment_metrics,
}
metric_definitions = [
[
"battery_level",
:float,
{
payload: %w[battery_level batteryLevel],
telemetry: %w[batteryLevel],
device: %w[battery_level batteryLevel],
environment: %w[battery_level batteryLevel],
},
],
[
"voltage",
:float,
{
payload: %w[voltage],
telemetry: %w[voltage],
device: %w[voltage],
environment: %w[voltage],
},
],
[
"channel_utilization",
:float,
{
payload: %w[channel_utilization channelUtilization],
telemetry: %w[channelUtilization],
device: %w[channel_utilization channelUtilization],
},
],
[
"air_util_tx",
:float,
{
payload: %w[air_util_tx airUtilTx],
telemetry: %w[airUtilTx],
device: %w[air_util_tx airUtilTx],
},
],
[
"uptime_seconds",
:integer,
{
payload: %w[uptime_seconds uptimeSeconds],
telemetry: %w[uptimeSeconds],
device: %w[uptime_seconds uptimeSeconds],
},
],
[
"temperature",
:float,
{
payload: %w[temperature temperatureC tempC],
telemetry: %w[temperature temperatureC tempC],
environment: %w[temperature temperatureC temperature_c tempC],
},
],
[
"relative_humidity",
:float,
{
payload: %w[relative_humidity relativeHumidity humidity],
telemetry: %w[relative_humidity relativeHumidity humidity],
environment: %w[relative_humidity relativeHumidity humidity],
},
],
[
"barometric_pressure",
:float,
{
payload: %w[barometric_pressure barometricPressure pressure],
telemetry: %w[barometric_pressure barometricPressure pressure],
environment: %w[barometric_pressure barometricPressure pressure],
},
],
[
"gas_resistance",
:float,
{
payload: %w[gas_resistance gasResistance],
telemetry: %w[gas_resistance gasResistance],
environment: %w[gas_resistance gasResistance],
},
],
[
"current",
:float,
{
payload: %w[current current_ma currentMa],
telemetry: %w[current current_ma currentMa],
device: %w[current current_ma currentMa],
environment: %w[current],
},
],
[
"iaq",
:integer,
{
payload: %w[iaq iaqIndex iaq_index],
telemetry: %w[iaq iaqIndex iaq_index],
environment: %w[iaq iaqIndex iaq_index],
},
],
[
"distance",
:float,
{
payload: %w[distance range rangeMeters],
telemetry: %w[distance range rangeMeters],
environment: %w[distance range rangeMeters],
},
],
[
"lux",
:float,
{
payload: %w[lux illuminance lightLux],
telemetry: %w[lux illuminance lightLux],
environment: %w[lux illuminance lightLux],
},
],
[
"white_lux",
:float,
{
payload: %w[white_lux whiteLux],
telemetry: %w[white_lux whiteLux],
environment: %w[white_lux whiteLux],
},
],
[
"ir_lux",
:float,
{
payload: %w[ir_lux irLux],
telemetry: %w[ir_lux irLux],
environment: %w[ir_lux irLux],
},
],
[
"uv_lux",
:float,
{
payload: %w[uv_lux uvLux uvIndex],
telemetry: %w[uv_lux uvLux uvIndex],
environment: %w[uv_lux uvLux uvIndex],
},
],
[
"wind_direction",
:integer,
{
payload: %w[wind_direction windDirection],
telemetry: %w[wind_direction windDirection],
environment: %w[wind_direction windDirection],
},
],
[
"wind_speed",
:float,
{
payload: %w[wind_speed windSpeed windSpeedMps],
telemetry: %w[wind_speed windSpeed windSpeedMps],
environment: %w[wind_speed windSpeed windSpeedMps],
},
],
[
"weight",
:float,
{
payload: %w[weight mass],
telemetry: %w[weight mass],
environment: %w[weight mass],
},
],
[
"wind_gust",
:float,
{
payload: %w[wind_gust windGust],
telemetry: %w[wind_gust windGust],
environment: %w[wind_gust windGust],
},
],
[
"wind_lull",
:float,
{
payload: %w[wind_lull windLull],
telemetry: %w[wind_lull windLull],
environment: %w[wind_lull windLull],
},
],
[
"radiation",
:float,
{
payload: %w[radiation radiationLevel],
telemetry: %w[radiation radiationLevel],
environment: %w[radiation radiationLevel],
},
],
[
"rainfall_1h",
:float,
{
payload: %w[rainfall_1h rainfall1h rainfallOneHour],
telemetry: %w[rainfall_1h rainfall1h rainfallOneHour],
environment: %w[rainfall_1h rainfall1h rainfallOneHour],
},
],
[
"rainfall_24h",
:float,
{
payload: %w[rainfall_24h rainfall24h rainfallTwentyFourHour],
telemetry: %w[rainfall_24h rainfall24h rainfallTwentyFourHour],
environment: %w[rainfall_24h rainfall24h rainfallTwentyFourHour],
},
],
[
"soil_moisture",
:integer,
{
payload: %w[soil_moisture soilMoisture],
telemetry: %w[soil_moisture soilMoisture],
environment: %w[soil_moisture soilMoisture],
},
],
[
"soil_temperature",
:float,
{
payload: %w[soil_temperature soilTemperature],
telemetry: %w[soil_temperature soilTemperature],
environment: %w[soil_temperature soilTemperature],
},
],
]
metric_values = {}
metric_definitions.each do |column, type, key_map|
value = resolve_numeric_metric(key_map, sources, type)
metric_values[column] = value unless value.nil?
end
battery_level = payload.key?("battery_level") ? payload["battery_level"] : nil
battery_level = coerce_float(battery_level)
battery_level ||= coerce_float(fetch_metric.call(device_metrics, :battery_level, :batteryLevel))
voltage = payload.key?("voltage") ? payload["voltage"] : nil
voltage = coerce_float(voltage)
voltage ||= coerce_float(fetch_metric.call(device_metrics, :voltage))
channel_utilization = payload.key?("channel_utilization") ? payload["channel_utilization"] : nil
channel_utilization ||= payload["channelUtilization"] if payload.key?("channelUtilization")
channel_utilization = coerce_float(channel_utilization)
channel_utilization ||= coerce_float(fetch_metric.call(device_metrics, :channel_utilization, :channelUtilization))
air_util_tx = payload.key?("air_util_tx") ? payload["air_util_tx"] : nil
air_util_tx ||= payload["airUtilTx"] if payload.key?("airUtilTx")
air_util_tx = coerce_float(air_util_tx)
air_util_tx ||= coerce_float(fetch_metric.call(device_metrics, :air_util_tx, :airUtilTx))
uptime_seconds = payload.key?("uptime_seconds") ? payload["uptime_seconds"] : nil
uptime_seconds ||= payload["uptimeSeconds"] if payload.key?("uptimeSeconds")
uptime_seconds = coerce_integer(uptime_seconds)
uptime_seconds ||= coerce_integer(fetch_metric.call(device_metrics, :uptime_seconds, :uptimeSeconds))
temperature = payload.key?("temperature") ? payload["temperature"] : nil
temperature = coerce_float(temperature)
temperature ||= coerce_float(fetch_metric.call(environment_metrics, :temperature, :temperatureC, :temperature_c, :tempC))
relative_humidity = payload.key?("relative_humidity") ? payload["relative_humidity"] : nil
relative_humidity ||= payload["relativeHumidity"] if payload.key?("relativeHumidity")
relative_humidity ||= payload["humidity"] if payload.key?("humidity")
relative_humidity = coerce_float(relative_humidity)
relative_humidity ||= coerce_float(fetch_metric.call(environment_metrics, :relative_humidity, :relativeHumidity, :humidity))
barometric_pressure = payload.key?("barometric_pressure") ? payload["barometric_pressure"] : nil
barometric_pressure ||= payload["barometricPressure"] if payload.key?("barometricPressure")
barometric_pressure ||= payload["pressure"] if payload.key?("pressure")
barometric_pressure = coerce_float(barometric_pressure)
barometric_pressure ||= coerce_float(fetch_metric.call(environment_metrics, :barometric_pressure, :barometricPressure, :pressure))
battery_level = metric_values["battery_level"]
voltage = metric_values["voltage"]
channel_utilization = metric_values["channel_utilization"]
air_util_tx = metric_values["air_util_tx"]
uptime_seconds = metric_values["uptime_seconds"]
temperature = metric_values["temperature"]
relative_humidity = metric_values["relative_humidity"]
barometric_pressure = metric_values["barometric_pressure"]
gas_resistance = metric_values["gas_resistance"]
current = metric_values["current"]
iaq = metric_values["iaq"]
distance = metric_values["distance"]
lux = metric_values["lux"]
white_lux = metric_values["white_lux"]
ir_lux = metric_values["ir_lux"]
uv_lux = metric_values["uv_lux"]
wind_direction = metric_values["wind_direction"]
wind_speed = metric_values["wind_speed"]
weight = metric_values["weight"]
wind_gust = metric_values["wind_gust"]
wind_lull = metric_values["wind_lull"]
radiation = metric_values["radiation"]
rainfall_1h = metric_values["rainfall_1h"]
rainfall_24h = metric_values["rainfall_24h"]
soil_moisture = metric_values["soil_moisture"]
soil_temperature = metric_values["soil_temperature"]
row = [
telemetry_id,
@@ -849,13 +1124,33 @@ module PotatoMesh
temperature,
relative_humidity,
barometric_pressure,
gas_resistance,
current,
iaq,
distance,
lux,
white_lux,
ir_lux,
uv_lux,
wind_direction,
wind_speed,
weight,
wind_gust,
wind_lull,
radiation,
rainfall_1h,
rainfall_24h,
soil_moisture,
soil_temperature,
]
placeholders = Array.new(row.length, "?").join(",")
with_busy_retry do
db.execute <<~SQL, row
INSERT INTO telemetry(id,node_id,node_num,from_id,to_id,rx_time,rx_iso,telemetry_time,channel,portnum,hop_limit,snr,rssi,bitfield,payload_b64,
battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,temperature,relative_humidity,barometric_pressure)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,temperature,relative_humidity,barometric_pressure,gas_resistance,current,iaq,distance,lux,white_lux,ir_lux,uv_lux,wind_direction,wind_speed,weight,wind_gust,wind_lull,radiation,rainfall_1h,rainfall_24h,soil_moisture,soil_temperature)
VALUES (#{placeholders})
ON CONFLICT(id) DO UPDATE SET
node_id=COALESCE(excluded.node_id,telemetry.node_id),
node_num=COALESCE(excluded.node_num,telemetry.node_num),
@@ -878,7 +1173,25 @@ module PotatoMesh
uptime_seconds=COALESCE(excluded.uptime_seconds,telemetry.uptime_seconds),
temperature=COALESCE(excluded.temperature,telemetry.temperature),
relative_humidity=COALESCE(excluded.relative_humidity,telemetry.relative_humidity),
barometric_pressure=COALESCE(excluded.barometric_pressure,telemetry.barometric_pressure)
barometric_pressure=COALESCE(excluded.barometric_pressure,telemetry.barometric_pressure),
gas_resistance=COALESCE(excluded.gas_resistance,telemetry.gas_resistance),
current=COALESCE(excluded.current,telemetry.current),
iaq=COALESCE(excluded.iaq,telemetry.iaq),
distance=COALESCE(excluded.distance,telemetry.distance),
lux=COALESCE(excluded.lux,telemetry.lux),
white_lux=COALESCE(excluded.white_lux,telemetry.white_lux),
ir_lux=COALESCE(excluded.ir_lux,telemetry.ir_lux),
uv_lux=COALESCE(excluded.uv_lux,telemetry.uv_lux),
wind_direction=COALESCE(excluded.wind_direction,telemetry.wind_direction),
wind_speed=COALESCE(excluded.wind_speed,telemetry.wind_speed),
weight=COALESCE(excluded.weight,telemetry.weight),
wind_gust=COALESCE(excluded.wind_gust,telemetry.wind_gust),
wind_lull=COALESCE(excluded.wind_lull,telemetry.wind_lull),
radiation=COALESCE(excluded.radiation,telemetry.radiation),
rainfall_1h=COALESCE(excluded.rainfall_1h,telemetry.rainfall_1h),
rainfall_24h=COALESCE(excluded.rainfall_24h,telemetry.rainfall_24h),
soil_moisture=COALESCE(excluded.soil_moisture,telemetry.soil_moisture),
soil_temperature=COALESCE(excluded.soil_temperature,telemetry.soil_temperature)
SQL
end
@@ -15,6 +15,30 @@
module PotatoMesh
module App
module Database
# Column definitions required for environment telemetry support. Each
# entry pairs the column name with the SQL type used when backfilling
# legacy databases that pre-date the extended telemetry schema.
TELEMETRY_COLUMN_DEFINITIONS = [
["gas_resistance", "REAL"],
["current", "REAL"],
["iaq", "INTEGER"],
["distance", "REAL"],
["lux", "REAL"],
["white_lux", "REAL"],
["ir_lux", "REAL"],
["uv_lux", "REAL"],
["wind_direction", "INTEGER"],
["wind_speed", "REAL"],
["weight", "REAL"],
["wind_gust", "REAL"],
["wind_lull", "REAL"],
["radiation", "REAL"],
["rainfall_1h", "REAL"],
["rainfall_24h", "REAL"],
["soil_moisture", "INTEGER"],
["soil_temperature", "REAL"],
].freeze
# Open a connection to the application database applying common pragmas.
#
# @param readonly [Boolean] whether to open the database in read-only mode.
@@ -119,6 +143,21 @@ module PotatoMesh
sql_file = File.expand_path("../../../../data/instances.sql", __dir__)
db.execute_batch(File.read(sql_file))
end
telemetry_tables =
db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='telemetry'").flatten
if telemetry_tables.empty?
telemetry_schema = File.expand_path("../../../../data/telemetry.sql", __dir__)
db.execute_batch(File.read(telemetry_schema))
end
telemetry_columns = db.execute("PRAGMA table_info(telemetry)").map { |row| row[1] }
TELEMETRY_COLUMN_DEFINITIONS.each do |name, type|
next if telemetry_columns.include?(name)
db.execute("ALTER TABLE telemetry ADD COLUMN #{name} #{type}")
telemetry_columns << name
end
rescue SQLite3::SQLException, Errno::ENOENT => e
warn_log(
"Failed to apply schema upgrade",
+46 -7
View File
@@ -128,10 +128,17 @@ module PotatoMesh
db = open_database(readonly: true)
db.results_as_hash = false
rows = with_busy_retry {
db.execute("SELECT domain FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != ''")
}
rows.flatten.compact.each do |raw_domain|
cutoff = Time.now.to_i - PotatoMesh::Config.week_seconds
rows = with_busy_retry do
db.execute(
"SELECT domain, last_update_time FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != ''",
)
end
rows.each do |row|
raw_domain = row[0]
last_update_time = coerce_integer(row[1])
next unless last_update_time && last_update_time >= cutoff
sanitized = sanitize_instance_domain(raw_domain)&.downcase
next unless sanitized
next if normalized_self && sanitized == normalized_self
@@ -162,8 +169,7 @@ module PotatoMesh
begin
http = build_remote_http_client(uri)
response = http.start do |connection|
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request = build_federation_http_request(Net::HTTP::Post, uri)
request.body = payload_json
connection.request(request)
end
@@ -250,6 +256,9 @@ module PotatoMesh
end
def start_federation_announcer!
# Federation broadcasts must not execute when federation support is disabled.
return nil unless federation_enabled?
existing = settings.federation_thread
return existing if existing&.alive?
@@ -277,6 +286,9 @@ module PotatoMesh
#
# @return [Thread, nil] the thread handling the initial announcement.
def start_initial_federation_announcement!
# Skip the initial broadcast entirely when federation is disabled.
return nil unless federation_enabled?
existing = settings.respond_to?(:initial_federation_thread) ? settings.initial_federation_thread : nil
return existing if existing&.alive?
@@ -343,7 +355,8 @@ module PotatoMesh
def perform_instance_http_request(uri)
http = build_remote_http_client(uri)
http.start do |connection|
response = connection.request(Net::HTTP::Get.new(uri))
request = build_federation_http_request(Net::HTTP::Get, uri)
response = connection.request(request)
case response
when Net::HTTPSuccess
response.body
@@ -355,6 +368,32 @@ module PotatoMesh
raise_instance_fetch_error(e)
end
# Build an HTTP request decorated with the headers required for federation peers.
#
# @param request_class [Class<Net::HTTPRequest>] HTTP request class such as {Net::HTTP::Get}.
# @param uri [URI::Generic] target URI describing the remote endpoint.
# @return [Net::HTTPRequest] configured HTTP request including standard headers.
def build_federation_http_request(request_class, uri)
request = request_class.new(uri)
request["User-Agent"] = federation_user_agent_header
request["Accept"] = "application/json"
request["Content-Type"] = "application/json" if request.request_body_permitted?
request
end
# Compose the User-Agent string used when communicating with federation peers.
#
# @return [String] descriptive identifier for PotatoMesh federation requests.
def federation_user_agent_header
version = app_constant(:APP_VERSION).to_s
version = "unknown" if version.empty?
sanitized_domain = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN), downcase: true)
base = "PotatoMesh/#{version}"
return base unless sanitized_domain && !sanitized_domain.empty?
"#{base} (+https://#{sanitized_domain})"
end
# Build a human readable error message for a failed instance request.
#
# @param error [StandardError] failure raised while performing the request.
+3 -2
View File
@@ -123,6 +123,7 @@ module PotatoMesh
maxDistanceKm: PotatoMesh::Config.max_distance_km,
tileFilters: PotatoMesh::Config.tile_filters,
instanceDomain: app_constant(:INSTANCE_DOMAIN),
instancesFeatureEnabled: federation_enabled? && !private_mode?,
}
end
@@ -323,7 +324,7 @@ module PotatoMesh
#
# @return [Boolean] true when PRIVATE=1.
def private_mode?
ENV["PRIVATE"] == "1"
PotatoMesh::Config.private_mode_enabled?
end
# Identify whether the Rack environment corresponds to the test suite.
@@ -337,7 +338,7 @@ module PotatoMesh
#
# @return [Boolean] true when federation configuration allows it.
def federation_enabled?
ENV.fetch("FEDERATION", "1") != "0" && !private_mode?
PotatoMesh::Config.federation_enabled?
end
# Determine whether federation announcements should run asynchronously.
+21 -12
View File
@@ -158,7 +158,8 @@ module PotatoMesh
end
# Fetch all instance rows ready to be served by the API while handling
# malformed rows gracefully.
# malformed rows gracefully. The dataset is restricted to records updated
# within the rolling window defined by PotatoMesh::Config.week_seconds.
#
# @return [Array<Hash>] list of cleaned instance payloads.
def load_instances_for_api
@@ -166,22 +167,30 @@ module PotatoMesh
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
min_last_update_time = now - PotatoMesh::Config.week_seconds
sql = <<~SQL
SELECT id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
FROM instances
WHERE domain IS NOT NULL AND TRIM(domain) != ''
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
AND last_update_time IS NOT NULL AND last_update_time >= ?
ORDER BY LOWER(domain)
SQL
rows = with_busy_retry do
db.execute(
<<~SQL
SELECT id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
FROM instances
WHERE domain IS NOT NULL AND TRIM(domain) != ''
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
ORDER BY LOWER(domain)
SQL
)
db.execute(sql, min_last_update_time)
end
rows.each_with_object([]) do |row, memo|
normalized = normalize_instance_row(row)
memo << normalized if normalized
next unless normalized
last_update_time = normalized["lastUpdateTime"]
next unless last_update_time.is_a?(Integer) && last_update_time >= min_last_update_time
memo << normalized
end
rescue SQLite3::Exception => e
warn_log(
+49 -4
View File
@@ -17,6 +17,33 @@ module PotatoMesh
module Queries
MAX_QUERY_LIMIT = 1000
# Remove nil or empty values from an API response hash to reduce payload size.
# Integer keys emitted by SQLite are ignored because the JSON representation
# only exposes symbolic keys. Strings containing only whitespace are treated
# as empty to mirror sanitisation elsewhere in the application.
#
# @param row [Hash] raw database row to compact.
# @return [Hash] cleaned hash without blank values.
def compact_api_row(row)
return {} unless row.is_a?(Hash)
row.each_with_object({}) do |(key, value), acc|
next if key.is_a?(Integer)
next if value.nil?
if value.is_a?(String)
trimmed = value.strip
next if trimmed.empty?
acc[key] = value
next
end
next if value.respond_to?(:empty?) && value.empty?
acc[key] = value
end
end
# Normalise a caller-provided limit to a sane, positive integer.
#
# @param limit [Object] value coerced to an integer.
@@ -179,7 +206,7 @@ module PotatoMesh
pb = r["precision_bits"]
r["precision_bits"] = pb.to_i if pb
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -301,7 +328,7 @@ module PotatoMesh
r["pdop"] = coerce_float(r["pdop"])
r["snr"] = coerce_float(r["snr"])
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -341,7 +368,7 @@ module PotatoMesh
r["rx_iso"] = Time.at(rx_time).utc.iso8601 if rx_time
r["snr"] = coerce_float(r["snr"])
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -400,8 +427,26 @@ module PotatoMesh
r["temperature"] = coerce_float(r["temperature"])
r["relative_humidity"] = coerce_float(r["relative_humidity"])
r["barometric_pressure"] = coerce_float(r["barometric_pressure"])
r["gas_resistance"] = coerce_float(r["gas_resistance"])
r["current"] = coerce_float(r["current"])
r["iaq"] = coerce_integer(r["iaq"])
r["distance"] = coerce_float(r["distance"])
r["lux"] = coerce_float(r["lux"])
r["white_lux"] = coerce_float(r["white_lux"])
r["ir_lux"] = coerce_float(r["ir_lux"])
r["uv_lux"] = coerce_float(r["uv_lux"])
r["wind_direction"] = coerce_integer(r["wind_direction"])
r["wind_speed"] = coerce_float(r["wind_speed"])
r["weight"] = coerce_float(r["weight"])
r["wind_gust"] = coerce_float(r["wind_gust"])
r["wind_lull"] = coerce_float(r["wind_lull"])
r["radiation"] = coerce_float(r["radiation"])
r["rainfall_1h"] = coerce_float(r["rainfall_1h"])
r["rainfall_24h"] = coerce_float(r["rainfall_24h"])
r["soil_moisture"] = coerce_integer(r["soil_moisture"])
r["soil_temperature"] = coerce_float(r["soil_temperature"])
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -17,6 +17,10 @@ module PotatoMesh
module Routes
module Api
def self.registered(app)
app.before "/api/messages*" do
halt 404 if private_mode?
end
app.get "/version" do
content_type :json
last_update = latest_node_update_timestamp
@@ -67,14 +71,12 @@ module PotatoMesh
end
app.get "/api/messages" do
halt 404 if private_mode?
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_messages(limit).to_json
end
app.get "/api/messages/:id" do
halt 404 if private_mode?
content_type :json
node_ref = string_or_nil(params["id"])
halt 400, { error: "missing node id" }.to_json unless node_ref
@@ -125,6 +127,9 @@ module PotatoMesh
end
app.get "/api/instances" do
# Prevent the federation catalog from being exposed when federation is disabled.
halt 404 unless federation_enabled?
content_type :json
ensure_self_instance_record!
payload = load_instances_for_api
@@ -40,7 +40,6 @@ module PotatoMesh
end
app.post "/api/messages" do
halt 404 if private_mode?
require_token!
content_type :json
begin
@@ -62,6 +62,7 @@ module PotatoMesh
contact_link_url: sanitized_contact_link_url,
version: app_constant(:APP_VERSION),
private_mode: private_mode?,
federation_enabled: federation_enabled?,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
app_config_json: JSON.generate(config),
initial_theme: theme,
+39 -5
View File
@@ -32,12 +32,34 @@ module PotatoMesh
DEFAULT_FREQUENCY = "915MHz"
DEFAULT_CONTACT_LINK = "#potatomesh:dod.ngo"
DEFAULT_MAX_DISTANCE_KM = 42.0
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 5
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 12
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 15
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 60
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE = 64
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL = 256
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
# Determine whether private mode should be activated.
#
# @return [Boolean] true when PRIVATE=1 in the environment.
def private_mode_enabled?
value = ENV.fetch("PRIVATE", "0")
value.to_s.strip == "1"
end
# Determine whether federation features are permitted for the instance.
#
# Federation is disabled when ``PRIVATE=1`` regardless of the
# ``FEDERATION`` environment variable to ensure a private deployment does
# not announce itself or crawl peers.
#
# @return [Boolean] true when federation should remain active.
def federation_enabled?
return false if private_mode_enabled?
value = ENV.fetch("FEDERATION", "1")
value.to_s.strip != "0"
end
# Resolve the absolute path to the web application root directory.
#
# @return [String] absolute filesystem path of the web folder.
@@ -134,7 +156,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"v0.5.2"
"v0.5.3"
end
# Default refresh interval for frontend polling routines.
@@ -276,16 +298,28 @@ module PotatoMesh
# Connection timeout used when establishing federation HTTP sockets.
#
# The timeout can be customised with the REMOTE_INSTANCE_CONNECT_TIMEOUT
# environment variable to accommodate slower or distant federation peers.
#
# @return [Integer] connect timeout in seconds.
def remote_instance_http_timeout
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT
fetch_positive_integer(
"REMOTE_INSTANCE_CONNECT_TIMEOUT",
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
end
# Read timeout used when streaming federation HTTP responses.
#
# The timeout can be customised with the REMOTE_INSTANCE_READ_TIMEOUT
# environment variable to accommodate slower or distant federation peers.
#
# @return [Integer] read timeout in seconds.
def remote_instance_read_timeout
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT
fetch_positive_integer(
"REMOTE_INSTANCE_READ_TIMEOUT",
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
end
# Limit the number of remote instances processed from a single response.
+162 -1
View File
@@ -6,7 +6,168 @@
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.0"
"version": "0.5.0",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"v8-to-istanbul": "^9.3.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
}
}
}
+6
View File
@@ -5,5 +5,11 @@
"private": true,
"scripts": {
"test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js"
},
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"v8-to-istanbul": "^9.3.0"
}
}
@@ -0,0 +1,172 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { buildInstanceUrl, initializeInstanceSelector, __test__ } from '../instance-selector.js';
const { resolveInstanceLabel } = __test__;
function setupSelectElement(document) {
const select = document.createElement('select');
const listeners = new Map();
const options = [];
Object.defineProperty(select, 'options', {
get() {
return options;
}
});
Object.defineProperty(select, 'value', {
get() {
if (typeof select.selectedIndex !== 'number') {
return '';
}
const current = options[select.selectedIndex];
return current ? current.value : '';
},
set(newValue) {
const index = options.findIndex(option => option.value === newValue);
select.selectedIndex = index >= 0 ? index : -1;
}
});
select.selectedIndex = -1;
select.appendChild = option => {
options.push(option);
if (select.selectedIndex === -1) {
select.selectedIndex = 0;
}
return option;
};
select.remove = index => {
if (index >= 0 && index < options.length) {
options.splice(index, 1);
if (options.length === 0) {
select.selectedIndex = -1;
} else if (select.selectedIndex >= options.length) {
select.selectedIndex = options.length - 1;
}
}
};
select.addEventListener = (event, handler) => {
listeners.set(event, handler);
};
select.dispatchEvent = event => {
const key = typeof event === 'string' ? event : event?.type;
const handler = listeners.get(key);
if (handler) {
handler(event);
}
};
return select;
}
test('resolveInstanceLabel falls back to the domain when the name is missing', () => {
assert.equal(resolveInstanceLabel({ domain: 'mesh.example' }), 'mesh.example');
assert.equal(resolveInstanceLabel({ name: ' Mesh Name ' }), 'Mesh Name');
assert.equal(resolveInstanceLabel(null), '');
});
test('buildInstanceUrl normalises domains into navigable HTTPS URLs', () => {
assert.equal(buildInstanceUrl('mesh.example'), 'https://mesh.example');
assert.equal(buildInstanceUrl(' https://mesh.example '), 'https://mesh.example');
assert.equal(buildInstanceUrl(''), null);
assert.equal(buildInstanceUrl(null), null);
});
test('initializeInstanceSelector populates options alphabetically and selects the configured domain', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const fetchCalls = [];
const fetchImpl = async url => {
fetchCalls.push(url);
return {
ok: true,
async json() {
return [
{ name: 'Zulu Mesh', domain: 'zulu.mesh' },
{ name: 'Alpha Mesh', domain: 'alpha.mesh' },
{ domain: 'beta.mesh' }
];
}
};
};
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document,
instanceDomain: 'beta.mesh',
defaultLabel: 'Select region ...'
});
assert.equal(fetchCalls.length, 1);
assert.equal(select.options.length, 4);
assert.equal(select.options[0].textContent, 'Select region ...');
assert.equal(select.options[1].textContent, 'Alpha Mesh');
assert.equal(select.options[2].textContent, 'beta.mesh');
assert.equal(select.options[3].textContent, 'Zulu Mesh');
assert.equal(select.options[select.selectedIndex].value, 'beta.mesh');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector navigates to the chosen instance domain', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'mesh.example' }];
}
});
let navigatedTo = null;
const navigate = url => {
navigatedTo = url;
};
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document,
navigate,
defaultLabel: 'Select region ...'
});
assert.equal(select.options.length, 2);
assert.equal(select.options[1].value, 'mesh.example');
select.value = 'mesh.example';
select.dispatchEvent({ type: 'change', target: select });
assert.equal(navigatedTo, 'https://mesh.example');
} finally {
env.cleanup();
}
});
@@ -0,0 +1,162 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TELEMETRY_FIELDS,
buildTelemetryDisplayEntries,
collectTelemetryMetrics,
} from '../short-info-telemetry.js';
test('collectTelemetryMetrics extracts values from nested payloads', () => {
const payload = {
battery: '100',
device_metrics: {
voltage: 4.224,
airUtilTx: 0.051,
uptimeSeconds: 305044,
},
environment_metrics: {
temperature: 21.98,
relativeHumidity: 39.5,
barometricPressure: 1017.8,
gasResistance: 1456,
iaq: 83,
distance: 12.5,
lux: 100.25,
whiteLux: 64.5,
irLux: 12.75,
uvLux: 1.6,
windDirection: 270,
windSpeed: 5.9,
windGust: 7.4,
windLull: 4.8,
weight: 32.7,
radiation: 0.45,
rainfall1h: 0.18,
rainfall24h: 1.42,
soilMoisture: 3100,
soilTemperature: 18.9,
},
};
const metrics = collectTelemetryMetrics(payload);
assert.equal(metrics.battery, 100);
assert.equal(metrics.voltage, 4.224);
assert.equal(metrics.airUtil, 0.051);
assert.equal(metrics.uptime, 305044);
assert.equal(metrics.temperature, 21.98);
assert.equal(metrics.humidity, 39.5);
assert.equal(metrics.pressure, 1017.8);
assert.equal(metrics.gasResistance, 1456);
assert.equal(metrics.iaq, 83);
assert.equal(metrics.distance, 12.5);
assert.equal(metrics.lux, 100.25);
assert.equal(metrics.whiteLux, 64.5);
assert.equal(metrics.irLux, 12.75);
assert.equal(metrics.uvLux, 1.6);
assert.equal(metrics.windDirection, 270);
assert.equal(metrics.windSpeed, 5.9);
assert.equal(metrics.windGust, 7.4);
assert.equal(metrics.windLull, 4.8);
assert.equal(metrics.weight, 32.7);
assert.equal(metrics.radiation, 0.45);
assert.equal(metrics.rainfall1h, 0.18);
assert.equal(metrics.rainfall24h, 1.42);
assert.equal(metrics.soilMoisture, 3100);
assert.equal(metrics.soilTemperature, 18.9);
});
test('collectTelemetryMetrics ignores non-numeric values', () => {
const metrics = collectTelemetryMetrics({
battery: '',
voltage: 'abc',
rainfall_1h: null,
wind_speed: undefined,
});
for (const field of TELEMETRY_FIELDS) {
assert.ok(!(field.key in metrics));
}
});
test('buildTelemetryDisplayEntries formats values for overlays', () => {
const telemetry = {
battery: 99,
voltage: 4.224,
current: 0.0715,
uptime: 305044,
channel: 0.5967,
airUtil: 0.03908,
temperature: 21.98,
humidity: 39.5,
pressure: 1017.8,
gasResistance: 1456,
iaq: 83,
distance: 12.5,
lux: 100.25,
whiteLux: 64.5,
irLux: 12.75,
uvLux: 1.6,
windDirection: 270,
windSpeed: 5.9,
windGust: 7.4,
windLull: 4.8,
weight: 32.7,
radiation: 0.45,
rainfall1h: 0.18,
rainfall24h: 1.42,
soilMoisture: 3100,
soilTemperature: 18.9,
};
const entries = buildTelemetryDisplayEntries(telemetry, {
formatUptime: value => `formatted-${value}`,
});
const entryMap = new Map(entries.map(entry => [entry.label, entry.value]));
assert.equal(entryMap.get('Battery'), '99%');
assert.equal(entryMap.get('Voltage'), '4.224V');
assert.equal(entryMap.get('Current'), '71.5 mA');
assert.equal(entryMap.get('Uptime'), 'formatted-305044');
assert.equal(entryMap.get('Channel Util'), '0.597%');
assert.equal(entryMap.get('Air Util Tx'), '0.039%');
assert.equal(entryMap.get('Temperature'), '22.0°C');
assert.equal(entryMap.get('Humidity'), '39.5%');
assert.equal(entryMap.get('Pressure'), '1017.8 hPa');
assert.equal(entryMap.get('Gas Resistance'), '1.46 kΩ');
assert.equal(entryMap.get('IAQ'), '83');
assert.equal(entryMap.get('Distance'), '12.50 m');
assert.equal(entryMap.get('Lux'), '100.3 lx');
assert.equal(entryMap.get('White Lux'), '64.5 lx');
assert.equal(entryMap.get('IR Lux'), '12.8 lx');
assert.equal(entryMap.get('UV Lux'), '1.6 lx');
assert.equal(entryMap.get('Wind Direction'), '270°');
assert.equal(entryMap.get('Wind Speed'), '5.9 m/s');
assert.equal(entryMap.get('Wind Gust'), '7.4 m/s');
assert.equal(entryMap.get('Wind Lull'), '4.8 m/s');
assert.equal(entryMap.get('Weight'), '32.70 kg');
assert.equal(entryMap.get('Radiation'), '0.45 µSv/h');
assert.equal(entryMap.get('Rainfall 1h'), '0.18 mm');
assert.equal(entryMap.get('Rainfall 24h'), '1.42 mm');
assert.equal(entryMap.get('Soil Moisture'), '3100');
assert.equal(entryMap.get('Soil Temperature'), '18.9°C');
});
test('buildTelemetryDisplayEntries omits empty metrics', () => {
const entries = buildTelemetryDisplayEntries({ uptime: null }, {
formatUptime: () => '',
});
assert.equal(entries.length, 0);
});
@@ -0,0 +1,217 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Determine the most suitable label for an instance list entry.
*
* @param {{ name?: string, domain?: string }} entry Instance record as returned by the API.
* @returns {string} Preferred display label falling back to the domain.
*/
function resolveInstanceLabel(entry) {
if (!entry || typeof entry !== 'object') {
return '';
}
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
if (name.length > 0) {
return name;
}
const domain = typeof entry.domain === 'string' ? entry.domain.trim() : '';
return domain;
}
/**
* Construct a navigable URL for the provided instance domain.
*
* @param {string} domain Instance domain as returned by the federation catalog.
* @returns {string|null} Navigable absolute URL or ``null`` when the domain is empty.
*/
export function buildInstanceUrl(domain) {
if (typeof domain !== 'string') {
return null;
}
const trimmed = domain.trim();
if (!trimmed) {
return null;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) {
return trimmed;
}
return `https://${trimmed}`;
}
/**
* Populate and activate the federation instance selector control.
*
* @param {{
* selectElement: HTMLSelectElement | null,
* fetchImpl?: typeof fetch,
* windowObject?: Window,
* documentObject?: Document,
* instanceDomain?: string,
* defaultLabel?: string,
* navigate?: (url: string) => void,
* }} options Configuration for the selector behaviour.
* @returns {Promise<void>} Promise resolving once the selector has been initialised.
*/
export async function initializeInstanceSelector(options) {
const {
selectElement,
fetchImpl = typeof fetch === 'function' ? fetch : null,
windowObject = typeof window !== 'undefined' ? window : undefined,
documentObject = typeof document !== 'undefined' ? document : undefined,
instanceDomain,
defaultLabel = 'Select region ...',
navigate,
} = options;
if (!selectElement || typeof selectElement !== 'object') {
return;
}
const doc = documentObject || windowObject?.document || null;
if (selectElement.options.length === 0) {
const optionFactory =
(doc && typeof doc.createElement === 'function')
? doc.createElement.bind(doc)
: (typeof selectElement.ownerDocument?.createElement === 'function'
? selectElement.ownerDocument.createElement.bind(selectElement.ownerDocument)
: null);
if (optionFactory) {
const placeholderOption = optionFactory('option');
placeholderOption.value = '';
placeholderOption.textContent = defaultLabel;
selectElement.appendChild(placeholderOption);
}
} else if (selectElement.options[0]) {
selectElement.options[0].textContent = defaultLabel;
selectElement.options[0].value = '';
}
if (typeof fetchImpl !== 'function') {
return;
}
let response;
try {
response = await fetchImpl('/api/instances', {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
} catch (error) {
console.warn('Failed to load federation instances', error);
return;
}
if (!response || typeof response.json !== 'function') {
return;
}
if (!response.ok) {
return;
}
let payload;
try {
payload = await response.json();
} catch (error) {
console.warn('Invalid federation instances payload', error);
return;
}
if (!Array.isArray(payload)) {
return;
}
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
const sortedEntries = payload
.filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '')
.map(entry => ({
domain: entry.domain.trim(),
label: resolveInstanceLabel(entry),
}))
.sort((a, b) => {
const labelA = a.label || a.domain;
const labelB = b.label || b.domain;
return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' });
});
while (selectElement.options.length > 1) {
selectElement.remove(1);
}
let matchedIndex = 0;
sortedEntries.forEach((entry, index) => {
if (!doc || typeof doc.createElement !== 'function') {
return;
}
const option = doc.createElement('option');
const optionLabel = entry.label && entry.label.trim().length > 0 ? entry.label : entry.domain;
const label = optionLabel.trim();
option.value = entry.domain;
option.textContent = label;
option.dataset.instanceDomain = entry.domain;
selectElement.appendChild(option);
if (sanitizedDomain && entry.domain.toLowerCase() === sanitizedDomain) {
matchedIndex = index + 1;
}
});
if (matchedIndex > 0 && selectElement.options[matchedIndex]) {
selectElement.selectedIndex = matchedIndex;
} else {
selectElement.selectedIndex = 0;
}
const navigateTo = typeof navigate === 'function'
? navigate
: url => {
if (!url || !windowObject || !windowObject.location) {
return;
}
if (typeof windowObject.location.assign === 'function') {
windowObject.location.assign(url);
} else {
windowObject.location.href = url;
}
};
selectElement.addEventListener('change', event => {
const target = event?.target;
if (!target || typeof target.value !== 'string' || target.value.trim() === '') {
return;
}
const url = buildInstanceUrl(target.value);
if (url) {
navigateTo(url);
}
});
}
export const __test__ = { resolveInstanceLabel };
+46 -111
View File
@@ -20,6 +20,16 @@ import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js';
import {
TELEMETRY_FIELDS,
buildTelemetryDisplayEntries,
collectTelemetryMetrics,
fmtAlt,
fmtHumidity,
fmtPressure,
fmtTemperature,
fmtTx,
} from './short-info-telemetry.js';
import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
@@ -27,6 +37,7 @@ import {
formatChatChannelTag,
formatNodeAnnouncementPrefix
} from './chat-format.js';
import { initializeInstanceSelector } from './instance-selector.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -63,6 +74,7 @@ export function initializeApp(config) {
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
const chatEl = document.getElementById('chat');
const refreshInfo = document.getElementById('refreshInfo');
const instanceSelect = document.getElementById('instanceSelect');
const baseTitle = document.title;
const nodesTable = document.getElementById('nodes');
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
@@ -123,8 +135,19 @@ export function initializeApp(config) {
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
const REFRESH_MS = config.refreshMs;
const CHAT_ENABLED = Boolean(config.chatEnabled);
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`;
if (instanceSelectorEnabled && instanceSelect) {
void initializeInstanceSelector({
selectElement: instanceSelect,
instanceDomain: config.instanceDomain,
defaultLabel: 'Select region ...',
}).catch(error => {
console.warn('Instance selector initialisation failed', error);
});
}
/** @type {ReturnType<typeof setTimeout>|null} */
let refreshTimer = null;
@@ -1624,24 +1647,17 @@ export function initializeApp(config) {
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role));
let infoAttr = '';
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
battery: nodeData.battery_level ?? nodeData.battery ?? null,
voltage: nodeData.voltage ?? null,
uptime: nodeData.uptime_seconds ?? nodeData.uptime ?? null,
channel: nodeData.channel_utilization ?? nodeData.channel ?? null,
airUtil: nodeData.air_util_tx ?? nodeData.airUtil ?? null,
temperature: nodeData.temperature ?? nodeData.temp ?? null,
humidity: nodeData.relative_humidity ?? nodeData.relativeHumidity ?? nodeData.humidity ?? null,
pressure: nodeData.barometric_pressure ?? nodeData.barometricPressure ?? nodeData.pressure ?? null,
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
Object.assign(info, collectTelemetryMetrics(nodeData));
const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`];
const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : '';
if (attrNodeIdRaw) {
@@ -1849,23 +1865,6 @@ export function initializeApp(config) {
return value != null && value !== '' ? String(value) : '—';
}
/**
* Append telemetry information to the short-info overlay payload.
*
* @param {Array<string>} lines Output accumulator.
* @param {string} label Field label.
* @param {*} rawValue Raw telemetry value.
* @param {Function} formatter Optional formatter callback.
* @returns {void}
*/
function appendTelemetryLine(lines, label, rawValue, formatter) {
if (!Array.isArray(lines)) return;
if (rawValue == null || rawValue === '') return;
const formatted = formatter ? formatter(rawValue) : rawValue;
if (formatted == null || formatted === '') return;
lines.push(`${escapeHtml(label)}: ${escapeHtml(String(formatted))}`);
}
/**
* Transform a node-shaped payload into the overlay data format.
*
@@ -1908,14 +1907,6 @@ export function initializeApp(config) {
}
const numericPairs = [
['battery', source.battery ?? source.battery_level],
['voltage', source.voltage],
['uptime', source.uptime ?? source.uptime_seconds],
['channel', source.channel ?? source.channel_utilization],
['airUtil', source.airUtil ?? source.air_util_tx],
['temperature', source.temperature],
['humidity', source.humidity ?? source.relative_humidity],
['pressure', source.pressure ?? source.barometric_pressure],
['telemetryTime', source.telemetryTime ?? source.telemetry_time],
['lastHeard', source.lastHeard ?? source.last_heard],
['latitude', source.latitude],
@@ -1931,6 +1922,14 @@ export function initializeApp(config) {
}
}
const telemetryMetrics = collectTelemetryMetrics(source);
for (const field of TELEMETRY_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(telemetryMetrics, field.key)) {
continue;
}
normalized[field.key] = telemetryMetrics[field.key];
}
const lastSeenRaw = source.lastSeenIso ?? source.last_seen_iso;
if (typeof lastSeenRaw === 'string' && lastSeenRaw.trim().length > 0) {
normalized.lastSeenIso = lastSeenRaw.trim();
@@ -2040,14 +2039,10 @@ export function initializeApp(config) {
if (modelValue) {
lines.push(`Model: ${escapeHtml(modelValue)}`);
}
appendTelemetryLine(lines, 'Battery', overlayInfo.battery, value => fmtAlt(value, '%'));
appendTelemetryLine(lines, 'Voltage', overlayInfo.voltage, value => fmtAlt(value, 'V'));
appendTelemetryLine(lines, 'Uptime', overlayInfo.uptime, formatShortInfoUptime);
appendTelemetryLine(lines, 'Channel Util', overlayInfo.channel, fmtTx);
appendTelemetryLine(lines, 'Air Util Tx', overlayInfo.airUtil, fmtTx);
appendTelemetryLine(lines, 'Temperature', overlayInfo.temperature, fmtTemperature);
appendTelemetryLine(lines, 'Humidity', overlayInfo.humidity, fmtHumidity);
appendTelemetryLine(lines, 'Pressure', overlayInfo.pressure, fmtPressure);
const telemetryEntries = buildTelemetryDisplayEntries(overlayInfo, { formatUptime: formatShortInfoUptime });
for (const entry of telemetryEntries) {
lines.push(`${escapeHtml(entry.label)}: ${escapeHtml(entry.value)}`);
}
if (neighborLineHtml) {
lines.push(neighborLineHtml);
}
@@ -2258,66 +2253,6 @@ export function initializeApp(config) {
return Number.isFinite(n) ? n.toFixed(d) : "";
}
/**
* Format altitude values with units.
*
* @param {*} v Raw altitude value.
* @param {string} s Unit suffix.
* @returns {string} Altitude string.
*/
function fmtAlt(v, s) {
return (v == null || v === '') ? "" : `${v}${s}`;
}
/**
* Format transmission utilisation values as percentages.
*
* @param {*} v Raw utilisation value.
* @param {number} [d=3] Decimal precision.
* @returns {string} Percentage string.
*/
function fmtTx(v, d = 3) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(d)}%` : "";
}
/**
* Format temperature telemetry with a degree suffix.
*
* @param {*} v Raw temperature value.
* @returns {string} Temperature string.
*/
function fmtTemperature(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)}°C` : "";
}
/**
* Format humidity telemetry as a percentage.
*
* @param {*} v Raw humidity value.
* @returns {string} Humidity string.
*/
function fmtHumidity(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)}%` : "";
}
/**
* Format barometric pressure telemetry in hPa.
*
* @param {*} v Raw pressure value.
* @returns {string} Pressure string.
*/
function fmtPressure(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)} hPa` : "";
}
/**
* Format SNR readings with a ``dB`` suffix.
*
@@ -0,0 +1,408 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Determine whether ``value`` can be treated as a finite number.
*
* @param {*} value Candidate numeric value.
* @returns {boolean} ``true`` when the value parses to a finite number.
*/
function isFiniteNumber(value) {
if (value == null || value === '') return false;
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number);
}
/**
* Retrieve the first defined property from ``container`` using ``keys``.
*
* @param {Object} container Object inspected for values.
* @param {Array<string>} keys Candidate property names.
* @returns {*} First non-nullish value discovered.
*/
function pickFirstValue(container, keys) {
if (!container || typeof container !== 'object') {
return undefined;
}
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(container, key)) {
const candidate = container[key];
if (candidate != null && (candidate !== '' || candidate === 0)) {
return candidate;
}
}
}
return undefined;
}
/**
* Format arbitrary telemetry values using a numeric suffix.
*
* @param {*} value Raw value to format.
* @param {string} suffix Unit suffix appended when formatting succeeds.
* @returns {string} Formatted value or an empty string for invalid input.
*/
export function fmtAlt(value, suffix) {
if (!isFiniteNumber(value) && !(value === 0 || value === '0')) {
return '';
}
return `${Number(value)}${suffix}`;
}
/**
* Format utilisation metrics as percentages.
*
* @param {*} value Raw utilisation value.
* @param {number} [decimals=3] Decimal precision applied to the percentage.
* @returns {string} Formatted percentage string.
*/
export function fmtTx(value, decimals = 3) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(decimals)}%`;
}
/**
* Format temperature telemetry in degrees Celsius.
*
* @param {*} value Raw temperature reading.
* @returns {string} Formatted temperature string.
*/
export function fmtTemperature(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)}°C`;
}
/**
* Format relative humidity telemetry as a percentage.
*
* @param {*} value Raw humidity reading.
* @returns {string} Formatted humidity string.
*/
export function fmtHumidity(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)}%`;
}
/**
* Format barometric pressure telemetry in hectopascals.
*
* @param {*} value Raw pressure value.
* @returns {string} Formatted pressure string.
*/
export function fmtPressure(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} hPa`;
}
/**
* Format current telemetry, automatically scaling to milliamperes when
* appropriate.
*
* @param {*} value Raw current reading expressed in amperes.
* @returns {string} Formatted current string.
*/
export function fmtCurrent(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
if (Math.abs(num) < 1) {
return `${(num * 1000).toFixed(1)} mA`;
}
return `${num.toFixed(2)} A`;
}
/**
* Format gas resistance telemetry using a human readable Ohm prefix.
*
* @param {*} value Raw resistance value expressed in Ohms.
* @returns {string} Formatted resistance string.
*/
export function fmtGasResistance(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
const absVal = Math.abs(num);
if (absVal >= 1_000_000) {
return `${(num / 1_000_000).toFixed(2)}`;
}
if (absVal >= 1_000) {
return `${(num / 1_000).toFixed(2)}`;
}
return `${num.toFixed(2)} Ω`;
}
/**
* Format generic distance telemetry in metres.
*
* @param {*} value Raw distance value.
* @returns {string} Formatted distance string.
*/
export function fmtDistance(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} m`;
}
/**
* Format optical telemetry in lux.
*
* @param {*} value Raw lux reading.
* @returns {string} Formatted lux string.
*/
export function fmtLux(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} lx`;
}
/**
* Format wind direction telemetry in degrees.
*
* @param {*} value Raw wind direction reading.
* @returns {string} Formatted wind direction string.
*/
export function fmtWindDirection(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}°`;
}
/**
* Format wind speed telemetry in metres per second.
*
* @param {*} value Raw wind speed reading.
* @returns {string} Formatted wind speed string.
*/
export function fmtWindSpeed(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} m/s`;
}
/**
* Format weight telemetry in kilograms.
*
* @param {*} value Raw weight value.
* @returns {string} Formatted weight string.
*/
export function fmtWeight(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} kg`;
}
/**
* Format radiation telemetry using microsieverts per hour.
*
* @param {*} value Raw radiation value.
* @returns {string} Formatted radiation string.
*/
export function fmtRadiation(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} µSv/h`;
}
/**
* Format rainfall telemetry using millimetres.
*
* @param {*} value Raw rainfall accumulation value.
* @returns {string} Formatted rainfall string.
*/
export function fmtRainfall(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} mm`;
}
/**
* Format soil moisture telemetry. The metrics are typically raw sensor values
* without defined units, therefore the raw integer is surfaced unchanged.
*
* @param {*} value Raw soil moisture reading.
* @returns {string} Soil moisture string.
*/
export function fmtSoilMoisture(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}`;
}
/**
* Format soil temperature telemetry in degrees Celsius.
*
* @param {*} value Raw soil temperature reading.
* @returns {string} Formatted soil temperature string.
*/
export function fmtSoilTemperature(value) {
return fmtTemperature(value);
}
/**
* Format indoor air quality index values.
*
* @param {*} value Raw IAQ reading.
* @returns {string} IAQ string.
*/
export function fmtIaq(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}`;
}
/**
* Telemetry descriptors consumed by the short-info overlay.
*
* Each descriptor includes a canonical key, display label, candidate source
* property names, and a formatter that converts numeric values into a human
* readable string.
*/
export const TELEMETRY_FIELDS = [
{ key: 'battery', label: 'Battery', sources: ['battery', 'battery_level', 'batteryLevel'], formatter: value => fmtAlt(value, '%') },
{ key: 'voltage', label: 'Voltage', sources: ['voltage'], formatter: value => fmtAlt(value, 'V') },
{ key: 'current', label: 'Current', sources: ['current'], formatter: fmtCurrent },
{ key: 'uptime', label: 'Uptime', sources: ['uptime', 'uptime_seconds', 'uptimeSeconds'], formatter: (value, utils) => (typeof utils.formatUptime === 'function' ? utils.formatUptime(value) : '') },
{
key: 'channel',
label: 'Channel Util',
sources: ['channel', 'channel_utilization', 'channelUtilization'],
formatter: value => fmtTx(value),
},
{
key: 'airUtil',
label: 'Air Util Tx',
sources: ['airUtil', 'air_util_tx', 'airUtilTx'],
formatter: value => fmtTx(value),
},
{ key: 'temperature', label: 'Temperature', sources: ['temperature', 'temp'], formatter: fmtTemperature },
{ key: 'humidity', label: 'Humidity', sources: ['humidity', 'relative_humidity', 'relativeHumidity'], formatter: fmtHumidity },
{ key: 'pressure', label: 'Pressure', sources: ['pressure', 'barometric_pressure', 'barometricPressure'], formatter: fmtPressure },
{ key: 'gasResistance', label: 'Gas Resistance', sources: ['gas_resistance', 'gasResistance'], formatter: fmtGasResistance },
{ key: 'iaq', label: 'IAQ', sources: ['iaq'], formatter: fmtIaq },
{ key: 'distance', label: 'Distance', sources: ['distance'], formatter: fmtDistance },
{ key: 'lux', label: 'Lux', sources: ['lux'], formatter: fmtLux },
{ key: 'whiteLux', label: 'White Lux', sources: ['white_lux', 'whiteLux'], formatter: fmtLux },
{ key: 'irLux', label: 'IR Lux', sources: ['ir_lux', 'irLux'], formatter: fmtLux },
{ key: 'uvLux', label: 'UV Lux', sources: ['uv_lux', 'uvLux'], formatter: fmtLux },
{ key: 'windDirection', label: 'Wind Direction', sources: ['wind_direction', 'windDirection'], formatter: fmtWindDirection },
{ key: 'windSpeed', label: 'Wind Speed', sources: ['wind_speed', 'windSpeed', 'windSpeedMps'], formatter: fmtWindSpeed },
{ key: 'windGust', label: 'Wind Gust', sources: ['wind_gust', 'windGust'], formatter: fmtWindSpeed },
{ key: 'windLull', label: 'Wind Lull', sources: ['wind_lull', 'windLull'], formatter: fmtWindSpeed },
{ key: 'weight', label: 'Weight', sources: ['weight'], formatter: fmtWeight },
{ key: 'radiation', label: 'Radiation', sources: ['radiation', 'radiationLevel'], formatter: fmtRadiation },
{ key: 'rainfall1h', label: 'Rainfall 1h', sources: ['rainfall_1h', 'rainfall1h', 'rainfall1H'], formatter: fmtRainfall },
{ key: 'rainfall24h', label: 'Rainfall 24h', sources: ['rainfall_24h', 'rainfall24h', 'rainfall24H'], formatter: fmtRainfall },
{ key: 'soilMoisture', label: 'Soil Moisture', sources: ['soil_moisture', 'soilMoisture'], formatter: fmtSoilMoisture },
{ key: 'soilTemperature', label: 'Soil Temperature', sources: ['soil_temperature', 'soilTemperature'], formatter: fmtSoilTemperature },
];
/**
* Collect telemetry metrics from arbitrary node payloads.
*
* The function inspects common top-level, device metric, and environment
* metric collections in order to surface numeric telemetry values.
*
* @param {*} source Node payload that may contain telemetry.
* @returns {Object} Object containing numeric telemetry keyed by descriptor.
*/
export function collectTelemetryMetrics(source) {
const metrics = {};
if (!source || typeof source !== 'object') {
return metrics;
}
const containers = [
source,
source.device_metrics,
source.deviceMetrics,
source.environment_metrics,
source.environmentMetrics,
source.telemetry,
].filter(container => container && typeof container === 'object');
for (const field of TELEMETRY_FIELDS) {
const keys = Array.isArray(field.sources) && field.sources.length > 0
? field.sources
: [field.key];
for (const container of containers) {
const raw = pickFirstValue(container, keys);
if (!isFiniteNumber(raw) && !(raw === 0 || raw === '0')) {
continue;
}
const num = Number(raw);
if (Number.isFinite(num)) {
metrics[field.key] = num;
break;
}
}
}
return metrics;
}
/**
* Build display entries for telemetry values suitable for short-info overlays.
*
* @param {Object} telemetry Telemetry metrics keyed by descriptor ``key``.
* @param {{formatUptime?: Function}} [utils] Optional formatter overrides.
* @returns {Array<{label: string, value: string}>} Renderable telemetry entries.
*/
export function buildTelemetryDisplayEntries(telemetry, utils = {}) {
const entries = [];
if (!telemetry || typeof telemetry !== 'object') {
return entries;
}
for (const field of TELEMETRY_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(telemetry, field.key)) {
continue;
}
const value = telemetry[field.key];
if (value == null) {
continue;
}
const formatted = typeof field.formatter === 'function'
? field.formatter(value, utils)
: String(value);
if (formatted == null || formatted === '') {
continue;
}
entries.push({ label: field.label, value: formatted });
}
return entries;
}
export default {
TELEMETRY_FIELDS,
collectTelemetryMetrics,
buildTelemetryDisplayEntries,
fmtAlt,
fmtTx,
fmtTemperature,
fmtHumidity,
fmtPressure,
fmtCurrent,
fmtGasResistance,
fmtDistance,
fmtLux,
fmtWindDirection,
fmtWindSpeed,
fmtWeight,
fmtRadiation,
fmtRainfall,
fmtSoilMoisture,
fmtSoilTemperature,
fmtIaq,
};
+66 -1
View File
@@ -131,7 +131,15 @@ body {
}
h1 {
margin: 0 0 8px;
margin: 0;
}
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.site-title {
@@ -152,6 +160,63 @@ h1 {
margin-bottom: 12px;
}
.instance-selector {
display: flex;
align-items: center;
}
.instance-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--input-bg);
color: var(--input-fg);
border: 1px solid var(--input-border);
border-radius: 8px;
padding: 6px 32px 6px 12px;
font-size: 14px;
line-height: 1.4;
min-width: 220px;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 18px) calc(50% - 4px), calc(100% - 12px) calc(50% - 4px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.instance-select:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 900px) {
.site-header {
flex-direction: column;
align-items: flex-start;
}
.instance-selector,
.instance-select {
width: 100%;
}
.instance-select {
min-width: 0;
}
}
.pill {
display: inline-block;
padding: 2px 8px;
+147 -14
View File
@@ -15,10 +15,25 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
const coverageDir = 'coverage';
const reportsDir = 'reports';
const outputPath = path.join(reportsDir, 'javascript-coverage.json');
import istanbulLibCoverage from 'istanbul-lib-coverage';
import istanbulLibReport from 'istanbul-lib-report';
import istanbulReports from 'istanbul-reports';
import v8toIstanbul from 'v8-to-istanbul';
const { createCoverageMap } = istanbulLibCoverage;
const { createContext } = istanbulLibReport;
const coverageDir = path.resolve('coverage');
const reportsDir = path.resolve('reports');
const jsonOutputName = 'javascript-coverage.json';
const lcovOutputName = 'javascript-coverage.lcov';
const projectRoot = process.cwd();
/**
* Ensure the reports directory exists so that coverage artefacts can be written.
*
* @returns {Promise<void>} A promise that resolves when the directory is available.
*/
async function ensureReportsDir() {
try {
await fs.mkdir(reportsDir, { recursive: true });
@@ -28,32 +43,150 @@ async function ensureReportsDir() {
}
}
async function copyLatestCoverage() {
/**
* Read the coverage directory and return a deterministically ordered list of JSON files.
*
* @returns {Promise<string[]>} The absolute paths of available coverage JSON artefacts.
*/
async function listCoverageFiles() {
let entries;
try {
entries = await fs.readdir(coverageDir);
} catch (error) {
if (error.code === 'ENOENT') {
console.warn('Coverage directory not found; skipping export.');
return;
return [];
}
throw error;
}
const coverageFiles = entries.filter(name => name.endsWith('.json'));
const coverageFiles = entries
.filter(name => name.endsWith('.json'))
.map(name => path.join(coverageDir, name))
.sort();
if (!coverageFiles.length) {
console.warn('No coverage files generated; skipping export.');
return;
return [];
}
// Sort to pick the most recent entry deterministically.
coverageFiles.sort();
const latest = coverageFiles[coverageFiles.length - 1];
const source = path.join(coverageDir, latest);
return coverageFiles;
}
await fs.copyFile(source, outputPath);
console.log(`Copied coverage report to ${outputPath}`);
/**
* Convert a V8 coverage URL to a project-local filesystem path.
*
* @param {string | undefined} url The coverage URL emitted by V8.
* @returns {string | null} A normalised absolute path, or null when the URL should be ignored.
*/
function normaliseFileUrl(url) {
if (!url || url.startsWith('node:')) {
return null;
}
if (!url.startsWith('file://')) {
return null;
}
let filePath;
try {
filePath = decodeURIComponent(new URL(url).pathname);
} catch {
return null;
}
if (!filePath.startsWith(projectRoot)) {
return null;
}
if (filePath.includes('node_modules')) {
return null;
}
return filePath;
}
/**
* Transform the raw V8 coverage reports into an Istanbul coverage map.
*
* @param {string[]} coverageFiles A list of coverage artefacts to consume.
* @returns {Promise<import('istanbul-lib-coverage').CoverageMap>} The aggregated coverage map.
*/
async function buildCoverageMap(coverageFiles) {
const coverageMap = createCoverageMap({});
for (const file of coverageFiles) {
const raw = await fs.readFile(file, 'utf8');
const parsed = JSON.parse(raw);
const entries = Array.isArray(parsed.result) ? parsed.result : [];
for (const entry of entries) {
const { url, functions } = entry;
const filePath = normaliseFileUrl(url);
if (!filePath) {
continue;
}
try {
const converter = v8toIstanbul(filePath, 0, {
source: await fs.readFile(filePath, 'utf8'),
});
await converter.load();
converter.applyCoverage(functions);
const fileCoverages = converter.toIstanbul();
for (const coverage of Object.values(fileCoverages)) {
if (coverage.path) {
const relativePath = path.relative(projectRoot, coverage.path);
coverage.path = relativePath || coverage.path;
}
try {
const existingCoverage = coverageMap.fileCoverageFor(coverage.path);
existingCoverage.merge(coverage);
} catch (error) {
if (error && typeof error.message === 'string' && error.message.includes('No file coverage')) {
coverageMap.addFileCoverage(coverage);
} else {
throw error;
}
}
}
} catch (error) {
console.warn(`Failed to translate coverage for ${filePath}:`, error);
}
}
}
return coverageMap;
}
/**
* Persist the Istanbul coverage map as JSON and LCOV artefacts for downstream tooling.
*
* @param {import('istanbul-lib-coverage').CoverageMap} coverageMap The populated coverage map.
* @returns {Promise<void>} A promise that resolves when the outputs are written.
*/
async function writeCoverageOutputs(coverageMap) {
const jsonOutputPath = path.join(reportsDir, jsonOutputName);
const lcovOutputPath = path.join(reportsDir, lcovOutputName);
await fs.writeFile(jsonOutputPath, `${JSON.stringify(coverageMap.toJSON(), null, 2)}\n`);
const context = createContext({ dir: reportsDir, coverageMap });
istanbulReports.create('lcovonly', { file: lcovOutputName }).execute(context);
console.log(`Wrote coverage reports to ${jsonOutputPath} and ${lcovOutputPath}`);
}
await ensureReportsDir();
await copyLatestCoverage();
const coverageFiles = await listCoverageFiles();
if (!coverageFiles.length) {
process.exit(0);
}
const coverageMap = await buildCoverageMap(coverageFiles);
if (!coverageMap.files().length) {
console.warn('No project coverage entries were recognised; skipping export.');
process.exit(0);
}
await writeCoverageOutputs(coverageMap);
+424 -24
View File
@@ -33,12 +33,38 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
describe ".resolve_port" do
it "always returns the baked-in default port" do
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ENV["PORT"] = "51515"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ensure
around do |example|
original_port = ENV["PORT"]
begin
example.run
ensure
if original_port
ENV["PORT"] = original_port
else
ENV.delete("PORT")
end
end
end
it "returns the baked-in default port when PORT is not provided" do
ENV.delete("PORT")
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
end
it "honours a valid PORT override" do
ENV["PORT"] = "51515"
expect(application_class.resolve_port).to eq(51_515)
end
it "falls back to the default for invalid PORT values" do
ENV["PORT"] = "abc"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ENV["PORT"] = "70000"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ENV["PORT"] = "0"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
end
end
@@ -280,6 +306,40 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(result).to be(dummy_thread)
expect(app.settings.federation_thread).to be(dummy_thread)
end
context "when federation is disabled" do
around do |example|
original = ENV["FEDERATION"]
begin
ENV["FEDERATION"] = "0"
example.run
ensure
if original.nil?
ENV.delete("FEDERATION")
else
ENV["FEDERATION"] = original
end
end
end
it "does not start the initial announcement thread" do
expect(Thread).not_to receive(:new)
result = app.start_initial_federation_announcement!
expect(result).to be_nil
expect(app.settings.respond_to?(:initial_federation_thread) ? app.settings.initial_federation_thread : nil).to be_nil
end
it "does not start the recurring announcer thread" do
expect(Thread).not_to receive(:new)
result = app.start_federation_announcer!
expect(result).to be_nil
expect(app.settings.federation_thread).to be_nil
end
end
end
before do
@@ -858,6 +918,34 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(targets).to eq(["potatomesh.net"])
end
it "ignores remote instances that have not updated within a week" do
with_db do |db|
db.execute("DELETE FROM instances")
stale_time = (Time.now.to_i - PotatoMesh::Config.week_seconds - 60)
db.execute(
"INSERT INTO instances (id, domain, pubkey, name, version, channel, frequency, latitude, longitude, last_update_time, is_private, signature) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
"stale-id",
"stale.mesh",
"pubkey",
"Stale",
"1.0.0",
nil,
nil,
nil,
nil,
stale_time,
0,
"signature",
],
)
end
targets = application_class.federation_target_domains("self.mesh")
expect(targets).to eq(["potatomesh.net"])
end
end
describe ".latest_node_update_timestamp" do
@@ -1000,6 +1088,29 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="footer-content"')
end
it "renders the federation instance selector when federation is enabled" do
get "/"
expect(last_response.body).to include('id="instanceSelect"')
expect(last_response.body).to include("Select region ...")
end
it "omits the instance selector when private mode is active" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
get "/"
expect(last_response.body).not_to include('id="instanceSelect"')
end
it "omits the instance selector when federation is disabled" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false)
get "/"
expect(last_response.body).not_to include('id="instanceSelect"')
end
it "includes SEO metadata from configuration" do
allow(PotatoMesh::Config).to receive(:site_name).and_return("Spec Mesh Title")
allow(PotatoMesh::Config).to receive(:channel).and_return("#SpecChannel")
@@ -2023,6 +2134,28 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(domains).to eq(["duplicate.example"])
end
end
context "when federation is disabled" do
around do |example|
original = ENV["FEDERATION"]
begin
ENV["FEDERATION"] = "0"
example.run
ensure
if original.nil?
ENV.delete("FEDERATION")
else
ENV["FEDERATION"] = original
end
end
end
it "returns 404" do
get "/api/instances"
expect(last_response.status).to eq(404)
end
end
end
describe "POST /api/nodes" do
@@ -2742,11 +2875,57 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(first["channel_utilization"], payload[0].dig("device_metrics", "channelUtilization"))
expect_same_value(first["air_util_tx"], payload[0].dig("device_metrics", "airUtilTx"))
expect(first["uptime_seconds"]).to eq(payload[0].dig("device_metrics", "uptimeSeconds"))
expect_same_value(first["current"], payload[0]["current"])
expect_same_value(first["gas_resistance"], payload[0]["gas_resistance"])
expect_same_value(first["iaq"], payload[0]["iaq"])
expect_same_value(first["distance"], payload[0]["distance"])
expect_same_value(first["lux"], payload[0]["lux"])
expect_same_value(first["white_lux"], payload[0]["white_lux"])
expect_same_value(first["ir_lux"], payload[0]["ir_lux"])
expect_same_value(first["uv_lux"], payload[0]["uv_lux"])
expect_same_value(first["wind_direction"], payload[0]["wind_direction"])
expect_same_value(first["wind_speed"], payload[0]["wind_speed"])
expect_same_value(first["wind_gust"], payload[0]["wind_gust"])
expect_same_value(first["wind_lull"], payload[0]["wind_lull"])
expect_same_value(first["weight"], payload[0]["weight"])
expect_same_value(first["radiation"], payload[0]["radiation"])
expect_same_value(first["rainfall_1h"], payload[0]["rainfall_1h"])
expect_same_value(first["rainfall_24h"], payload[0]["rainfall_24h"])
expect_same_value(first["soil_moisture"], payload[0]["soil_moisture"])
expect_same_value(first["soil_temperature"], payload[0]["soil_temperature"])
environment_row = rows.find { |row| row["id"] == payload[1]["id"] }
expect(environment_row["temperature"]).to be_within(1e-6).of(payload[1].dig("environment_metrics", "temperature"))
expect(environment_row["relative_humidity"]).to be_within(1e-6).of(payload[1].dig("environment_metrics", "relativeHumidity"))
expect(environment_row["barometric_pressure"]).to be_within(1e-6).of(payload[1].dig("environment_metrics", "barometricPressure"))
expect_same_value(environment_row["gas_resistance"], payload[1].dig("environment_metrics", "gasResistance"))
expect_same_value(environment_row["iaq"], payload[1].dig("environment_metrics", "iaq"))
expect_same_value(environment_row["distance"], payload[1].dig("environment_metrics", "distance"))
expect_same_value(environment_row["lux"], payload[1].dig("environment_metrics", "lux"))
expect_same_value(environment_row["white_lux"], payload[1].dig("environment_metrics", "whiteLux"))
expect_same_value(environment_row["ir_lux"], payload[1].dig("environment_metrics", "irLux"))
expect_same_value(environment_row["uv_lux"], payload[1].dig("environment_metrics", "uvLux"))
expect_same_value(environment_row["wind_direction"], payload[1].dig("environment_metrics", "windDirection"))
expect_same_value(environment_row["wind_speed"], payload[1].dig("environment_metrics", "windSpeed"))
expect_same_value(environment_row["wind_gust"], payload[1].dig("environment_metrics", "windGust"))
expect_same_value(environment_row["wind_lull"], payload[1].dig("environment_metrics", "windLull"))
expect_same_value(environment_row["weight"], payload[1].dig("environment_metrics", "weight"))
expect_same_value(environment_row["radiation"], payload[1].dig("environment_metrics", "radiation"))
expect_same_value(environment_row["rainfall_1h"], payload[1].dig("environment_metrics", "rainfall1h"))
expect_same_value(environment_row["rainfall_24h"], payload[1].dig("environment_metrics", "rainfall24h"))
expect_same_value(environment_row["soil_moisture"], payload[1].dig("environment_metrics", "soilMoisture"))
expect_same_value(environment_row["soil_temperature"], payload[1].dig("environment_metrics", "soilTemperature"))
third_row = rows.find { |row| row["id"] == payload[2]["id"] }
expect_same_value(third_row["current"], payload[2].dig("device_metrics", "current"))
expect_same_value(third_row["distance"], payload[2].dig("environment_metrics", "distance"))
expect_same_value(third_row["lux"], payload[2].dig("environment_metrics", "lux"))
expect_same_value(third_row["wind_direction"], payload[2].dig("environment_metrics", "windDirection"))
expect_same_value(third_row["wind_speed"], payload[2].dig("environment_metrics", "windSpeed"))
expect_same_value(third_row["weight"], payload[2].dig("environment_metrics", "weight"))
expect_same_value(third_row["rainfall_24h"], payload[2].dig("environment_metrics", "rainfall24h"))
expect_same_value(third_row["soil_moisture"], payload[2].dig("environment_metrics", "soilMoisture"))
expect_same_value(third_row["soil_temperature"], payload[2].dig("environment_metrics", "soilTemperature"))
end
with_db(readonly: true) do |db|
@@ -3144,30 +3323,19 @@ RSpec.describe "Potato Mesh Sinatra app" do
expected = expected_node_row(node)
actual_row = actual_by_id.fetch(node["node_id"])
expect(actual_row["short_name"]).to eq(expected["short_name"])
expect(actual_row["long_name"]).to eq(expected["long_name"])
expect(actual_row["hw_model"]).to eq(expected["hw_model"])
expect(actual_row["role"]).to eq(expected["role"])
expect_same_value(actual_row["snr"], expected["snr"])
expect_same_value(actual_row["battery_level"], expected["battery_level"])
expect_same_value(actual_row["voltage"], expected["voltage"])
expect(actual_row["last_heard"]).to eq(expected["last_heard"])
expect(actual_row["first_heard"]).to eq(expected["first_heard"])
expect_same_value(actual_row["uptime_seconds"], expected["uptime_seconds"])
expect_same_value(actual_row["channel_utilization"], expected["channel_utilization"])
expect_same_value(actual_row["air_util_tx"], expected["air_util_tx"])
expect_same_value(actual_row["position_time"], expected["position_time"])
expect(actual_row["location_source"]).to eq(expected["location_source"])
expect_same_value(actual_row["precision_bits"], expected["precision_bits"])
expect_same_value(actual_row["latitude"], expected["latitude"])
expect_same_value(actual_row["longitude"], expected["longitude"])
expect_same_value(actual_row["altitude"], expected["altitude"])
expected.each do |key, value|
if value.nil?
expect(actual_row).not_to have_key(key), "expected #{key} to be omitted"
else
expect_same_value(actual_row[key], value)
end
end
if expected["last_heard"]
expected_last_seen_iso = Time.at(expected["last_heard"]).utc.iso8601
expect(actual_row["last_seen_iso"]).to eq(expected_last_seen_iso)
else
expect(actual_row["last_seen_iso"]).to be_nil
expect(actual_row).not_to have_key("last_seen_iso")
end
if node["position_time"]
@@ -3212,6 +3380,64 @@ RSpec.describe "Potato Mesh Sinatra app" do
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!fresh-node")
end
it "omits blank values from node responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, battery_level, voltage, last_heard, first_heard, uptime_seconds, channel_utilization, air_util_tx, position_time, location_source, precision_bits, latitude, longitude, altitude) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
"!blank",
" ",
nil,
"",
nil,
nil,
nil,
nil,
now,
now,
nil,
nil,
nil,
nil,
" ",
nil,
nil,
nil,
nil,
],
)
end
get "/api/nodes"
expect(last_response).to be_ok
nodes = JSON.parse(last_response.body)
expect(nodes.length).to eq(1)
entry = nodes.first
expect(entry["node_id"]).to eq("!blank")
%w[short_name long_name hw_model snr battery_level voltage uptime_seconds channel_utilization air_util_tx position_time location_source precision_bits latitude longitude altitude].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
expect(entry["role"]).to eq("CLIENT")
expect(entry["last_heard"]).to eq(now)
expect(entry["first_heard"]).to eq(now)
expect(entry["last_seen_iso"]).to eq(Time.at(now).utc.iso8601)
expect(entry).not_to have_key("pos_time_iso")
get "/api/nodes/!blank"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!blank")
expect(payload).not_to have_key("short_name")
expect(payload).not_to have_key("hw_model")
end
end
describe "GET /api/messages" do
@@ -3386,6 +3612,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.status).to eq(404)
end
it "returns 404 for HEAD /api/messages" do
head "/api/messages"
expect(last_response.status).to eq(404)
end
it "returns 404 for POST /api/messages" do
post "/api/messages", {}.to_json, auth_headers
expect(last_response.status).to eq(404)
@@ -3496,6 +3727,55 @@ RSpec.describe "Potato Mesh Sinatra app" do
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
end
it "omits blank values from position responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude, altitude, location_source, precision_bits, sats_in_view, pdop, payload_b64) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
7,
"!pos-blank",
nil,
now,
" ",
nil,
nil,
nil,
nil,
" ",
nil,
nil,
nil,
"",
],
)
end
get "/api/positions"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!pos-blank")
expect(entry["rx_time"]).to eq(now)
expect(entry["rx_iso"]).to eq(Time.at(now).utc.iso8601)
%w[position_time latitude longitude altitude location_source precision_bits sats_in_view pdop payload_b64].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
get "/api/positions/!pos-blank"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("payload_b64")
expect(filtered.first).not_to have_key("location_source")
end
end
describe "GET /api/neighbors" do
@@ -3545,6 +3825,45 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(filtered.first["neighbor_id"]).to eq("!neighbor-new")
expect(filtered.first["rx_time"]).to eq(fresh_rx)
end
it "omits blank values from neighbor responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!origin", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor", "neig", "Neighbor", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!origin", "!neighbor", nil, now],
)
end
get "/api/neighbors"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.length).to eq(1)
entry = payload.first
expect(entry["node_id"]).to eq("!origin")
expect(entry["neighbor_id"]).to eq("!neighbor")
expect(entry["rx_time"]).to eq(now)
expect(entry).not_to have_key("snr")
get "/api/neighbors/!origin"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("snr")
end
end
describe "GET /api/telemetry" do
@@ -3569,6 +3888,15 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(first_entry["telemetry_time_iso"]).to eq(Time.at(latest["telemetry_time"]).utc.iso8601)
expect(first_entry).not_to have_key("device_metrics")
expect_same_value(first_entry["battery_level"], latest.dig("device_metrics", "battery_level") || latest.dig("device_metrics", "batteryLevel"))
expect_same_value(first_entry["current"], latest.dig("device_metrics", "current"))
expect_same_value(first_entry["distance"], latest.dig("environment_metrics", "distance"))
expect_same_value(first_entry["lux"], latest.dig("environment_metrics", "lux"))
expect_same_value(first_entry["wind_direction"], latest.dig("environment_metrics", "windDirection"))
expect_same_value(first_entry["wind_speed"], latest.dig("environment_metrics", "windSpeed"))
expect_same_value(first_entry["weight"], latest.dig("environment_metrics", "weight"))
expect_same_value(first_entry["rainfall_24h"], latest.dig("environment_metrics", "rainfall24h"))
expect_same_value(first_entry["soil_moisture"], latest.dig("environment_metrics", "soilMoisture"))
expect_same_value(first_entry["soil_temperature"], latest.dig("environment_metrics", "soilTemperature"))
second_entry = data.last
expect(second_entry["id"]).to eq(second_latest["id"])
@@ -3576,6 +3904,23 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(second_entry["temperature"]).to be_within(1e-6).of(second_latest["environment_metrics"]["temperature"])
expect(second_entry["relative_humidity"]).to be_within(1e-6).of(second_latest["environment_metrics"]["relativeHumidity"])
expect(second_entry["barometric_pressure"]).to be_within(1e-6).of(second_latest["environment_metrics"]["barometricPressure"])
expect_same_value(second_entry["gas_resistance"], second_latest.dig("environment_metrics", "gasResistance"))
expect_same_value(second_entry["iaq"], second_latest.dig("environment_metrics", "iaq"))
expect_same_value(second_entry["distance"], second_latest.dig("environment_metrics", "distance"))
expect_same_value(second_entry["lux"], second_latest.dig("environment_metrics", "lux"))
expect_same_value(second_entry["white_lux"], second_latest.dig("environment_metrics", "whiteLux"))
expect_same_value(second_entry["ir_lux"], second_latest.dig("environment_metrics", "irLux"))
expect_same_value(second_entry["uv_lux"], second_latest.dig("environment_metrics", "uvLux"))
expect_same_value(second_entry["wind_direction"], second_latest.dig("environment_metrics", "windDirection"))
expect_same_value(second_entry["wind_speed"], second_latest.dig("environment_metrics", "windSpeed"))
expect_same_value(second_entry["wind_gust"], second_latest.dig("environment_metrics", "windGust"))
expect_same_value(second_entry["wind_lull"], second_latest.dig("environment_metrics", "windLull"))
expect_same_value(second_entry["weight"], second_latest.dig("environment_metrics", "weight"))
expect_same_value(second_entry["radiation"], second_latest.dig("environment_metrics", "radiation"))
expect_same_value(second_entry["rainfall_1h"], second_latest.dig("environment_metrics", "rainfall1h"))
expect_same_value(second_entry["rainfall_24h"], second_latest.dig("environment_metrics", "rainfall24h"))
expect_same_value(second_entry["soil_moisture"], second_latest.dig("environment_metrics", "soilMoisture"))
expect_same_value(second_entry["soil_temperature"], second_latest.dig("environment_metrics", "soilTemperature"))
end
it "excludes telemetry entries older than seven days" do
@@ -3609,5 +3954,60 @@ RSpec.describe "Potato Mesh Sinatra app" do
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
end
it "omits blank values from telemetry responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, channel, portnum, hop_limit, snr, rssi, bitfield, payload_b64, battery_level, voltage, channel_utilization, air_util_tx, uptime_seconds, temperature, relative_humidity) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
77,
"!tele-blank",
nil,
now,
" ",
nil,
nil,
"",
nil,
nil,
nil,
nil,
"",
nil,
nil,
nil,
nil,
nil,
nil,
nil,
],
)
end
get "/api/telemetry"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!tele-blank")
expect(entry["rx_time"]).to eq(now)
expect(entry["rx_iso"]).to eq(Time.at(now).utc.iso8601)
%w[telemetry_time channel portnum hop_limit snr rssi bitfield payload_b64 battery_level voltage channel_utilization air_util_tx uptime_seconds temperature relative_humidity].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
get "/api/telemetry/!tele-blank"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("battery_level")
expect(filtered.first).not_to have_key("portnum")
end
end
end
+78 -10
View File
@@ -115,6 +115,58 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".private_mode_enabled?" do
it "returns false when PRIVATE is unset" do
within_env("PRIVATE" => nil) do
expect(described_class.private_mode_enabled?).to be(false)
end
end
it "returns false when PRIVATE=0" do
within_env("PRIVATE" => "0") do
expect(described_class.private_mode_enabled?).to be(false)
end
end
it "returns true when PRIVATE=1" do
within_env("PRIVATE" => "1") do
expect(described_class.private_mode_enabled?).to be(true)
end
end
it "ignores surrounding whitespace" do
within_env("PRIVATE" => " 1 ") do
expect(described_class.private_mode_enabled?).to be(true)
end
end
end
describe ".federation_enabled?" do
it "returns true when FEDERATION is unset" do
within_env("FEDERATION" => nil, "PRIVATE" => "0") do
expect(described_class.federation_enabled?).to be(true)
end
end
it "returns false when FEDERATION=0" do
within_env("FEDERATION" => "0", "PRIVATE" => "0") do
expect(described_class.federation_enabled?).to be(false)
end
end
it "returns false when PRIVATE=1" do
within_env("FEDERATION" => "1", "PRIVATE" => "1") do
expect(described_class.federation_enabled?).to be(false)
end
end
it "ignores surrounding whitespace" do
within_env("FEDERATION" => " 0 ", "PRIVATE" => "0") do
expect(described_class.federation_enabled?).to be(false)
end
end
end
describe ".legacy_well_known_candidates" do
it "includes repository config directories" do
Dir.mktmpdir do |dir|
@@ -138,14 +190,22 @@ RSpec.describe PotatoMesh::Config do
end
describe ".remote_instance_http_timeout" do
it "returns the baked-in connect timeout" do
expect(described_class.remote_instance_http_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
it "returns the baked-in connect timeout when unset" do
within_env("REMOTE_INSTANCE_CONNECT_TIMEOUT" => nil) do
expect(described_class.remote_instance_http_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
end
end
it "ignores environment overrides" do
it "accepts positive environment overrides" do
within_env("REMOTE_INSTANCE_CONNECT_TIMEOUT" => "27") do
expect(described_class.remote_instance_http_timeout).to eq(27)
end
end
it "rejects non-positive overrides" do
within_env("REMOTE_INSTANCE_CONNECT_TIMEOUT" => "0") do
expect(described_class.remote_instance_http_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
@@ -154,14 +214,22 @@ RSpec.describe PotatoMesh::Config do
end
describe ".remote_instance_read_timeout" do
it "returns the baked-in read timeout" do
expect(described_class.remote_instance_read_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
it "returns the baked-in read timeout when unset" do
within_env("REMOTE_INSTANCE_READ_TIMEOUT" => nil) do
expect(described_class.remote_instance_read_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
end
end
it "ignores environment overrides" do
it "accepts positive overrides" do
within_env("REMOTE_INSTANCE_READ_TIMEOUT" => "20") do
expect(described_class.remote_instance_read_timeout).to eq(20)
end
end
it "rejects non-positive overrides" do
within_env("REMOTE_INSTANCE_READ_TIMEOUT" => "-5") do
expect(described_class.remote_instance_read_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
+166
View File
@@ -0,0 +1,166 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require "sqlite3"
RSpec.describe PotatoMesh::App::Database do
let(:harness_class) do
Class.new do
extend PotatoMesh::App::Database
extend PotatoMesh::App::Helpers
class << self
attr_reader :warnings
# Capture warning log entries generated during migrations for
# inspection within the unit tests.
#
# @param message [String] warning message text.
# @param context [String] logical source of the log entry.
# @param metadata [Hash] structured metadata supplied by the caller.
# @return [void]
def warn_log(message, context:, **metadata)
@warnings ||= []
@warnings << { message: message, context: context, metadata: metadata }
end
# Capture debug log entries generated during migrations for
# completeness of the helper interface.
#
# @param message [String] debug message text.
# @param context [String] logical source of the log entry.
# @param metadata [Hash] structured metadata supplied by the caller.
# @return [void]
def debug_log(message, context:, **metadata)
@debug_entries ||= []
@debug_entries << { message: message, context: context, metadata: metadata }
end
# Reset captured log entries between test examples.
#
# @return [void]
def reset_logs!
@warnings = []
@debug_entries = []
end
end
end
end
around do |example|
harness_class.reset_logs!
Dir.mktmpdir("db-upgrade-spec-") do |dir|
db_path = File.join(dir, "mesh.db")
RSpec::Mocks.with_temporary_scope do
allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path)
allow(PotatoMesh::Config).to receive(:default_db_path).and_return(db_path)
allow(PotatoMesh::Config).to receive(:legacy_db_path).and_return(db_path)
example.run
end
end
ensure
harness_class.reset_logs!
end
# Retrieve column names for the requested table within the temporary
# database used for upgrade tests.
#
# @param table [String] table name whose columns should be returned.
# @return [Array<String>] names of the columns defined on +table+.
def column_names_for(table)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true)
db.execute("PRAGMA table_info(#{table})").map { |row| row[1] }
ensure
db&.close
end
it "adds missing telemetry columns when upgrading an existing schema" do
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
db.execute("CREATE TABLE nodes(node_id TEXT)")
db.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)")
db.execute <<~SQL
CREATE TABLE telemetry (
id INTEGER PRIMARY KEY,
node_id TEXT,
node_num INTEGER,
from_id TEXT,
to_id TEXT,
rx_time INTEGER NOT NULL,
rx_iso TEXT NOT NULL,
telemetry_time INTEGER,
channel INTEGER,
portnum TEXT,
hop_limit INTEGER,
snr REAL,
rssi INTEGER,
bitfield INTEGER,
payload_b64 TEXT,
battery_level REAL,
voltage REAL,
channel_utilization REAL,
air_util_tx REAL,
uptime_seconds INTEGER,
temperature REAL,
relative_humidity REAL,
barometric_pressure REAL
)
SQL
end
harness_class.ensure_schema_upgrades
telemetry_columns = column_names_for("telemetry")
expect(telemetry_columns).to include(
"gas_resistance",
"current",
"iaq",
"distance",
"lux",
"white_lux",
"ir_lux",
"uv_lux",
"wind_direction",
"wind_speed",
"weight",
"wind_gust",
"wind_lull",
"radiation",
"rainfall_1h",
"rainfall_24h",
"soil_moisture",
"soil_temperature",
)
expect { harness_class.ensure_schema_upgrades }.not_to raise_error
end
it "initialises the telemetry table when it is missing" do
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
db.execute("CREATE TABLE nodes(node_id TEXT)")
db.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)")
end
expect(column_names_for("telemetry")).to be_empty
harness_class.ensure_schema_upgrades
telemetry_columns = column_names_for("telemetry")
expect(telemetry_columns).to include("soil_temperature", "lux", "iaq")
expect(telemetry_columns).to include("rx_time", "battery_level")
end
end
+76
View File
@@ -298,6 +298,38 @@ RSpec.describe PotatoMesh::App::Federation do
end
end
describe ".federation_user_agent_header" do
it "combines the version and sanitized domain" do
allow(federation_helpers).to receive(:app_constant).and_call_original
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("9.9.9")
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return("Example.Mesh")
header = federation_helpers.federation_user_agent_header
expect(header).to eq("PotatoMesh/9.9.9 (+https://example.mesh)")
end
it "falls back to the product name when the domain is unavailable" do
allow(federation_helpers).to receive(:app_constant).and_call_original
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("1.2.3")
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return(nil)
header = federation_helpers.federation_user_agent_header
expect(header).to eq("PotatoMesh/1.2.3")
end
it "uses an explicit unknown marker when the version is blank" do
allow(federation_helpers).to receive(:app_constant).and_call_original
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("")
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return("Example.Mesh")
header = federation_helpers.federation_user_agent_header
expect(header).to eq("PotatoMesh/unknown (+https://example.mesh)")
end
end
describe ".perform_instance_http_request" do
let(:uri) { URI.parse("https://remote.example.com/api") }
let(:http_client) { instance_double(Net::HTTP) }
@@ -350,6 +382,30 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.send(:perform_instance_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "ArgumentError: restricted domain")
end
it "applies federation headers to instance fetch requests" do
connection = instance_double("Net::HTTPConnection")
success_response = Net::HTTPOK.new("1.1", "200", "OK")
allow(success_response).to receive(:body).and_return("{}")
allow(success_response).to receive(:code).and_return("200")
captured_request = nil
allow(http_client).to receive(:start) do |&block|
block.call(connection)
end
allow(connection).to receive(:request) do |request|
captured_request = request
success_response
end
result = federation_helpers.send(:perform_instance_http_request, uri)
expect(result).to eq("{}")
expect(captured_request).not_to be_nil
expect(captured_request["Accept"]).to eq("application/json")
expect(captured_request["User-Agent"]).to eq(federation_helpers.send(:federation_user_agent_header))
expect(captured_request["Content-Type"]).to be_nil
end
end
describe ".announce_instance_to_domain" do
@@ -399,5 +455,25 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.warn_messages.count { |message| message.include?("Federation announcement raised exception") },
).to eq(2)
end
it "applies federation headers to announcement requests" do
https_client = instance_double(Net::HTTP)
allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client)
captured_request = nil
allow(https_client).to receive(:start).and_yield(http_connection).and_return(success_response)
allow(http_connection).to receive(:request) do |request|
captured_request = request
success_response
end
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
expect(result).to be(true)
expect(captured_request).not_to be_nil
expect(captured_request["Content-Type"]).to eq("application/json")
expect(captured_request["Accept"]).to eq("application/json")
expect(captured_request["User-Agent"]).to eq(federation_helpers.send(:federation_user_agent_header))
end
end
end
+99
View File
@@ -0,0 +1,99 @@
# Copyright (C) 2025 l5yth
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require "sqlite3"
RSpec.describe PotatoMesh::App::Instances do
let(:application_class) { PotatoMesh::Application }
let(:week_seconds) { PotatoMesh::Config.week_seconds }
# Execute the provided block with a configured SQLite connection.
#
# @param readonly [Boolean] whether the connection should be read-only.
# @yieldparam db [SQLite3::Database] configured database handle.
# @return [void]
def with_db(readonly: false)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly)
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
db.execute("PRAGMA foreign_keys = ON")
yield db
ensure
db&.close
end
before do
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
application_class.init_db unless application_class.db_schema_present?
with_db do |db|
db.execute("DELETE FROM instances")
end
end
describe ".load_instances_for_api" do
it "only returns instances updated within the configured rolling window" do
fixed_time = Time.utc(2025, 1, 15, 12, 0, 0)
allow(Time).to receive(:now).and_return(fixed_time)
application_class.ensure_self_instance_record!
recent_timestamp = fixed_time.to_i - (week_seconds / 2)
stale_timestamp = fixed_time.to_i - week_seconds - 60
with_db do |db|
db.execute(
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private) VALUES (?, ?, ?, ?, ?)",
[
"recent-instance",
"recent.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
recent_timestamp,
0,
],
)
db.execute(
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private) VALUES (?, ?, ?, ?, ?)",
[
"stale-instance",
"stale.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
stale_timestamp,
0,
],
)
db.execute(
"INSERT INTO instances (id, domain, pubkey, is_private) VALUES (?, ?, ?, ?)",
[
"missing-instance",
"missing.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
0,
],
)
end
payload = application_class.load_instances_for_api
domains = payload.map { |row| row["domain"] }
lower_bound = fixed_time.to_i - week_seconds
expect(domains).to include("recent.mesh.test")
expect(domains).to include(application_class.app_constant(:INSTANCE_DOMAIN))
expect(domains).not_to include("stale.mesh.test")
expect(domains).not_to include("missing.mesh.test")
expect(payload.all? { |row| row["lastUpdateTime"] >= lower_bound }).to be(true)
end
end
end
+14 -4
View File
@@ -76,10 +76,20 @@
<% body_classes = [] %>
<% body_classes << "dark" if initial_theme == "dark" %>
<body class="<%= body_classes.join(" ") %>" data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>" data-theme="<%= initial_theme %>">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<div class="site-header">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if !private_mode && federation_enabled %>
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
</div>
<% end %>
</div>
<div class="row meta">
<div class="meta-info">
<div class="refresh-row">