Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ee28d62
feat: Added scaffold for myspeed service
lxBlazarxl Sep 20, 2026
7abaecd
test(core_models): verify myspeed instance serialization
lxBlazarxl Sep 20, 2026
8598691
feat(service_myspeed): add 3-tab bottom navbar with status, history, …
lxBlazarxl Sep 20, 2026
89b3591
feat(app): add myspeed service icon asset
lxBlazarxl Sep 20, 2026
49a689c
feat(service_myspeed): enhance MySpeedTest model with id, jitter, and…
lxBlazarxl Sep 20, 2026
048ef04
feat(service_myspeed): add dedicated metric box and test card component
lxBlazarxl Sep 20, 2026
29eb775
feat(service_myspeed): implement in-memory history caching and diff u…
lxBlazarxl Sep 20, 2026
c06cdb6
feat(service_myspeed): scope status tab to 24-hour speedtests and add…
lxBlazarxl Sep 20, 2026
9272bb1
feat(service_myspeed): add periodic polling and real-time running sta…
lxBlazarxl Sep 20, 2026
5750b8a
feat(core_models): graduate myspeed service integration out of beta
lxBlazarxl Sep 20, 2026
d57808c
feat(service_myspeed): merge execution status and manual test into fu…
lxBlazarxl Sep 20, 2026
c0bbe28
fix(service_myspeed): resolve status card layout overflow and enforce…
lxBlazarxl Sep 20, 2026
f1a7aaf
feat(service_myspeed): add history search by id and integrate storage…
lxBlazarxl Sep 20, 2026
722ad9e
fix(service_myspeed): refine config tab layout and remove redundant s…
lxBlazarxl Sep 20, 2026
e94b571
style(service_myspeed): rename 24-hour results section header to Rece…
lxBlazarxl Sep 20, 2026
a1613d5
feat(service_myspeed): remove redundant reload action icon from appbar
lxBlazarxl Sep 20, 2026
4626b69
test(app): add unit test verifying bundled myspeed brand icon
lxBlazarxl Sep 20, 2026
0ac4f95
fix(service_myspeed): ask the server for the whole day and the whole …
retransmit Sep 20, 2026
1df545d
fix(service_myspeed): sentence-case the labels, count one test as one…
retransmit Sep 20, 2026
b55c182
docs: list MySpeed in the README and STATUS.md
retransmit Sep 20, 2026
f2412c5
Merge development into the MySpeed branch
retransmit Sep 20, 2026
d786a52
fix(app): finish the MySpeed early return the merge cut short
retransmit Sep 20, 2026
1f1f900
feat(myspeed): add authentication support in instance form and networ…
lxBlazarxl Sep 20, 2026
1b0a4e9
fix(core_networking): send MySpeed's raw password header only when Da…
retransmit Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The central courtyard for your self-hosted media stack.
One Android app that fronts Sonarr, Radarr, Lidarr, Prowlarr, Bazarr,
Seerr, Tautulli, Tracearr, Jellyfin, Emby, Plex, qBittorrent, Deluge,
Transmission, rTorrent, SABnzbd, NZBGet, Glances, Speedtest Tracker, Beszel, dashdot and Unraid - and routes every request through the right URL
Transmission, rTorrent, SABnzbd, NZBGet, Glances, Speedtest Tracker, MySpeed, Beszel, dashdot and Unraid - and routes every request through the right URL
whether you're on the home Wi-Fi or out in the world.

**[Website][site]** - screenshots and a tour, no install needed.
Expand Down Expand Up @@ -73,6 +73,7 @@ each one covers:
| NZBGet | queue with reorder/priority/category, add NZB, speed limit, history |
| Glances | CPU/memory/network/disk monitoring |
| Speedtest Tracker | latest result, history chart, dashboard, confirmed remote test runs |
| MySpeed | status and manual runs, the day's results, history with averages |
| Beszel | systems list, live metrics, per-system detail screen |
| dashdot | live CPU, memory, disk, and GPU usage with a system-info tab |
| Unraid | array and disk health, system with per-core load, Docker, VM control |
Expand Down
5 changes: 5 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ Atrium is a **controller** app. Video playback was removed by design
and health, system with per-core CPU load and an About card, Docker
containers with a detail sheet and start/stop/pause/resume, and virtual
machines with start, shut down, pause, resume, reboot, force stop and reset
- **MySpeed** (added 2026-09-20 by lxBlazarxl, live-verified against 1.0.9):
execution status with a manual run, the last 24 hours of results, a
history with averages and a search by test id, the server's config and
storage figures. No password support yet, so a MySpeed with a password
set cannot be added
- **Speedtest Tracker** (live-verified): authenticated 1.1+ result history,
latest metrics, combined download / upload chart, multi-instance dashboard
widget, and confirmed 1.6+ remote runs with queued/running/terminal-state
Expand Down
Binary file added app/assets/service_icons/myspeed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions app/lib/src/screens/instance_form_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,24 @@ class _InstanceFormScreenState extends ConsumerState<InstanceFormScreen> {
List<Widget> _authFields() {
switch (_kind.authStyle) {
case AuthStyle.apiKey:
if (_kind == ServiceKind.myspeed) {
return <Widget>[
TextFormField(
controller: _apiKey,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Password (optional)',
helperText:
'Leave empty if password protection is disabled on your MySpeed instance.',
),
obscureText: true,
autocorrect: false,
enableSuggestions: false,
onChanged: _clearConnectionTest,
validator: (String? v) => null,
),
];
}
// Gluetun's control server can run without auth, through a role with
// auth = "none", and then there is no key to give.
final bool keyOptional = _kind == ServiceKind.gluetun;
Expand All @@ -574,6 +592,7 @@ class _InstanceFormScreenState extends ConsumerState<InstanceFormScreen> {
: null,
),
autocorrect: false,
onChanged: _clearConnectionTest,
validator: (String? v) =>
!keyOptional && (v == null || v.trim().isEmpty)
? 'Required'
Expand Down
11 changes: 11 additions & 0 deletions app/lib/src/screens/service_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import 'package:service_unraid/service_unraid.dart';
import 'package:service_gluetun/service_gluetun.dart';
import 'package:service_navidrome/service_navidrome.dart';
import 'package:service_ombi/service_ombi.dart';
import 'package:service_myspeed/service_myspeed.dart';

import 'dashboard_screen.dart';

Expand Down Expand Up @@ -123,6 +124,15 @@ class _ServiceDetailScreenState extends ConsumerState<ServiceDetailScreen> {
),
);
}
if (instance.kind == ServiceKind.myspeed) {
return MySpeedHome(
instance: instance,
drawer: ServicesDrawer(
instances: ref.watch(activeInstancesProvider),
profile: ref.watch(activeProfileProvider),
),
);
}
if (instance.kind == ServiceKind.transmission) {
return TransmissionHome(
instance: instance,
Expand Down Expand Up @@ -346,6 +356,7 @@ class _ServiceDetailScreenState extends ConsumerState<ServiceDetailScreen> {
ServiceKind.gluetun => GluetunHome(instance: instance),
ServiceKind.navidrome => const SizedBox.shrink(),
ServiceKind.ombi => OmbiHome(instance: instance),
ServiceKind.myspeed => const SizedBox.shrink(),
};
}
}
Expand Down
1 change: 1 addition & 0 deletions app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ dependencies:
service_gluetun:
service_navidrome:
service_ombi:
service_myspeed:
url_launcher: ^6.3.2
dev_dependencies:
flutter_launcher_icons: ^0.14.3
Expand Down
18 changes: 18 additions & 0 deletions app/test/myspeed_brand_icon_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:core_models/core_models.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

/// MySpeed has its own icon in the service picker.
///
/// The picker loads `assets/service_icons/<kind>.png` and falls back to a
/// generic server icon when there is none.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();

test('the icon the picker asks for is bundled', () async {
final ByteData icon = await rootBundle
.load('assets/service_icons/${ServiceKind.myspeed.name}.png');

expect(icon.lengthInBytes, greaterThan(0));
});
}
103 changes: 103 additions & 0 deletions app/test/myspeed_form_auth_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import 'package:atrium/src/screens/instance_form_screen.dart';
import 'package:core_models/core_models.dart';
import 'package:core_profile/core_profile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

/// Verifies MySpeed password authentication in InstanceFormScreen.
void main() {
FormField<String> passwordField(WidgetTester tester) =>
tester.widget<TextFormField>(
find.ancestor(
of: find.text('Password (optional)'),
matching: find.byType(TextFormField),
),
);

testWidgets('MySpeed form shows obscured optional password field',
(WidgetTester tester) async {
tester.view.physicalSize = const Size(900, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);

await tester.pumpWidget(
const ProviderScope(child: MaterialApp(home: InstanceFormScreen())),
);

await tester.tap(find.byType(DropdownMenu<ServiceKind>));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('MySpeed - Internet speed'),
200,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
await tester.tap(find.text('MySpeed - Internet speed'));
await tester.pumpAndSettle();

final FormField<String> field = passwordField(tester);
expect(field.validator!(''), isNull);

final TextField textField = tester.widget<TextField>(
find.descendant(
of: find.ancestor(
of: find.text('Password (optional)'),
matching: find.byType(TextFormField),
),
matching: find.byType(TextField),
),
);
expect(textField.obscureText, isTrue);
expect(
find.textContaining('Leave empty if password protection is disabled'),
findsOneWidget,
);
});

testWidgets('MySpeed edit form hydrates existing password',
(WidgetTester tester) async {
tester.view.physicalSize = const Size(900, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);

const Instance existing = Instance(
id: 'myspeed-instance-1',
name: 'Home MySpeed',
kind: ServiceKind.myspeed,
localUrl: 'http://192.168.1.50:5216',
externalUrl: '',
urlMode: UrlMode.auto,
auth: InstanceAuth.apiKey(apiKey: 'super-secret-password'),
);

await tester.pumpWidget(
ProviderScope(
overrides: [
instanceByIdProvider(existing.id).overrideWithValue(existing),
],
child: const MaterialApp(
home: InstanceFormScreen(instanceId: 'myspeed-instance-1'),
),
),
);
await tester.pumpAndSettle();

expect(find.text('Edit service'), findsOneWidget);
expect(find.text('Password (optional)'), findsOneWidget);

final TextField textField = tester.widget<TextField>(
find.descendant(
of: find.ancestor(
of: find.text('Password (optional)'),
matching: find.byType(TextFormField),
),
matching: find.byType(TextField),
),
);
expect(textField.controller?.text, 'super-secret-password');
expect(textField.obscureText, isTrue);
});
}
19 changes: 19 additions & 0 deletions app/test/new_services_render_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:service_plex/service_plex.dart';
import 'package:service_radarr/service_radarr.dart';
import 'package:service_sabnzbd/service_sabnzbd.dart';
import 'package:service_tautulli/service_tautulli.dart';
import 'package:service_myspeed/service_myspeed.dart';
import 'package:atrium/src/preferences.dart';
import 'package:atrium/src/screens/calendar_screen.dart';

Expand Down Expand Up @@ -399,4 +400,22 @@ void main() {
expect(find.text('Radiohead - A Moon Shaped Pool'), findsOneWidget);
expect(find.text('Downloaded'), findsOneWidget);
});

testWidgets('MySpeedHome renders', (WidgetTester tester) async {
final Instance myspeed = _instance(ServiceKind.myspeed);
await _pump(
tester,
<Override>[
myspeedStatusProvider(myspeed).overrideWith(
(ref) async => const MySpeedStatus(isRunning: false),
),
],
MySpeedHome(instance: myspeed),
pumps: 2,
);

expect(find.text('Execution status'), findsOneWidget);
expect(find.text('Idle'), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
});
}
6 changes: 6 additions & 0 deletions packages/core_models/lib/src/service_kind.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ enum ServiceKind {
navidrome,
gluetun,
ombi,
myspeed,
}

/// Static metadata about a [ServiceKind] - display name, default port, the
Expand Down Expand Up @@ -63,6 +64,7 @@ extension ServiceKindX on ServiceKind {
ServiceKind.gluetun => 'Gluetun',
ServiceKind.navidrome => 'Navidrome',
ServiceKind.ombi => 'Ombi',
ServiceKind.myspeed => 'MySpeed',
};

/// One-line role description.
Expand Down Expand Up @@ -92,6 +94,7 @@ extension ServiceKindX on ServiceKind {
ServiceKind.gluetun => 'VPN client',
ServiceKind.navidrome => 'Music server',
ServiceKind.ombi => 'Requests',
ServiceKind.myspeed => 'Internet speed',
};

/// Whether this service's integration is still in beta. Surfaced as a
Expand Down Expand Up @@ -143,6 +146,7 @@ extension ServiceKindX on ServiceKind {
// The linuxserver image, which most people run. Ombi's own default
// is 5000.
ServiceKind.ombi => 3579,
ServiceKind.myspeed => 5216,
};

/// What auth flow the service uses by default. Some services (Jellyfin) can
Expand All @@ -159,6 +163,7 @@ extension ServiceKindX on ServiceKind {
ServiceKind.lidarr ||
ServiceKind.unraid ||
ServiceKind.gluetun ||
ServiceKind.myspeed ||
ServiceKind.ombi =>
AuthStyle.apiKey,
// Transmission and rTorrent both use HTTP Basic, and for both it is
Expand Down Expand Up @@ -211,6 +216,7 @@ extension ServiceKindX on ServiceKind {
ServiceKind.dashdot => ServiceRole.analytics,
ServiceKind.unraid => ServiceRole.analytics,
ServiceKind.gluetun => ServiceRole.analytics,
ServiceKind.myspeed => ServiceRole.analytics,
};

/// Whether this service can be handed a torrent - a magnet URI, a link to a
Expand Down
20 changes: 20 additions & 0 deletions packages/core_models/test/profile_compat_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ void main() {
expect(decoded.kind, ServiceKind.speedtestTracker);
});

test('MySpeed instance round-trips by stable enum name', () {
const Instance instance = Instance(
id: 'myspeed-1',
name: 'Home MySpeed',
kind: ServiceKind.myspeed,
localUrl: 'http://192.168.1.10:5216',
externalUrl: '',
urlMode: UrlMode.auto,
auth: InstanceAuth.apiKey(apiKey: ''),
);

final Map<String, dynamic> json = jsonDecode(
jsonEncode(instance.toJson()),
) as Map<String, dynamic>;
final Instance decoded = Instance.fromJson(json);

expect(json['kind'], 'myspeed');
expect(decoded.kind, ServiceKind.myspeed);
});

test('WolDevice.fromJson fills broadcast and port defaults', () {
final WolDevice device = WolDevice.fromJson(<String, dynamic>{
'id': 'w1',
Expand Down
12 changes: 12 additions & 0 deletions packages/core_models/test/service_kind_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ void main() {
'navidrome',
'gluetun',
'ombi',
'myspeed',
],
);
});
Expand All @@ -48,6 +49,16 @@ void main() {
expect(ServiceKind.ombi.acceptsTorrents, isFalse);
});

test('MySpeed is registered as an apiKey analytics service', () {
expect(ServiceKind.myspeed.displayName, 'MySpeed');
expect(ServiceKind.myspeed.tagline, 'Internet speed');
expect(ServiceKind.myspeed.isBeta, isFalse);
expect(ServiceKind.myspeed.defaultPort, 5216);
expect(ServiceKind.myspeed.authStyle, AuthStyle.apiKey);
expect(ServiceKind.myspeed.role, ServiceRole.analytics);
expect(ServiceKind.myspeed.acceptsTorrents, isFalse);
});

test('Lidarr is registered as apiKey automation service', () {
expect(ServiceKind.lidarr.displayName, 'Lidarr');
expect(ServiceKind.lidarr.role, ServiceRole.automation);
Expand Down Expand Up @@ -92,6 +103,7 @@ void main() {
expect(ServiceKind.tracearr.isBeta, isFalse);
expect(ServiceKind.unraid.isBeta, isTrue);
expect(ServiceKind.navidrome.isBeta, isTrue);
expect(ServiceKind.myspeed.isBeta, isFalse);
});

test('Navidrome is registered as userPass mediaServer service', () {
Expand Down
12 changes: 12 additions & 0 deletions packages/core_networking/lib/src/auth_interceptor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import 'package:core_models/core_models.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';

import 'service_auth_headers.dart';

/// Adds the auth header(s) appropriate for the [Instance]'s service kind.
///
/// Decoder for the various conventions across the stack:
Expand Down Expand Up @@ -55,6 +57,16 @@ class AuthInterceptor extends Interceptor {
case ServiceKind.ombi:
// Ombi reads only its own header; X-Api-Key gets a 401.
options.headers['ApiKey'] = apiKey;
case ServiceKind.myspeed:
// MySpeed 1.0.9 reads a raw 'password' header; newer builds
// prefer a URL-encoded 'x-password' and fall back to the raw
// one. The raw header only goes when Dart will let it through:
// a password outside printable ASCII would otherwise throw
// inside every request.
if (apiKey.isNotEmpty) {
options.headers['x-password'] = Uri.encodeComponent(apiKey);
if (fitsHeaderValue(apiKey)) options.headers['password'] = apiKey;
}
case _:
options.headers['X-Api-Key'] = apiKey;
}
Expand Down
Loading
Loading