Compare commits

..

5 Commits

Author SHA1 Message Date
l5y 7160d72aae web: display sats in view (#523) 2025-11-26 22:01:53 +01:00
l5y f4aee2aba4 web: display air quality in separate chart (#521) 2025-11-26 21:02:59 +01:00
l5y a789a9552a Add macOS and Ubuntu builds to Flutter workflow (#519)
* Add macOS and Ubuntu builds to Flutter workflow

* Add no-codesign flag to iOS Flutter build
2025-11-26 20:53:47 +01:00
l5y 9fd1401737 web:add current to charts (#520)
* web:add current to charts

* cover missing unit test vectors
2025-11-26 20:45:04 +01:00
l5y 9ae837582b app: fix notification icon (#518)
* app: fix notification icon

* cover missing unit test vectors
2025-11-26 18:48:22 +01:00
19 changed files with 697 additions and 54 deletions
+12 -1
View File
@@ -25,7 +25,12 @@ permissions:
jobs:
flutter:
runs-on: ubuntu-latest
strategy:
matrix:
os:
- ubuntu-latest
- macos-latest
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: ./app
@@ -44,6 +49,12 @@ jobs:
run: dart format --set-exit-if-changed .
- name: Analyze Dart code
run: flutter analyze
- name: Build Android debug APK
if: matrix.os == 'ubuntu-latest'
run: flutter build apk --debug
- name: Build iOS debug IPA
if: matrix.os == 'macos-latest'
run: flutter build ipa --debug --no-codesign
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v5
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright © 2025-26 l5yth & contributors -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 126.00 231.38 C116.24,230.24 108.69,228.55 97.57,225.02 C89.71,222.53 81.61,220.73 74.92,220.01 C56.54,218.03 47.54,215.19 35.90,207.72 C25.90,201.31 16.09,188.73 11.28,176.17 C9.05,170.34 8.73,167.93 8.69,157.00 C8.66,146.56 9.01,143.52 10.83,138.58 C13.71,130.75 16.90,125.44 22.99,118.35 C25.75,115.13 30.06,108.55 32.57,103.72 C39.65,90.08 52.73,77.41 61.66,75.54 C63.80,75.09 65.00,74.16 65.38,72.61 C66.30,68.96 73.33,61.70 79.81,57.70 C83.11,55.66 86.29,54.00 86.90,54.00 C87.50,54.00 91.26,50.90 95.25,47.11 C107.07,35.89 118.95,31.10 131.78,32.42 C139.49,33.21 147.99,37.24 153.00,42.48 C154.93,44.50 160.84,48.38 166.15,51.12 C175.72,56.04 185.00,64.04 185.00,67.35 C185.00,68.84 187.05,70.00 189.69,70.00 C190.52,70.00 193.21,71.35 195.66,73.00 C198.10,74.65 200.30,76.00 200.55,76.00 C201.87,76.00 209.57,85.37 213.05,91.22 C215.39,95.15 221.15,102.01 226.90,107.72 C237.44,118.20 241.06,123.94 244.87,136.29 C252.34,160.53 244.67,187.19 225.29,204.27 C215.26,213.12 206.19,217.02 189.50,219.66 C184.00,220.54 174.32,222.95 168.00,225.04 C152.19,230.25 136.54,232.62 126.00,231.38 ZM 149.23 220.54 C152.93,219.75 160.90,217.52 166.93,215.57 C172.97,213.61 182.16,211.33 187.36,210.50 C192.56,209.66 198.70,208.31 200.99,207.50 C219.04,201.13 232.11,186.69 236.54,168.21 C239.68,155.13 237.23,137.60 230.79,127.15 C229.56,125.14 224.01,118.78 218.45,113.00 C212.70,107.01 206.60,99.49 204.27,95.50 C200.27,88.66 197.03,85.00 192.03,81.65 C188.54,79.32 187.14,79.59 186.36,82.75 C184.35,90.89 178.61,95.54 166.19,99.09 C162.70,100.09 157.74,102.15 155.17,103.68 C142.50,111.19 133.13,113.70 123.94,112.03 C120.95,111.49 114.12,108.78 108.77,106.02 C100.44,101.72 97.99,100.92 91.77,100.48 C81.44,99.74 78.08,99.08 74.05,97.01 C69.74,94.80 66.49,91.44 65.08,87.73 C64.04,84.97 64.02,84.96 60.27,86.48 C54.59,88.78 45.89,98.56 40.58,108.65 C38.01,113.52 33.29,120.79 30.09,124.80 C22.80,133.95 19.57,140.55 17.99,149.58 C14.84,167.46 24.86,188.52 41.92,199.87 C51.75,206.40 57.70,208.37 74.36,210.57 C80.89,211.43 90.12,213.30 94.86,214.73 C115.79,221.03 120.20,221.90 131.50,221.93 C137.55,221.95 145.53,221.32 149.23,220.54 ZM 136.76 102.49 C140.36,101.65 145.79,99.27 149.54,96.90 C153.18,94.60 159.03,92.02 162.79,91.05 C170.97,88.95 173.22,87.80 175.92,84.37 C178.38,81.24 178.67,75.20 176.55,71.10 C174.28,66.71 168.40,62.13 160.26,58.42 C155.62,56.31 150.53,52.93 146.85,49.52 C133.35,37.02 118.65,38.50 101.56,54.08 C97.39,57.87 91.17,62.38 87.74,64.10 C77.86,69.03 73.00,74.56 73.00,80.87 C73.00,88.43 77.15,90.86 91.91,91.95 C102.41,92.73 103.22,92.96 111.55,97.50 C124.46,104.54 126.44,104.93 136.76,102.49 ZM 88.35 189.48 C87.37,186.91 91.59,125.01 92.84,123.76 C94.47,122.13 105.29,121.55 107.91,122.95 C109.31,123.70 113.25,130.52 118.80,141.81 L 127.51 159.50 L 136.00 142.15 C140.68,132.61 145.09,124.17 145.82,123.40 C146.71,122.46 149.41,122.00 154.00,122.00 C161.67,122.00 163.99,123.34 164.02,127.82 C164.03,129.29 164.70,140.18 165.50,152.00 C167.35,179.25 167.41,189.65 165.75,190.21 C165.06,190.44 161.57,190.38 158.00,190.07 L 151.50 189.50 L 150.36 170.00 C149.73,159.27 149.14,150.27 149.06,150.00 C148.98,149.73 145.22,156.70 140.71,165.50 L 132.50 181.50 L 127.50 181.50 L 122.50 181.50 L 114.25 165.08 C107.87,152.38 105.97,149.32 105.88,151.58 C105.82,153.19 105.81,154.81 105.86,155.18 C105.91,155.55 105.48,163.42 104.90,172.67 C104.23,183.39 103.43,189.74 102.68,190.22 C102.03,190.63 98.67,190.98 95.22,190.98 C90.51,191.00 88.79,190.62 88.35,189.48 ZM 61.61 182.42 C60.64,179.90 60.89,178.25 62.48,176.66 C65.63,173.51 69.97,177.37 68.50,182.00 C67.71,184.48 62.52,184.80 61.61,182.42 ZM 216.53 167.38 C215.64,166.50 215.07,165.29 215.26,164.71 C215.85,162.94 218.62,161.64 220.36,162.31 C222.46,163.11 222.50,167.60 220.42,168.39 C218.30,169.21 218.37,169.22 216.53,167.38 ZM 36.04 153.55 C33.39,150.36 37.30,145.34 40.42,147.93 C42.30,149.49 42.47,152.13 40.80,153.80 C39.20,155.40 37.51,155.31 36.04,153.55 ZM 201.16 148.12 C199.41,147.01 200.04,142.99 202.19,141.64 C203.48,140.83 204.37,141.02 205.94,142.45 C208.41,144.68 208.48,145.38 206.43,147.43 C204.67,149.18 203.15,149.38 201.16,148.12 ZM 67.34 137.43 C66.47,135.18 68.24,132.50 70.61,132.50 C73.61,132.50 73.27,138.39 70.25,138.82 C68.83,139.02 67.75,138.50 67.34,137.43 ZM 56.54 116.66 C55.17,115.65 54.88,114.62 55.39,112.59 C55.94,110.38 56.56,109.93 58.68,110.19 C62.53,110.65 63.88,113.37 61.55,115.94 C59.36,118.37 58.96,118.42 56.54,116.66 ZM 186.67 113.33 C185.21,111.88 186.09,108.02 188.12,106.93 C189.87,106.00 190.60,106.15 192.23,107.78 C195.00,110.56 193.82,114.00 190.10,114.00 C188.58,114.00 187.03,113.70 186.67,113.33 Z" />
</vector>
+1 -1
View File
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
<string>14.0</string>
</dict>
</plist>
+56
View File
@@ -0,0 +1,56 @@
# Copyright © 2025-26 l5yth & contributors
#
# 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.
platform :ios, "14.0"
ENV["COCOAPODS_DISABLE_STATS"] = "true"
project "Runner", {
"Debug" => :debug,
"Profile" => :release,
"Release" => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join("..", "Flutter", "Generated.xcconfig"), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Run flutter pub get and try again."
end
require File.expand_path(File.join("packages", "flutter_tools", "bin", "podhelper"), flutter_root)
flutter_ios_podfile_setup
target "Runner" do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = "14.0"
end
end
end
+3 -3
View File
@@ -346,7 +346,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -472,7 +472,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -523,7 +523,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+109 -23
View File
@@ -45,6 +45,7 @@ const String _notificationChannelId = 'mesh.messages';
const String _notificationChannelName = 'Mesh messages';
const String _notificationChannelDescription =
'Alerts when new PotatoMesh messages arrive';
const String _notificationIconName = 'ic_mesh_notification';
const String _backgroundTaskName = 'mesh_message_poll';
const String _backgroundTaskId = 'mesh.message.poll';
const Duration _backgroundFetchInterval = Duration(minutes: 15);
@@ -61,9 +62,32 @@ abstract class NotificationClient {
required MeshMessage message,
required String domain,
String? senderShortName,
String? senderLongName,
});
}
/// Display names used when rendering a notification title and subtitle.
class NotificationSender {
const NotificationSender({
required this.shortName,
this.longName,
});
/// Short name fallback for the sender when a long name is unavailable.
final String shortName;
/// Full descriptive name for the sender when provided by the mesh.
final String? longName;
/// Preferred display name that favors the long form when available.
String get preferredName {
if (longName != null && longName!.trim().isNotEmpty) {
return longName!.trim();
}
return shortName.trim();
}
}
/// No-op notification client used in tests and web builds.
class NoopNotificationClient implements NotificationClient {
const NoopNotificationClient();
@@ -76,6 +100,7 @@ class NoopNotificationClient implements NotificationClient {
required MeshMessage message,
required String domain,
String? senderShortName,
String? senderLongName,
}) async {}
}
@@ -102,14 +127,9 @@ class LocalNotificationClient implements NotificationClient {
_initialized = true;
return;
}
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosInit = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
final settings =
const InitializationSettings(android: androidInit, iOS: iosInit);
final androidInit = buildAndroidInitializationSettings();
final iosInit = buildDarwinInitializationSettings();
final settings = InitializationSettings(android: androidInit, iOS: iosInit);
await _plugin.initialize(settings);
final android = _plugin.resolvePlatformSpecificImplementation<
@@ -137,11 +157,14 @@ class LocalNotificationClient implements NotificationClient {
priority: Priority.high,
enableVibration: true,
playSound: true,
category: AndroidNotificationCategory.message,
icon: _notificationIconName,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
categoryIdentifier: 'chat',
);
return const NotificationDetails(
android: androidDetails,
@@ -149,22 +172,64 @@ class LocalNotificationClient implements NotificationClient {
);
}
/// Builds the default Android initialization settings.
@visibleForTesting
AndroidInitializationSettings buildAndroidInitializationSettings() {
return const AndroidInitializationSettings(_notificationIconName);
}
/// Builds the default Darwin (iOS) initialization settings.
@visibleForTesting
DarwinInitializationSettings buildDarwinInitializationSettings() {
return const DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
}
/// Exposes notification details for test assertions.
@visibleForTesting
NotificationDetails notificationDetailsForTest() {
return _notificationDetails();
}
/// Picks the preferred sender name prioritising the long form when present.
String _preferredSenderName({
required MeshMessage message,
String? senderLongName,
String? senderShortName,
}) {
final trimmedLong = senderLongName?.trim();
if (trimmedLong != null && trimmedLong.isNotEmpty) {
return trimmedLong;
}
final trimmedShort = senderShortName?.trim();
if (trimmedShort != null && trimmedShort.isNotEmpty) {
return trimmedShort;
}
return message.fromShort;
}
@override
Future<void> showNewMessage({
required MeshMessage message,
required String domain,
String? senderShortName,
String? senderLongName,
}) async {
await initialize();
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return;
debugPrint('D/Notifications: showing message ${message.id} on $domain');
final displaySender = senderShortName?.trim().isNotEmpty == true
? senderShortName!.trim()
: message.fromShort;
final displaySender = _preferredSenderName(
senderLongName: senderLongName,
senderShortName: senderShortName,
message: message,
);
final channel = message.channelName?.trim().isNotEmpty == true
? message.channelName!.trim()
: domain;
final title = 'New message from $displaySender';
final title = displaySender;
final body = message.text.trim().isNotEmpty
? message.text.trim()
: 'New message on $channel';
@@ -337,17 +402,17 @@ class BackgroundSyncManager {
'D/BackgroundSync: task=$task domain=$domain fetched=${messages.length} unseen=${unseen.length}');
for (final message in unseen) {
final sender = NodeShortNameCache.fallbackShortName(
message.lookupNodeId.isNotEmpty
? message.lookupNodeId
: message.fromId,
final sender = repository.resolveNotificationSender(
domain: domain,
message: message,
);
debugPrint(
'D/BackgroundSync: notifying message=${message.id} sender=$sender');
'D/BackgroundSync: notifying message=${message.id} sender=${sender.preferredName}');
await notification.showNewMessage(
message: message,
domain: domain,
senderShortName: sender,
senderShortName: sender.shortName,
senderLongName: sender.longName,
);
}
return true;
@@ -1619,6 +1684,27 @@ class MeshRepository implements MeshNodeResolver {
}
}
/// Resolves the best-effort sender names for notification payloads.
NotificationSender resolveNotificationSender({
required String domain,
required MeshMessage message,
}) {
final node = findNode(domain, message.lookupNodeId);
final trimmedLong = node?.longName.trim();
final fallbackId =
message.lookupNodeId.isNotEmpty ? message.lookupNodeId : message.fromId;
final shortFromNode = node?.displayShortName.trim();
final shortName = (shortFromNode != null && shortFromNode.isNotEmpty)
? shortFromNode
: NodeShortNameCache.fallbackShortName(fallbackId);
return NotificationSender(
shortName: shortName,
longName:
trimmedLong != null && trimmedLong.isNotEmpty ? trimmedLong : null,
);
}
bool _matchesNodeId(String existing, String candidate) {
final cleanExisting = _normalizeNodeId(existing);
final cleanCandidate = _normalizeNodeId(candidate);
@@ -1865,15 +1951,15 @@ class _MessagesScreenState extends State<MessagesScreen>
}
await _notificationReady;
for (final message in unseen) {
final sender = NodeShortNameCache.fallbackShortName(
message.lookupNodeId.isNotEmpty
? message.lookupNodeId
: message.fromId,
final sender = repo.resolveNotificationSender(
domain: widget.domain,
message: message,
);
await _notificationClient.showNewMessage(
message: message,
domain: widget.domain,
senderShortName: sender,
senderShortName: sender.shortName,
senderLongName: sender.longName,
);
}
} catch (error) {
+29 -1
View File
@@ -56,6 +56,10 @@ class _FakeNotificationClient extends NotificationClient {
_FakeNotificationClient();
int calls = 0;
MeshMessage? lastMessage;
String? lastDomain;
String? lastShortName;
String? lastLongName;
@override
Future<void> initialize() async {}
@@ -65,8 +69,13 @@ class _FakeNotificationClient extends NotificationClient {
required MeshMessage message,
required String domain,
String? senderShortName,
String? senderLongName,
}) async {
calls += 1;
lastMessage = message;
lastDomain = domain;
lastShortName = senderShortName;
lastLongName = senderLongName;
}
}
@@ -75,11 +84,15 @@ class _FakeRepository extends MeshRepository {
required this.domain,
required this.messages,
required this.unseen,
}) : super();
NotificationSender? sender,
}) : sender = sender ??
const NotificationSender(shortName: 'MOCK', longName: 'Mocky'),
super();
final String domain;
final List<MeshMessage> messages;
final List<MeshMessage> unseen;
final NotificationSender sender;
int loadMessagesCalls = 0;
@override
@@ -101,6 +114,14 @@ class _FakeRepository extends MeshRepository {
}) async {
return unseen;
}
@override
NotificationSender resolveNotificationSender({
required String domain,
required MeshMessage message,
}) {
return sender;
}
}
MeshMessage _buildMessage(int id, String text) {
@@ -133,6 +154,10 @@ void main() {
domain: 'potatomesh.net',
messages: [_buildMessage(1, 'hello')],
unseen: [_buildMessage(2, 'new')],
sender: const NotificationSender(
shortName: 'TEST',
longName: 'Test Sender',
),
);
final notifier = _FakeNotificationClient();
@@ -160,6 +185,9 @@ void main() {
expect(handled, isTrue);
expect(fakeRepo.loadMessagesCalls, 1);
expect(notifier.calls, 1);
expect(notifier.lastDomain, 'potatomesh.net');
expect(notifier.lastShortName, 'TEST');
expect(notifier.lastLongName, 'Test Sender');
});
test('returns true and no-ops when dependencies are missing', () async {
+31
View File
@@ -0,0 +1,31 @@
// Copyright © 2025-26 l5yth & contributors
//
// 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 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:potato_mesh_reader/main.dart';
void main() {
test('uses drawable name for Android init and notifications', () {
final client = LocalNotificationClient();
final androidInit = client.buildAndroidInitializationSettings();
expect(androidInit.defaultIcon, 'ic_mesh_notification');
final details = client.notificationDetailsForTest();
final androidDetails = details.android as AndroidNotificationDetails;
expect(androidDetails.icon, 'ic_mesh_notification');
expect(androidDetails.category, AndroidNotificationCategory.message);
});
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright © 2025-26 l5yth & contributors
//
// 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 'package:flutter_test/flutter_test.dart';
import 'package:potato_mesh_reader/main.dart';
class _StubRepository extends MeshRepository {
_StubRepository(this.node) : super();
final MeshNode? node;
@override
MeshNode? findNode(String domain, String nodeId) {
return node;
}
}
MeshMessage _buildMessage({
required int id,
required String nodeId,
String text = 'hello',
}) {
final rx = DateTime.utc(2024, 1, 1, 12, id);
return MeshMessage(
id: id,
rxTime: rx,
rxIso: rx.toIso8601String(),
fromId: nodeId,
nodeId: nodeId,
toId: '^',
channel: 1,
channelName: 'Main',
portnum: 'TEXT',
text: text,
rssi: -50,
snr: 1.0,
hopLimit: 1,
);
}
void main() {
setUp(() {
NodeShortNameCache.instance.clear();
});
test('prefers node long name when resolving sender', () {
const node = MeshNode(
nodeId: '!NODE1',
shortName: 'N1',
longName: 'Verbose Node',
);
final repo = _StubRepository(node);
final sender = repo.resolveNotificationSender(
domain: 'potatomesh.net',
message: _buildMessage(id: 1, nodeId: '!NODE1'),
);
expect(sender.longName, 'Verbose Node');
expect(sender.shortName, 'N1');
expect(sender.preferredName, 'Verbose Node');
});
test('falls back to short identifier when metadata is missing', () {
final repo = _StubRepository(null);
final sender = repo.resolveNotificationSender(
domain: 'potatomesh.net',
message: _buildMessage(id: 2, nodeId: '!NODE2'),
);
expect(sender.longName, isNull);
expect(sender.shortName, 'ODE2');
expect(sender.preferredName, 'ODE2');
});
}
+79
View File
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:ns1="http://sozi.baierouge.fr"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:dc="http://purl.org/dc/elements/1.1/"
id="svg7158"
viewBox="0 0 453.54 453.54"
version="1.1"
>
<title
id="title7193"
>Satellite Icon</title
>
<g
id="layer1"
transform="translate(-174.99 -97.13)"
>
<path
id="path7145"
style="fill:#000000"
d="m287.44 286.58c8.8583-0.0756 18.161 1.2414 27.843 4.3505 23.639 7.5916 49.437 23.77 72.544 46.876 22.316 22.316 38.195 47.044 46.114 70.042 3.5848 10.411 5.3257 20.665 5.3294 30.127 34.464-34.381 74.719-74.501 74.719-74.501 8.8046-8.8046 10.662-25.016 4.0244-45.788-6.6374-20.772-21.622-44.897-43.287-66.562-21.664-21.665-45.789-36.649-66.561-43.287-20.772-6.6373-36.984-4.7803-45.788 4.0241a7.3368 7.3368 0 0 1 -0.32655 0.32655c0.00015-0.00038-0.15572-0.0378-0.32656 0.10658a7.3368 7.3368 0 0 1 -0.10733 0.10809c-0.0755 0.0755-0.23434 0.23735-0.3258 0.32655a7.3368 7.3368 0 0 1 -0.10734 0.10734c-0.0227 0.0227-0.0755 0.0907-0.10734 0.10734-0.27741 0.27062-0.46261 0.46942-0.87004 0.87005-1.3246 1.3035-3.2358 3.2497-5.7641 5.7642-5.0603 5.0256-12.407 12.42-21.317 21.317-11.973 11.959-29.332 29.327-45.679 45.68zm-40.639-122.78-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-115.4-38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm233 156.07-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-115.4-38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-26.89-249.86c14.671-18.593 23.601-32.737 23.601-32.737 6.0975-9.8498 8.7897-20.878 6.5255-33.498-2.2639-12.621-9.7854-27.187-25.667-43.069-15.989-15.989-29.977-23.759-42.199-26.103-12.222-2.3439-23.154 0.29026-34.586 6.9608a8.7573 8.7573 0 0 1 -0.10733 0.10734c-0.90581 0.51174-0.23131 0.0975-0.4354 0.21694-0.20486 0.11566-0.55106 0.2948-0.97959 0.54275-0.85462 0.49889-2.117 1.2198-3.8063 2.284-3.3786 2.1287-8.3084 5.4327-14.574 10.006-3.793 2.7685-8.1932 6.2168-12.943 10.115 20.824 8.7749 42.483 23.558 62.211 43.287 19.646 19.646 34.196 41.165 42.96 61.885zm-243.08 138.23c15.825 15.825 27.262 20.215 35.673 20.012 8.2898-0.19956 16.009-4.9974 24.362-13.269 0.18141-0.20258 0.17082-0.17235 0.32654-0.32579 0.67351-0.65991 1.1912-1.0953 1.5228-1.3049 0.81396-0.51552 1.1331-0.63722 1.4148-0.75877 0.5639-0.24415 0.87103-0.43313 1.196-0.54349 0.65234-0.22073 1.3406-0.40517 2.1753-0.65311 1.67-0.49361 3.9356-1.1132 6.6343-1.8491 5.3972-1.4715 12.536-3.2413 19.686-5.1116 12.5-3.2709 22.067-5.7697 25.015-6.5254 5.2003-6.2337 6.9486-16.641 2.8278-30.888-4.3795-15.141-14.954-33.009-30.779-48.834s-33.693-26.4-48.834-30.779c-14.246-4.121-24.654-2.3723-30.888 2.8278-0.39307 1.4287-0.92984 3.0175-1.5226 5.2205-1.4259 5.3024-3.3261 12.432-5.2202 19.577-3.793 14.289-7.613 28.713-7.613 28.713a9.2981 9.2981 0 0 1 -2.3919 4.1331c-8.4974 8.4974-13.392 16.277-13.595 24.689-0.20259 8.4119 4.1869 19.848 20.012 35.673z"
/>
</g
>
<metadata
>
<rdf:RDF
>
<cc:Work
>
<dc:format
>image/svg+xml</dc:format
>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage"
/>
<cc:license
rdf:resource="http://creativecommons.org/licenses/publicdomain/"
/>
<dc:publisher
>
<cc:Agent
rdf:about="http://openclipart.org/"
>
<dc:title
>Openclipart</dc:title
>
</cc:Agent
>
</dc:publisher
>
</cc:Work
>
<cc:License
rdf:about="http://creativecommons.org/licenses/publicdomain/"
>
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction"
/>
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution"
/>
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
/>
</cc:License
>
</rdf:RDF
>
</metadata
>
</svg
>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -113,6 +113,36 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
});
});
test('refreshNodeInformation surfaces sats_in_view from the latest position packet', async () => {
const calls = [];
const responses = new Map([
['/api/nodes/!sat?limit=7', createResponse(200, {
node_id: '!sat',
short_name: 'SAT',
})],
['/api/telemetry/!sat?limit=1000', createResponse(404, { error: 'not found' })],
['/api/positions/!sat?limit=7', createResponse(200, [
{ node_id: '!sat', position_time: 200, rx_time: 200, latitude: 1.1, longitude: 2.1, sats_in_view: 8 },
{ node_id: '!sat', position_time: 100, rx_time: 100, latitude: 1, longitude: 2, sats_in_view: 3 },
])],
['/api/neighbors/!sat?limit=1000', createResponse(404, { error: 'not found' })],
]);
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return responses.get(url) ?? createResponse(404, { error: 'not found' });
};
const node = await refreshNodeInformation({ nodeId: '!sat' }, { fetchImpl });
assert.equal(node.satsInView, 8);
assert.equal(node.sats_in_view, 8);
assert.ok(node.position);
assert.equal(node.position.sats_in_view, 8);
assert.equal(node.rawSources.position.sats_in_view, 8);
assert.equal(calls.length, 4);
});
test('refreshNodeInformation normalizes telemetry aliases for downstream consumers', async () => {
const responses = new Map([
['/api/nodes/!chan?limit=7', createResponse(404, { error: 'not found' })],
@@ -354,12 +354,14 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
voltage: 4.1,
channel_utilization: 40,
air_util_tx: 22,
current: 0.75,
},
environment_metrics: {
temperature: 19.5,
relative_humidity: 55,
barometric_pressure: 995,
gas_resistance: 1500,
iaq: 83,
},
},
{
@@ -369,12 +371,14 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
voltage: 4.05,
channelUtilization: 35,
airUtilTx: 20,
current: 0.65,
},
environmentMetrics: {
temperature: 18.4,
relativeHumidity: 52,
barometricPressure: 1000,
gasResistance: 2000,
iaq: 88,
},
},
],
@@ -387,12 +391,15 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
assert.equal(html.includes('Environmental telemetry'), true);
assert.equal(html.includes('Battery (0-100%)'), true);
assert.equal(html.includes('Voltage (0-6V)'), true);
assert.equal(html.includes('Battery (%)'), true);
assert.equal(html.includes('Voltage (V)'), true);
assert.equal(html.includes('Current (A)'), true);
assert.equal(html.includes('Channel utilization (%)'), true);
assert.equal(html.includes('Air util TX (%)'), true);
assert.equal(html.includes('Utilization (%)'), true);
assert.equal(html.includes('Gas resistance (\u03a9)'), true);
assert.equal(html.includes('Air quality'), true);
assert.equal(html.includes('IAQ index'), true);
assert.equal(html.includes('Temperature (\u00b0C)'), true);
assert.equal(html.includes(expectedDate), true);
assert.equal(html.includes('node-detail__chart-point'), true);
@@ -454,6 +461,7 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present',
battery_level: 75,
voltage: 4.08,
channel_utilization: 30,
current: 0.42,
temperature: 20,
relative_humidity: 45,
barometric_pressure: 990,
@@ -469,6 +477,7 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present',
});
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
assert.equal(html.includes('Air quality'), true);
});
test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
@@ -0,0 +1,47 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* 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 { renderSatsInViewBadge, resolveSatsInView, __testUtils } from '../short-info-satellites.js';
const { toPositiveInteger } = __testUtils;
test('resolveSatsInView inspects aliases and nested payloads', () => {
assert.equal(resolveSatsInView({ sats_in_view: '3.6' }), 4);
assert.equal(resolveSatsInView({ position: { satsInView: 5 } }), 5);
assert.equal(resolveSatsInView({ rawSources: { position: { sats_in_view: 9 } } }), 9);
assert.equal(resolveSatsInView({ satsInView: 0 }), null);
assert.equal(resolveSatsInView(null), null);
});
test('renderSatsInViewBadge returns markup only for positive counts', () => {
const html = renderSatsInViewBadge({ satsInView: 6 });
assert.match(html, /short-info-sats/);
assert.ok(html.includes('satellite-icon.svg'));
assert.match(html, />6</);
assert.equal(renderSatsInViewBadge({ satsInView: 0 }), '');
assert.equal(renderSatsInViewBadge({ position: { sats_in_view: -1 } }), '');
});
test('toPositiveInteger normalizes numeric values defensively', () => {
assert.equal(toPositiveInteger('7.2'), 7);
assert.equal(toPositiveInteger(''), null);
assert.equal(toPositiveInteger(-3), null);
assert.equal(toPositiveInteger(NaN), null);
});
+6
View File
@@ -34,6 +34,7 @@ import {
fmtTemperature,
fmtTx,
} from './short-info-telemetry.js';
import { renderSatsInViewBadge } from './short-info-satellites.js';
import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
@@ -2002,6 +2003,7 @@ export function initializeApp(config) {
['latitude', source.latitude],
['longitude', source.longitude],
['altitude', source.altitude],
['satsInView', source.satsInView ?? source.sats_in_view],
['positionTime', source.positionTime ?? source.position_time],
];
for (const [key, value] of numericPairs) {
@@ -2116,6 +2118,10 @@ export function initializeApp(config) {
if (nodeIdValue !== '—') {
shortParts.push(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
}
const satelliteLine = renderSatsInViewBadge(overlayInfo);
if (satelliteLine) {
shortParts.push(satelliteLine);
}
if (shortParts.length) {
lines.push(shortParts.join(' '));
}
+3
View File
@@ -181,6 +181,7 @@ function mergeNodeFields(target, record) {
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
mergeModemMetadata(target, record);
assignNumber(target, 'snr', extractNumber(record, ['snr']));
assignNumber(target, 'satsInView', extractNumber(record, ['sats_in_view', 'satsInView']));
assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel']));
assignNumber(target, 'voltage', extractNumber(record, ['voltage']));
assignNumber(target, 'uptime', extractNumber(record, ['uptime', 'uptime_seconds', 'uptimeSeconds']));
@@ -278,6 +279,8 @@ function mergePosition(target, position) {
assignString(target, 'lastSeenIso', extractString(position, ['rx_iso', 'rxIso']), { preferExisting: true });
}
}
assignNumber(target, 'satsInView', extractNumber(position, ['sats_in_view', 'satsInView']), { preferExisting: true });
}
/**
+68 -23
View File
@@ -23,6 +23,7 @@ import {
} from './chat-format.js';
import {
fmtAlt,
fmtCurrent,
fmtHumidity,
fmtPressure,
fmtTemperature,
@@ -53,7 +54,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
{
id: 'battery',
position: 'left',
label: 'Battery (0-100%)',
label: 'Battery (%)',
min: 0,
max: 100,
ticks: 4,
@@ -62,12 +63,21 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
{
id: 'voltage',
position: 'right',
label: 'Voltage (0-6V)',
label: 'Voltage (V)',
min: 0,
max: 6,
ticks: 3,
color: '#9ebcda',
},
{
id: 'current',
position: 'rightSecondary',
label: 'Current (A)',
min: 0,
max: 3,
ticks: 3,
color: '#3182bd',
},
],
series: [
{
@@ -75,7 +85,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
axis: 'battery',
color: '#8856a7',
label: 'Battery level',
legend: 'Battery (0-100%)',
legend: 'Battery (%)',
fields: ['battery', 'battery_level', 'batteryLevel'],
valueFormatter: value => `${value.toFixed(1)}%`,
},
@@ -84,10 +94,19 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
axis: 'voltage',
color: '#9ebcda',
label: 'Voltage',
legend: 'Voltage (0-6V)',
legend: 'Voltage (V)',
fields: ['voltage', 'voltageReading'],
valueFormatter: value => `${value.toFixed(2)} V`,
},
{
id: 'current',
axis: 'current',
color: '#3182bd',
label: 'Current',
legend: 'Current (A)',
fields: ['current'],
valueFormatter: value => fmtCurrent(value),
},
],
},
{
@@ -148,25 +167,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
color: '#91bfdb',
visible: false,
},
{
id: 'pressure',
position: 'right',
label: 'Pressure (hPa)',
min: 800,
max: 1_100,
ticks: 4,
color: '#c51b8a',
},
{
id: 'gas',
position: 'rightSecondary',
label: 'Gas resistance (\u03a9)',
min: 10,
max: 100_000,
ticks: 5,
color: '#fa9fb5',
scale: 'log',
},
],
series: [
{
@@ -187,6 +187,42 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
fields: ['humidity', 'relative_humidity', 'relativeHumidity'],
valueFormatter: value => `${value.toFixed(1)}%`,
},
],
},
{
id: 'airQuality',
title: 'Air quality',
axes: [
{
id: 'pressure',
position: 'left',
label: 'Pressure (hPa)',
min: 800,
max: 1_100,
ticks: 4,
color: '#c51b8a',
},
{
id: 'gas',
position: 'right',
label: 'Gas resistance (\u03a9)',
min: 10,
max: 100_000,
ticks: 5,
color: '#fa9fb5',
scale: 'log',
},
{
id: 'iaq',
position: 'rightSecondary',
label: 'IAQ index',
min: 0,
max: 500,
ticks: 5,
color: '#636363',
},
],
series: [
{
id: 'pressure',
axis: 'pressure',
@@ -205,6 +241,15 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
fields: ['gas_resistance', 'gasResistance'],
valueFormatter: value => formatGasResistance(value),
},
{
id: 'iaq',
axis: 'iaq',
color: '#636363',
label: 'IAQ',
legend: 'IAQ index',
fields: ['iaq'],
valueFormatter: value => value.toFixed(0),
},
],
},
]);
@@ -74,6 +74,7 @@ const FIELD_ALIASES = Object.freeze([
{ keys: ['relative_humidity', 'relativeHumidity', 'humidity'], normalise: normalizeNumber },
{ keys: ['barometric_pressure', 'barometricPressure', 'pressure'], normalise: normalizeNumber },
{ keys: ['gas_resistance', 'gasResistance'], normalise: normalizeNumber },
{ keys: ['sats_in_view', 'satsInView'], normalise: normalizeNumber },
{ keys: ['snr'], normalise: normalizeNumber },
{ keys: ['last_heard', 'lastHeard'], normalise: normalizeNumber },
{ keys: ['last_seen_iso', 'lastSeenIso'], normalise: normalizeString },
@@ -0,0 +1,82 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* 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.
*/
/**
* Coerce a candidate value into a positive integer satellite count.
*
* @param {*} value Raw candidate value.
* @returns {number|null} Rounded positive integer or ``null``.
*/
function toPositiveInteger(value) {
if (value == null || value === '') return null;
const num = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(num) || num <= 0) return null;
return Math.round(num);
}
/**
* Extract the satellite count from a node-like payload.
*
* @param {*} info Node payload potentially containing satellite metadata.
* @returns {number|null} Satellite count when present and positive.
*/
export function resolveSatsInView(info) {
if (!info || typeof info !== 'object') {
return toPositiveInteger(info);
}
const candidates = [
info.satsInView,
info.sats_in_view,
info.position?.satsInView,
info.position?.sats_in_view,
info.rawSources?.position?.satsInView,
info.rawSources?.position?.sats_in_view,
];
for (const candidate of candidates) {
const count = toPositiveInteger(candidate);
if (count != null) {
return count;
}
}
return null;
}
const ICON_PATH = '/assets/img/satellite-icon.svg';
/**
* Render a short-info overlay row describing visible satellites.
*
* @param {*} info Node payload providing satellite metadata.
* @returns {string} HTML snippet or an empty string when unavailable.
*/
export function renderSatsInViewBadge(info) {
const count = resolveSatsInView(info);
if (count == null) {
return '';
}
return [
'<span class="short-info-sats" aria-label="Satellites in view">',
`<span class="short-info-sats__icon" aria-hidden="true">` +
`<img src="${ICON_PATH}" alt="" width="14" height="14" loading="lazy" decoding="async" class="short-info-sats__glyph">` +
`</span>`,
`<span class="short-info-sats__count">${count}</span>`,
'</span>',
].join('');
}
export const __testUtils = {
toPositiveInteger,
};
+33
View File
@@ -783,6 +783,39 @@ body.view-map .map-panel--full #map {
background: rgba(0, 0, 0, 0.08);
}
.short-info-sats {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: inherit;
color: inherit;
padding: 0;
margin: 0;
vertical-align: middle;
}
.short-info-sats__icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: currentColor;
}
.short-info-sats__glyph {
display: block;
filter: grayscale(1);
}
body.dark .short-info-sats__glyph {
filter: invert(1) grayscale(1);
}
.short-info-sats__count {
font-family: inherit;
font-size: inherit;
letter-spacing: 0.1px;
}
.node-detail-overlay {
position: fixed;
inset: 0;