From cc21860809d185c36afe36fd3a4d80a347599b80 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Fri, 3 Oct 2025 17:11:24 +0200 Subject: [PATCH 01/47] feat(error_handling): add ConnectionErrorDialog and ConnectionErrorWatcher for Backend Error Handling --- lib/main.dart | 59 ++- .../connection_error_dialog.dart | 401 ++++++++++++++++++ .../connection_error_watcher.dart | 81 ++++ pubspec.lock | 80 ++++ pubspec.yaml | 1 + 5 files changed, 606 insertions(+), 16 deletions(-) create mode 100644 lib/util/error_handling/connection_error_dialog.dart create mode 100644 lib/util/error_handling/connection_error_watcher.dart diff --git a/lib/main.dart b/lib/main.dart index 537e5f1..4950f84 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,6 +47,7 @@ import 'package:orion/tools/tools_screen.dart'; import 'package:orion/util/error_handling/error_handler.dart'; import 'package:orion/util/providers/locale_provider.dart'; import 'package:orion/util/providers/theme_provider.dart'; +import 'package:orion/util/error_handling/connection_error_watcher.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -174,6 +175,9 @@ class OrionMainApp extends StatefulWidget { class OrionMainAppState extends State { late final GoRouter _router; + ConnectionErrorWatcher? _connWatcher; + final GlobalKey _navKey = GlobalKey(); + // navigatorKey removed; using MaterialApp.router builder context instead @override void initState() { @@ -181,8 +185,15 @@ class OrionMainAppState extends State { _initRouter(); } + @override + void dispose() { + _connWatcher?.dispose(); + super.dispose(); + } + void _initRouter() { _router = GoRouter( + navigatorKey: _navKey, routes: [ GoRoute( path: '/', @@ -251,22 +262,38 @@ class OrionMainAppState extends State { value: themeProvider.setThemeMode, // Use ThemeProvider's method directly child: GlassApp( - child: MaterialApp.router( - title: 'Orion', - debugShowCheckedModeBanner: false, - routerConfig: _router, - theme: themeProvider.lightTheme, - darkTheme: themeProvider.darkTheme, - themeMode: themeProvider.themeMode, - locale: localeProvider.locale, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - ), + child: Builder(builder: (innerCtx) { + // Use MaterialApp.router's builder to get a context that has + // MaterialLocalizations and a Navigator. Install the watcher + // after the first frame using that context. + return MaterialApp.router( + title: 'Orion', + debugShowCheckedModeBanner: false, + routerConfig: _router, + theme: themeProvider.lightTheme, + builder: (ctx, child) { + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + final navCtx = _navKey.currentContext; + if (_connWatcher == null && navCtx != null) { + _connWatcher = ConnectionErrorWatcher.install(navCtx); + } + } catch (_) {} + }); + return child ?? const SizedBox.shrink(); + }, + darkTheme: themeProvider.darkTheme, + themeMode: themeProvider.themeMode, + locale: localeProvider.locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + ); + }), ), ); }, diff --git a/lib/util/error_handling/connection_error_dialog.dart b/lib/util/error_handling/connection_error_dialog.dart new file mode 100644 index 0000000..e136e55 --- /dev/null +++ b/lib/util/error_handling/connection_error_dialog.dart @@ -0,0 +1,401 @@ +/* + * Orion - Connection Error Dialog + * Shows live reconnection attempt counts and countdown to next attempt. + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:logging/logging.dart'; +import 'package:orion/glasser/glasser.dart'; +import 'package:orion/backend_service/providers/status_provider.dart'; +import 'package:orion/util/orion_config.dart'; + +/// Show a connection dialog that displays current attempt counts and a +/// countdown to the next scheduled retry. The dialog listens to the +/// [StatusProvider] for values and updates every second while visible. +Future showConnectionErrorDialog(BuildContext context) async { + final completer = Completer(); + final log = Logger('ConnectionErrorDialog'); + + Future attemptShow(int triesLeft) async { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Check for Navigator and MaterialLocalizations availability before + // attempting to show the dialog. If either is missing, delay and + // retry to avoid synchronous exceptions. + final hasNavigator = Navigator.maybeOf(context) != null; + final hasMaterialLocs = Localizations.of( + context, MaterialLocalizations) != + null; + if (!hasNavigator || !hasMaterialLocs) { + log.info( + 'showConnectionErrorDialog: context not ready (navigator=$hasNavigator, materialLocs=$hasMaterialLocs), triesLeft=$triesLeft'); + if (triesLeft > 0) { + Future.delayed(const Duration(milliseconds: 300), + () => attemptShow(triesLeft - 1)); + } else { + if (!completer.isCompleted) completer.complete(); + } + return; + } + + try { + log.info( + 'showConnectionErrorDialog: attempting to show dialog (triesLeft=$triesLeft)'); + showDialog( + context: context, + barrierDismissible: true, + useRootNavigator: true, + builder: (BuildContext ctx) { + return _ConnectionErrorDialogContent(); + }, + ).then((_) { + if (!completer.isCompleted) completer.complete(); + }).catchError((err, st) { + log.warning('showConnectionErrorDialog: showDialog failed', err, st); + if (!completer.isCompleted) completer.complete(); + }); + } catch (e, st) { + log.warning( + 'showConnectionErrorDialog: synchronous showDialog threw', e, st); + if (triesLeft > 0) { + Future.delayed(const Duration(milliseconds: 300), + () => attemptShow(triesLeft - 1)); + } else { + if (!completer.isCompleted) completer.complete(); + } + } + }); + } + + // Try a few times to avoid calling showDialog before Navigator/Localizations + attemptShow(5); + return completer.future; +} + +class _ConnectionErrorDialogContent extends StatefulWidget { + @override + State<_ConnectionErrorDialogContent> createState() => + _ConnectionErrorDialogContentState(); +} + +class _ConnectionErrorDialogContentState + extends State<_ConnectionErrorDialogContent> { + Timer? _tick; + + @override + void initState() { + super.initState(); + // Update UI each second while dialog is visible so countdown updates. + _tick = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _tick?.cancel(); + super.dispose(); + } + + String _formatCountdown(DateTime? at) { + if (at == null) return '—'; + final now = DateTime.now(); + if (at.isBefore(now)) return '0 sec'; + final diff = at.difference(now); + final s = diff.inSeconds; + if (s < 60) return '$s sec'; + final m = diff.inMinutes; + final sec = s % 60; + return '$m min $sec secs'; + } + + @override + Widget build(BuildContext context) { + final statusProv = Provider.of(context); + final pollAttempts = statusProv.pollAttemptCount; + final sseAttempts = statusProv.sseAttemptCount; + final next = statusProv.nextRetryAt; + final sseSupported = statusProv.sseSupported; + final devMode = + OrionConfig().getFlag('developerMode', category: 'advanced'); + + void retryNow() { + // Close dialog and trigger an immediate refresh + try { + Navigator.of(context, rootNavigator: true).pop(); + } catch (_) {} + try { + statusProv.refresh(); + } catch (_) {} + } + + if (devMode) { + // Compute backend and target URI for developer display + String backendName = ''; + String targetUri = ''; + try { + final cfg = OrionConfig(); + backendName = cfg.getString('backend', category: 'advanced'); + final devNano = cfg.getFlag('nanoDLPmode', category: 'developer'); + final isNano = backendName == 'nanodlp' || devNano; + if (isNano) { + final base = cfg.getString('nanodlp.base_url', category: 'advanced'); + final useCustom = cfg.getFlag('useCustomUrl', category: 'advanced'); + final custom = cfg.getString('customUrl', category: 'advanced'); + if (base.isNotEmpty) { + targetUri = base; + } else if (useCustom && custom.isNotEmpty) { + targetUri = custom; + } else { + targetUri = 'http://localhost'; + } + if (backendName.isEmpty) backendName = 'NanoDLP'; + } else { + final custom = cfg.getString('customUrl', category: 'advanced'); + final useCustom = cfg.getFlag('useCustomUrl', category: 'advanced'); + targetUri = (useCustom && custom.isNotEmpty) + ? custom + : 'http://localhost:12357'; + if (backendName.isEmpty) backendName = 'Odyssey'; + } + } catch (_) { + // best-effort: leave strings empty if config read fails + } + + return GlassAlertDialog( + title: Row( + children: [ + Icon( + Icons.wifi_off, + color: Colors.orange.shade600, + size: 26, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Connection Lost', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 2), + Text( + sseSupported == false + ? 'Using polling only (SSE unsupported)' + : 'Attempting to reconnect...', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade400, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Developer info: backend and target URI in a compact two-column row + if (backendName.isNotEmpty || targetUri.isNotEmpty) ...[ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Backend', + style: + TextStyle(fontSize: 14, color: Colors.white70)), + const SizedBox(height: 4), + Text( + backendName.isNotEmpty ? backendName : 'unknown', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w600), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Target', + style: + TextStyle(fontSize: 14, color: Colors.white70)), + const SizedBox(height: 4), + Text( + targetUri.isNotEmpty ? targetUri : 'unknown', + style: const TextStyle( + fontSize: 18, + fontFamily: 'monospace', + fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ], + const SizedBox(height: 12), + const Text('Attempting to reconnect in', + style: TextStyle(fontSize: 14, color: Colors.white70)), + const SizedBox(height: 6), + Text( + _formatCountdown(next), + style: const TextStyle( + fontSize: 22, height: 1.1, fontWeight: FontWeight.w600), + textAlign: TextAlign.left, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Poll attempts', + style: TextStyle(fontSize: 14)), + const SizedBox(height: 4), + Text('$pollAttempts', + style: const TextStyle( + fontSize: 22, fontWeight: FontWeight.bold)), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('SSE attempts', + style: TextStyle(fontSize: 14)), + const SizedBox(height: 4), + Text('$sseAttempts', + style: const TextStyle( + fontSize: 26, fontWeight: FontWeight.bold)), + ], + ), + ), + ], + ), + ], + ), + actions: [ + Row( + children: [ + Flexible( + child: GlassButton( + onPressed: retryNow, + style: + ElevatedButton.styleFrom(minimumSize: const Size(0, 60)), + child: + const Text('Retry now', style: TextStyle(fontSize: 20)), + ), + ), + const SizedBox(width: 12), + Flexible( + child: GlassButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 60), + backgroundColor: Colors.transparent), + child: const Text('Close', style: TextStyle(fontSize: 20)), + ), + ), + ], + ), + ], + ); + } + + // Production (non-developer) compact dialog + return GlassAlertDialog( + title: Row( + children: [ + Icon( + Icons.wifi_off, + color: Colors.orange.shade600, + size: 26, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Connection Lost', + style: TextStyle( + fontSize: 22, fontWeight: FontWeight.bold), + ), + ), + // Show attempt counter next to title in production mode + Builder(builder: (ctx) { + final attempts = statusProv.pollAttemptCount; + final maxAttempts = statusProv.maxReconnectAttempts; + if (attempts > 0) { + return Text('($attempts/$maxAttempts)', + style: const TextStyle( + fontSize: 16, color: Colors.white70)); + } + return const SizedBox.shrink(); + }), + ], + ), + ], + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 20), + const Text('Attempting to reconnect in', + style: TextStyle(fontSize: 18, color: Colors.white70)), + const SizedBox(height: 6), + Text( + _formatCountdown(next), + style: const TextStyle( + fontSize: 32, height: 1.1, fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + ], + ), + actions: [ + Row( + children: [ + Flexible( + child: GlassButton( + onPressed: retryNow, + style: ElevatedButton.styleFrom(minimumSize: const Size(0, 60)), + child: const Text('Retry now', style: TextStyle(fontSize: 20)), + ), + ), + const SizedBox(width: 12), + Flexible( + child: GlassButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 60), + backgroundColor: Colors.transparent), + child: const Text('Close', style: TextStyle(fontSize: 20)), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/util/error_handling/connection_error_watcher.dart b/lib/util/error_handling/connection_error_watcher.dart new file mode 100644 index 0000000..b826139 --- /dev/null +++ b/lib/util/error_handling/connection_error_watcher.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; + +import 'package:orion/backend_service/providers/status_provider.dart'; +import 'package:orion/util/error_handling/connection_error_dialog.dart'; + +/// Installs a watcher that will show the ConnectionErrorDialog when +/// [StatusProvider.error] becomes non-null and will dismiss it when +/// [StatusProvider.error] returns to null. The provided [context] must be +/// attached to a Navigator (e.g., top-level app context). Multiple calls will +/// replace the previous watcher for the same context. +class ConnectionErrorWatcher { + final BuildContext _context; + late final StatusProvider _provider; + bool _dialogVisible = false; + bool _listening = false; + Object? _lastProviderError; + bool? _lastDialogVisible; + + ConnectionErrorWatcher._(this._context) { + _provider = Provider.of(_context, listen: false); + } + + /// Install the watcher and begin listening immediately. + static ConnectionErrorWatcher install(BuildContext context) { + final watcher = ConnectionErrorWatcher._(context); + watcher._start(); + return watcher; + } + + void _start() { + if (_listening) return; + _listening = true; + _provider.addListener(_onProviderChange); + // Immediately evaluate initial state + _onProviderChange(); + } + + void _onProviderChange() async { + final _log = Logger('ConnErrorWatcher'); + try { + final hasError = _provider.error != null; + // Only log transitions or when there's something noteworthy to report + final providerError = _provider.error; + final shouldLog = (providerError != _lastProviderError) || + (_dialogVisible != _lastDialogVisible) || + (providerError != null) || + _dialogVisible; + if (shouldLog) { + _log.info( + 'provider error=${providerError != null} dialogVisible=$_dialogVisible'); + _lastProviderError = providerError; + _lastDialogVisible = _dialogVisible; + } + if (hasError && !_dialogVisible) { + _dialogVisible = true; + // Show the dialog; this Future completes when the dialog is dismissed + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + await showConnectionErrorDialog(_context); + } catch (_) {} + // Dialog was dismissed by user or programmatically + _dialogVisible = false; + }); + } else if (!hasError && _dialogVisible) { + // Dismiss the dialog if it's visible and the error cleared. + try { + Navigator.of(_context, rootNavigator: true).maybePop(); + } catch (_) {} + } + } catch (_) {} + } + + void dispose() { + if (_listening) { + _provider.removeListener(_onProviderChange); + _listening = false; + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 245b1d1..ebbea41 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -193,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: "direct main" description: @@ -557,6 +573,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -789,6 +813,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -826,6 +866,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.7" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -882,6 +938,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" test_api: dependency: transitive description: @@ -890,6 +954,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" timing: dependency: transitive description: @@ -1058,6 +1130,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" window_size: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9e33914..568cd53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + test: ^1.21.0 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is From 2c782dfd483e7a57f478066703db97c09732afdc Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Fri, 3 Oct 2025 17:20:12 +0200 Subject: [PATCH 02/47] feat(backend_service): ManualProvider: add moveDelta and moveToTop methods with error handling --- .../providers/manual_provider.dart | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/lib/backend_service/providers/manual_provider.dart b/lib/backend_service/providers/manual_provider.dart index b453d3c..a23c626 100644 --- a/lib/backend_service/providers/manual_provider.dart +++ b/lib/backend_service/providers/manual_provider.dart @@ -40,6 +40,26 @@ class ManualProvider extends ChangeNotifier { } } + Future moveDelta(double deltaMm) async { + _log.info('moveDelta: $deltaMm'); + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await _client.moveDelta(deltaMm); + _busy = false; + notifyListeners(); + return true; + } catch (e, st) { + _log.severe('moveDelta failed', e, st); + _error = e; + _busy = false; + notifyListeners(); + return false; + } + } + Future manualHome() async { _log.info('manualHome'); if (_busy) return false; @@ -100,6 +120,35 @@ class ManualProvider extends ChangeNotifier { } } + /// Whether the backend supports a direct move-to-top operation. + Future canMoveToTop() async { + try { + return await _client.canMoveToTop(); + } catch (_) { + return false; + } + } + + Future moveToTop() async { + _log.info('moveToTop'); + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await _client.moveToTop(); + _busy = false; + notifyListeners(); + return true; + } catch (e, st) { + _log.severe('moveToTop failed', e, st); + _error = e; + _busy = false; + notifyListeners(); + return false; + } + } + Future displayTest(String test) async { _log.info('displayTest: $test'); if (_busy) return false; From a3701e76167ea18893432f31432e1f41b126cf79 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Fri, 3 Oct 2025 17:20:43 +0200 Subject: [PATCH 03/47] fix(backend_service): ConfigProvider: ensure refresh is called after widget build to avoid setState issues --- lib/backend_service/providers/config_provider.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/backend_service/providers/config_provider.dart b/lib/backend_service/providers/config_provider.dart index e9da71a..a61c744 100644 --- a/lib/backend_service/providers/config_provider.dart +++ b/lib/backend_service/providers/config_provider.dart @@ -20,7 +20,13 @@ class ConfigProvider extends ChangeNotifier { ConfigProvider({OdysseyClient? client}) : _client = client ?? BackendService() { - refresh(); + // Don't call refresh synchronously during construction — when the + // provider is created inside widget build (e.g. `create: (_) => + // ConfigProvider()`), calling `notifyListeners()` can trigger the + // 'setState() or markNeedsBuild() called during build' assertion. Use + // a post-frame callback so the initial fetch runs after the first frame + // has been rendered and the framework is no longer building widgets. + WidgetsBinding.instance.addPostFrameCallback((_) => refresh()); } Future refresh() async { @@ -38,6 +44,9 @@ class ConfigProvider extends ChangeNotifier { _log.severe('Failed to fetch config', e, st); _error = e; _loading = false; + // Rethrow so callers can decide how to surface the error. Avoids + // coupling the provider to UI dialog presentation during build. + rethrow; } finally { notifyListeners(); } From a875fa1a61e3651541f3bd8e6beea3f82ee2f545 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 00:07:24 +0200 Subject: [PATCH 04/47] feat(backend_service): StatusProvider: enhance with device status messaging --- .../providers/status_provider.dart | 381 ++++++++++++++++-- 1 file changed, 347 insertions(+), 34 deletions(-) diff --git a/lib/backend_service/providers/status_provider.dart b/lib/backend_service/providers/status_provider.dart index 9b06224..dff9472 100644 --- a/lib/backend_service/providers/status_provider.dart +++ b/lib/backend_service/providers/status_provider.dart @@ -6,6 +6,7 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -29,6 +30,13 @@ class StatusProvider extends ChangeNotifier { final _log = Logger('StatusProvider'); StatusModel? _status; + String? _deviceStatusMessage; + + /// Optional raw device-provided status message (e.g. NanoDLP "Status" + /// field). When present this may be used to override the app bar title + /// on the `StatusScreen` so device-provided messages like + /// "Peel Detection Started" are surfaced directly. + String? get deviceStatusMessage => _deviceStatusMessage; StatusModel? get status => _status; bool _loading = true; @@ -78,10 +86,29 @@ class StatusProvider extends ChangeNotifier { bool _polling = false; int _pollIntervalSeconds = 1; static const int _minPollIntervalSeconds = 1; - static const int _maxPollIntervalSeconds = 15; + static const int _maxPollIntervalSeconds = 60; // SSE reconnect tuning: aggressive base retry for local devices + small jitter. static const int _sseReconnectBaseSeconds = 3; - static const int _sseReconnectJitterSeconds = 2; // +/- jitter window + // Don't attempt SSE if polling is repeatedly failing. If polling shows + // consecutive errors above this threshold, skip SSE reconnect attempts + // until polls recover. + static const int _ssePollErrorThreshold = 3; + int _sseConsecutiveErrors = 0; + + /// SSE support tri-state: null = unknown, true = supported, false = unsupported + bool? _sseSupported; + + /// True once we've successfully established an SSE subscription at least once + bool _sseEverConnected = false; + + /// True once we've attempted an SSE subscription (even if it failed) + // bool _sseAttempted = false; // reserved for future introspection + // Exponential backoff counters + int _consecutiveErrors = 0; + static const int _maxReconnectAttempts = 20; + DateTime? _nextPollRetryAt; + DateTime? _nextSseRetryAt; + // int _sseConsecutiveErrors = 0; // reserved for future use bool _fetchInFlight = false; bool _disposed = false; @@ -90,10 +117,28 @@ class StatusProvider extends ChangeNotifier { bool _sseConnected = false; StreamSubscription>? _sseStreamSub; + // Exposed getters for UI/dialogs to present attempt counters and next retry + // timestamps. + int get pollAttemptCount => _consecutiveErrors; + int get sseAttemptCount => _sseConsecutiveErrors; + int get maxReconnectAttempts => _maxReconnectAttempts; + DateTime? get nextPollRetryAt => _nextPollRetryAt; + DateTime? get nextSseRetryAt => _nextSseRetryAt; + DateTime? get nextRetryAt { + if (_nextPollRetryAt == null) return _nextSseRetryAt; + if (_nextSseRetryAt == null) return _nextPollRetryAt; + return _nextPollRetryAt!.isBefore(_nextSseRetryAt!) + ? _nextPollRetryAt + : _nextSseRetryAt; + } + + bool? get sseSupported => _sseSupported; + StatusProvider({OdysseyClient? client}) : _client = client ?? BackendService() { // Prefer an SSE subscription when the backend supports it. If the - // connection fails we fall back to the existing polling loop. + // connection fails we fall back to the existing polling loop. See + // comments in _tryStartSse for detailed reconnect / fallback behavior. _tryStartSse(); } @@ -128,6 +173,12 @@ class StatusProvider extends ChangeNotifier { /// reconnection attempt after [_maxPollIntervalSeconds]. Future _tryStartSse() async { if (_sseConnected || _disposed) return; + // If we've determined SSE is unsupported, don't retry it. + if (_sseSupported == false) { + _log.info('SSE previously determined unsupported; skipping SSE attempts'); + _startPolling(); + return; + } // If configured for NanoDLP (developer override or backend config) the // NanoDLP adapter is polling-only and does not support SSE. Avoid // attempting to establish an SSE subscription in that case. @@ -141,6 +192,15 @@ class StatusProvider extends ChangeNotifier { _startPolling(); return; } + // If polling has been failing repeatedly, avoid attempting SSE until + // polls recover. This prevents wasting SSE attempts while API is + // clearly unreachable. + if (_consecutiveErrors >= _ssePollErrorThreshold) { + _log.info( + 'Skipping SSE attempt because polling has $_consecutiveErrors consecutive errors'); + _startPolling(); + return; + } } catch (_) { // If config read fails, proceed to attempt SSE as a best-effort. } @@ -151,11 +211,25 @@ class StatusProvider extends ChangeNotifier { // When SSE becomes active, cancel any existing polling loop so we rely // on the event stream instead of periodic polling. _sseConnected = true; + _sseEverConnected = true; + _sseSupported = true; _polling = false; + // Reset SSE error counter on successful subscription + _sseConsecutiveErrors = 0; _sseStreamSub = stream.listen((raw) async { try { + // Capture any raw device status text (mapper sets 'device_status_message') + try { + _deviceStatusMessage = + (raw['device_status_message'] ?? raw['Status'] ?? raw['status']) + ?.toString(); + } catch (_) { + _deviceStatusMessage = null; + } final parsed = StatusModel.fromJson(raw); + // (previous snapshot removed) transitional clears now rely on the + // parsed payload directly. // Lazy thumbnail acquisition (same rules as refresh) if (parsed.isPrinting && _thumbnailPath == null && !_thumbnailReady) { @@ -187,7 +261,31 @@ class StatusProvider extends ChangeNotifier { _status = parsed; _error = null; _loading = false; + _consecutiveErrors = 0; _pollIntervalSeconds = _minPollIntervalSeconds; + // If we were previously awaiting a new print, consider the print + // started as soon as the backend reports an active job (printing + // or paused). Waiting for file metadata or thumbnails can cause + // long spinners on some backends that populate those fields + // asynchronously; prefer showing the status immediately and + // update metadata when it becomes available. + if (_awaitingNewPrintData) { + _awaitingNewPrintData = false; + _awaitingSince = null; + } + + // Clear transitional flags when backend reflects the requested + // change. We clear regardless of previous snapshot so cases where + // the pre-action status was null/stale still resolve the UI. + if (_isPausing && parsed.isPaused) { + _isPausing = false; + } + if (_isCanceling && + (parsed.isCanceled || + !parsed.isPrinting || + (parsed.isIdle && parsed.layer != null))) { + _isCanceling = false; + } } catch (e, st) { _log.warning('Failed to handle SSE payload', e, st); } @@ -196,39 +294,89 @@ class StatusProvider extends ChangeNotifier { _log.warning('SSE stream error, falling back to polling', err, st); _closeSse(); if (!_disposed) _startPolling(); - // Schedule aggressive reconnection attempts with jitter while we - // continue polling. - final rand = Random(); - final jitter = rand.nextInt(_sseReconnectJitterSeconds + 1); - final delaySec = _sseReconnectBaseSeconds + jitter; - Future.delayed(Duration(seconds: delaySec), () { - if (!_sseConnected && !_disposed) _tryStartSse(); - }); + // If we previously had a working SSE subscription, retry with + // exponential backoff because we know the backend supported SSE. + if (_sseEverConnected) { + _sseConsecutiveErrors = + min(_sseConsecutiveErrors + 1, _maxReconnectAttempts); + final delaySec = _computeBackoff(_sseConsecutiveErrors, + base: _sseReconnectBaseSeconds, max: _maxPollIntervalSeconds); + _log.warning( + 'SSE stream error; scheduling reconnect in ${delaySec}s (attempt $_sseConsecutiveErrors)'); + _nextSseRetryAt = DateTime.now().add(Duration(seconds: delaySec)); + Future.delayed(Duration(seconds: delaySec), () { + _nextSseRetryAt = null; + if (!_sseConnected && !_disposed) _tryStartSse(); + }); + } else { + // We never established SSE successfully. Decide whether to mark + // SSE unsupported or to keep trying later. If polling is healthy + // (no recent consecutive poll errors) then the server likely + // doesn't support SSE and we should not keep retrying. + // mark that we've attempted SSE (implicit via logs) + if (_consecutiveErrors < _ssePollErrorThreshold) { + _sseSupported = false; + _log.info( + 'SSE appears unsupported (stream error while polling healthy); disabling SSE attempts'); + } else { + // Polling currently unhealthy; we'll continue polling and let + // refresh() attempt SSE after polls recover. + _log.info( + 'SSE stream error while polls unhealthy; will retry SSE after poll recovery'); + } + } }, onDone: () { _log.info('SSE stream closed by server; falling back to polling'); _closeSse(); if (!_disposed) _startPolling(); - // Schedule aggressive reconnection attempts with jitter while we - // continue polling. - final rand = Random(); - final jitter = rand.nextInt(_sseReconnectJitterSeconds + 1); - final delaySec = _sseReconnectBaseSeconds + jitter; - Future.delayed(Duration(seconds: delaySec), () { - if (!_sseConnected && !_disposed) _tryStartSse(); - }); + if (_sseEverConnected) { + _sseConsecutiveErrors = + min(_sseConsecutiveErrors + 1, _maxReconnectAttempts); + final delaySec = _computeBackoff(_sseConsecutiveErrors, + base: _sseReconnectBaseSeconds, max: _maxPollIntervalSeconds); + _log.warning( + 'SSE stream closed by server; scheduling reconnect in ${delaySec}s (attempt $_sseConsecutiveErrors)'); + _nextSseRetryAt = DateTime.now().add(Duration(seconds: delaySec)); + Future.delayed(Duration(seconds: delaySec), () { + _nextSseRetryAt = null; + if (!_sseConnected && !_disposed) _tryStartSse(); + }); + } else { + // mark that we've attempted SSE (implicit via logs) + if (_consecutiveErrors < _ssePollErrorThreshold) { + _sseSupported = false; + _log.info( + 'SSE appears unsupported (stream closed while polling healthy); disabling SSE attempts'); + } else { + _log.info( + 'SSE closed while polls unhealthy; will retry SSE after poll recovery'); + } + } }, cancelOnError: true); } catch (e, st) { _log.info('SSE subscription failed; using polling', e, st); _sseConnected = false; + // mark that we've attempted SSE (implicit via logs) if (!_disposed) _startPolling(); - // Schedule an aggressive reconnect attempt with jitter. For a local - // printer API we expect very few clients, so retry quickly. - final rand = Random(); - final jitter = rand.nextInt(_sseReconnectJitterSeconds + 1); - final delaySec = _sseReconnectBaseSeconds + jitter; - Future.delayed(Duration(seconds: delaySec), () { - if (!_sseConnected && !_disposed) _tryStartSse(); - }); + // If polling is healthy, the server likely doesn't support SSE; + // otherwise schedule reconnects because network may be flaky. + if (_consecutiveErrors < _ssePollErrorThreshold) { + _sseSupported = false; + _log.info( + 'SSE appears unsupported (subscription failed while polls healthy); disabling SSE attempts'); + } else { + _sseConsecutiveErrors = + min(_sseConsecutiveErrors + 1, _maxReconnectAttempts); + final delaySec = _computeBackoff(_sseConsecutiveErrors, + base: _sseReconnectBaseSeconds, max: _maxPollIntervalSeconds); + _log.warning( + 'SSE subscription failed; scheduling reconnect in ${delaySec}s (attempt $_sseConsecutiveErrors)'); + _nextSseRetryAt = DateTime.now().add(Duration(seconds: delaySec)); + Future.delayed(Duration(seconds: delaySec), () { + _nextSseRetryAt = null; + if (!_sseConnected && !_disposed) _tryStartSse(); + }); + } } } @@ -245,9 +393,63 @@ class StatusProvider extends ChangeNotifier { Future refresh() async { if (_fetchInFlight) return; // simple re-entrancy guard _fetchInFlight = true; + // Snapshot fields to avoid emitting notifications on every successful + // polling refresh when nothing meaningful changed. This reduces churn + // for listeners (e.g., ConnectionErrorWatcher) in polling-only backends + // like NanoDLP. Also snapshot transitional flags so clearing them will + // reliably cause a UI update even when the backend payload is otherwise + // identical. + final prevError = _error; + final prevConsecutive = _consecutiveErrors; + final prevLoading = _loading; + final prevSseSupported = _sseSupported; + final prevIsPausing = _isPausing; + final prevIsCanceling = _isCanceling; + // Capture previous status JSON so we can detect meaningful payload changes + // (e.g. z position, progress) and notify UI even if other counters are + // unchanged. Use JSON encoding of the model's toJson for a stable compare. + final prevStatusJson = _status?.toJson(); + // Helper to compute a small fingerprint capturing the fields that + // usually change while a print is active. This avoids noisy full-JSON + // comparisons and ensures the UI updates when z/progress/layer change. + String fingerprint(StatusModel? s) { + if (s == null) return ''; + final z = s.physicalState.z.toStringAsFixed(3); + final layer = s.layer?.toString() ?? ''; + final total = s.printData?.layerCount.toString() ?? ''; + final paused = s.isPaused ? '1' : '0'; + final status = s.status; + return '$status|$paused|$layer|$total|$z'; + } + + final prevFingerprint = fingerprint(_status); + bool statusChangedByFingerprint = false; try { final raw = await _client.getStatus(); + // Capture device message if present + try { + _deviceStatusMessage = + (raw['device_status_message'] ?? raw['Status'] ?? raw['status']) + ?.toString(); + } catch (_) { + _deviceStatusMessage = null; + } final parsed = StatusModel.fromJson(raw); + // Compute fingerprint difference to detect meaningful changes that + // should update the UI (z/layer/progress/etc.). This allows the + // UI to update every poll while printing without requiring full JSON + // equality checks that can hide small numeric changes. + final nowFingerprint = fingerprint(parsed); + statusChangedByFingerprint = prevFingerprint != nowFingerprint; + // If a print is active (printing or paused) treat the poll as a + // meaningful update so the UI refreshes every interval. Some + // NanoDLP installs report minimal numeric changes that may be lost + // by strict comparisons; forcing updates during active prints keeps + // the UI responsive and in sync with the device. + if (parsed.isPrinting || parsed.isPaused) { + statusChangedByFingerprint = true; + } + // Transitional clears will be based on the freshly parsed payload. // Attempt lazy thumbnail acquisition (only while printing and not yet fetched) if (parsed.isPrinting && _thumbnailPath == null && !_thumbnailReady) { @@ -285,31 +487,137 @@ class StatusProvider extends ChangeNotifier { // Successful refresh -> shorten polling interval _pollIntervalSeconds = _minPollIntervalSeconds; + // Reset polling error counter; when polls are healthy we may try to + // opportunistically establish an SSE subscription. + final wasErroring = _consecutiveErrors > 0; + _consecutiveErrors = 0; + if (wasErroring) { + _log.fine('Status refresh succeeded; consecutive error counter reset'); + } + if (wasErroring && !_sseConnected && !_disposed) { + // Defer SSE attempt slightly so any racing logic in _tryStartSse can + // act after the current refresh completes. + Future.delayed(const Duration(milliseconds: 250), () { + if (!_sseConnected && !_disposed) _tryStartSse(); + }); + } if (_awaitingNewPrintData) { final timedOut = _awaitingSince != null && DateTime.now().difference(_awaitingSince!) > _awaitingTimeout; - if (newPrintReady || timedOut) { + // If backend reports active printing/paused we clear awaiting early + // (do not strictly require file metadata or thumbnail). This avoids + // leaving the UI stuck on a spinner when the backend delays + // populating print_data or thumbnails after a start request. + if (newPrintReady || parsed.isPrinting || parsed.isPaused || timedOut) { _awaitingNewPrintData = false; _awaitingSince = null; } } - // Clear transitional flags when backend state matches - if (_isPausing && parsed.isPaused) _isPausing = false; - if (_isCanceling && parsed.isCanceled) _isCanceling = false; + // Clear transitional flags when the backend's paused/canceled state + // changes compared to our previous snapshot. This handles both + // pause->resume and resume->pause transitions and avoids leaving the + // UI stuck in a spinner. + // Clear transitional flags when backend reflects the requested change + // (e.g., resume -> paused=false or cancel -> layer==null). + if (_isPausing && parsed.isPaused) { + _isPausing = false; + } + if (_isCanceling && + (parsed.isCanceled || + !parsed.isPrinting || + (parsed.isIdle && parsed.layer != null))) { + _isCanceling = false; + } } catch (e, st) { _log.severe('Status refresh failed', e, st); _error = e; _loading = false; - // On failure, back off polling frequency - _pollIntervalSeconds = _maxPollIntervalSeconds; + // On failure, increase consecutive error count and back off polling + _consecutiveErrors = min(_consecutiveErrors + 1, _maxReconnectAttempts); + final backoff = _computeBackoff(_consecutiveErrors, + base: _minPollIntervalSeconds, max: _maxPollIntervalSeconds); + _pollIntervalSeconds = backoff; + _nextPollRetryAt = DateTime.now().add(Duration(seconds: backoff)); + // Clear timestamp when timer expires + Future.delayed(Duration(seconds: backoff), () { + _nextPollRetryAt = null; + }); + _log.warning( + 'Status refresh failed; backing off polling for ${_pollIntervalSeconds}s (attempt $_consecutiveErrors)'); } finally { _fetchInFlight = false; - if (!_disposed) notifyListeners(); + if (!_disposed) { + // Only notify listeners if one of the meaningful observable fields + // actually changed. This avoids spamming watchers with identical + // state on each poll when the backend is healthy. Additionally, + // compare the previous status model JSON so UI will update when + // backend fields (z/progress/layer) change between polls. + final nowError = _error; + final nowConsecutive = _consecutiveErrors; + final nowLoading = _loading; + final nowSseSupported = _sseSupported; + final nowIsPausing = _isPausing; + final nowIsCanceling = _isCanceling; + final nowStatusJson = _status?.toJson(); + // Prefer fingerprint-based change detection for performance and to + // ensure per-second updates while printing. Fall back to full JSON + // comparison if necessary. + bool statusChanged = statusChangedByFingerprint; + if (!statusChanged) { + try { + final p = json.encode(prevStatusJson); + final n = json.encode(nowStatusJson); + statusChanged = p != n; + } catch (_) { + statusChanged = true; + } + } + + var shouldNotify = (prevError != nowError) || + (prevConsecutive != nowConsecutive) || + (prevLoading != nowLoading) || + (prevSseSupported != nowSseSupported) || + (prevIsPausing != nowIsPausing) || + (prevIsCanceling != nowIsCanceling) || + statusChanged; + // If configured for NanoDLP (developer override or backend config), + // we poll frequently. Some NanoDLP setups report only tiny numeric + // changes which can be normalized away; ensure active printing/paused + // always triggers UI updates on each poll so the status screen stays + // visually responsive. + try { + final cfg = OrionConfig(); + final backend = cfg.getString('backend', category: 'advanced'); + final devNano = cfg.getFlag('nanoDLPmode', category: 'developer'); + final isNano = backend == 'nanodlp' || devNano; + if (!shouldNotify && + isNano && + (_status?.isPrinting == true || _status?.isPaused == true)) { + shouldNotify = true; + } + } catch (_) { + // If config read fails, don't change shouldNotify. + } + if (shouldNotify) notifyListeners(); + } } } + int _computeBackoff(int attempts, {required int base, required int max}) { + // Exponential: base * 2^attempts with cap, plus random jitter up to 50%. + final raw = base * pow(2, attempts); + int secs = raw.toInt(); + if (secs > max) secs = max; + // Add jitter up to 50% of the computed backoff to avoid thundering herd. + final rand = Random(); + final jitter = rand.nextInt((secs ~/ 2) + 1); + secs = secs + jitter; + if (secs > max) secs = max; + return secs; + } + // --- Derived UI convenience --- String get displayStatus => _status?.displayLabel( @@ -340,6 +648,11 @@ class StatusProvider extends ChangeNotifier { notifyListeners(); try { await _client.resumePrint(); + // Clear transitional flag proactively on success so the UI doesn't + // remain in a spinning 'resuming' state while the backend takes a + // moment to reflect the new paused=false status. + _isPausing = false; + if (!_disposed) notifyListeners(); } catch (e, st) { _log.severe('Resume failed', e, st); _isPausing = false; // revert transitional flag From 9e542e9dd1e4296d5ddfffc7dae8e4561e79580f Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 00:22:39 +0200 Subject: [PATCH 05/47] feat(backend_service): add moveDelta, canMoveToTop, and moveToTop methods to OdysseyHttpClient --- lib/backend_service/odyssey/odyssey_client.dart | 11 +++++++++++ .../odyssey/odyssey_http_client.dart | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/backend_service/odyssey/odyssey_client.dart b/lib/backend_service/odyssey/odyssey_client.dart index e0d4df0..da07e10 100644 --- a/lib/backend_service/odyssey/odyssey_client.dart +++ b/lib/backend_service/odyssey/odyssey_client.dart @@ -37,6 +37,17 @@ abstract class OdysseyClient { // Manual controls and hardware commands Future> move(double height); + + /// Send a relative Z move in millimeters (positive = up, negative = down). + /// This maps directly to the device's relative move endpoints when + /// available (e.g. NanoDLP /z-axis/move/.../micron/...). + Future> moveDelta(double deltaMm); + + /// Whether the client supports a direct "move to top limit" command. + Future canMoveToTop(); + + /// Move the Z axis directly to the device's top limit if supported. + Future> moveToTop(); Future> manualCure(bool cure); Future> manualHome(); Future> manualCommand(String command); diff --git a/lib/backend_service/odyssey/odyssey_http_client.dart b/lib/backend_service/odyssey/odyssey_http_client.dart index fb4fb59..a7672fa 100644 --- a/lib/backend_service/odyssey/odyssey_http_client.dart +++ b/lib/backend_service/odyssey/odyssey_http_client.dart @@ -119,6 +119,20 @@ class OdysseyHttpClient implements OdysseyClient { as Map; } + @override + Future> moveDelta(double deltaMm) async { + final resp = await _odysseyPost('/manual', {'dz': deltaMm.toString()}); + return json.decode(resp.body == '' ? '{}' : resp.body) + as Map; + } + + @override + Future canMoveToTop() async => false; + + @override + Future> moveToTop() async => + throw UnimplementedError('moveToTop not supported by Odyssey backend'); + @override Future> manualCure(bool cure) async { final resp = await _odysseyPost('/manual', {'cure': cure.toString()}); From 0d43ee02fc7cd1d44d061ab76d8c3e63cd46a614 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 00:23:57 +0200 Subject: [PATCH 06/47] feat(files): GridFilesScreen: add support for NanoDLP backend configuration and adjust UI accordingly - no need to show a USB/Internal Switcher, NanoDLP combines the list. --- lib/files/grid_files_screen.dart | 35 ++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/lib/files/grid_files_screen.dart b/lib/files/grid_files_screen.dart index b0d83c4..880201a 100644 --- a/lib/files/grid_files_screen.dart +++ b/lib/files/grid_files_screen.dart @@ -69,6 +69,7 @@ class GridFilesScreenState extends State { bool _apiErrorState = false; bool _isLoading = false; bool _isNavigating = false; + bool _isNanoDlp = false; @override void initState() { @@ -77,6 +78,11 @@ class GridFilesScreenState extends State { // decoded off the main isolate. final OrionConfig config = OrionConfig(); _isUSB = config.getFlag('useUsbByDefault'); + // Determine whether the configured backend is NanoDLP. Use this as the + // canonical source for UI decisions (hide USB/Internal toggles etc.). + _isNanoDlp = + config.getString('backend', category: 'advanced').toLowerCase() == + 'nanodlp'; WidgetsBinding.instance.addPostFrameCallback((_) async { if (_defaultDirectory.isEmpty) { final provider = Provider.of(context, listen: false); @@ -185,6 +191,7 @@ class GridFilesScreenState extends State { String _getDisplayNameForDirectory(String directory) { if (directory == _defaultDirectory && !_apiErrorState) { + if (_isNanoDlp) return 'Print Files'; return _isUSB == false ? 'Print Files (Internal)' : 'Print Files (USB)'; } @@ -230,6 +237,14 @@ class GridFilesScreenState extends State { if (loading) { return const Center(child: CircularProgressIndicator()); } + // In NanoDLP mode there is no Internal/USB toggle, so hide the + // parent card. Use the screen-level `_isNanoDlp` flag derived + // from `orion.cfg` as the source-of-truth. + final hideParentCard = _isNanoDlp; + final crossCount = + MediaQuery.of(context).orientation == Orientation.landscape + ? 4 + : 2; return Padding( padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10), child: GridView.builder( @@ -238,18 +253,20 @@ class GridFilesScreenState extends State { childAspectRatio: 1.03, mainAxisSpacing: 5, crossAxisSpacing: 5, - crossAxisCount: MediaQuery.of(context).orientation == - Orientation.landscape - ? 4 - : 2, + crossAxisCount: crossCount, ), - itemCount: itemsList.length + 1, + itemCount: itemsList.length + (hideParentCard ? 0 : 1), itemBuilder: (BuildContext context, int index) { - if (index == 0) { - return _buildParentCard(context); + if (!hideParentCard) { + if (index == 0) { + return _buildParentCard(context); + } + final OrionApiItem item = itemsList[index - 1]; + return _buildItemCard(context, item); + } else { + final OrionApiItem item = itemsList[index]; + return _buildItemCard(context, item); } - final OrionApiItem item = itemsList[index - 1]; - return _buildItemCard(context, item); }, ), ); From 20bd09fc201fd28a0372a31950f8b2d2d2fb4a8d Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 01:52:54 +0200 Subject: [PATCH 07/47] fix(tools): ExposureScreen: defer API status check to after first frame and handle errors gracefully --- lib/tools/exposure_screen.dart | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/tools/exposure_screen.dart b/lib/tools/exposure_screen.dart index 09b591d..d53ed6b 100644 --- a/lib/tools/exposure_screen.dart +++ b/lib/tools/exposure_screen.dart @@ -209,18 +209,29 @@ class ExposureScreenState extends State { @override void initState() { super.initState(); - getApiStatus(); + // Defer to after first frame to avoid provider notifications during build. + WidgetsBinding.instance.addPostFrameCallback((_) => getApiStatus()); } Future getApiStatus() async { try { final provider = Provider.of(context, listen: false); - if (provider.config == null) await provider.refresh(); + if (provider.config == null) { + try { + await provider.refresh(); + } catch (e) { + setState(() { + _apiErrorState = true; + }); + if (mounted) showErrorDialog(context, 'BLUE-BANANA'); + _logger.severe('Failed to refresh config: $e'); + } + } } catch (e) { setState(() { _apiErrorState = true; - showErrorDialog(context, 'BLUE-BANANA'); }); + if (mounted) showErrorDialog(context, 'BLUE-BANANA'); _logger.severe('Failed to get config: $e'); } } From b08324e39565e9b1d5db66bedd5d926bd2d7e626 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 01:57:09 +0200 Subject: [PATCH 08/47] fix(tools): MoveZScreen: improve error handling during config refresh and update button actions --- lib/tools/move_z_screen.dart | 135 ++++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 41 deletions(-) diff --git a/lib/tools/move_z_screen.dart b/lib/tools/move_z_screen.dart index 72fefff..5ffd236 100644 --- a/lib/tools/move_z_screen.dart +++ b/lib/tools/move_z_screen.dart @@ -78,11 +78,24 @@ class MoveZScreenState extends State { maxZ = provider.config?.machine?['printer']?['max_z'] ?? maxZ; }); } else { - await provider.refresh(); - if (provider.config != null) { + try { + await provider.refresh(); + if (provider.config != null) { + // Safe to update state here because we're already async and not + // in the middle of a build. + setState(() { + maxZ = provider.config?.machine?['printer']?['max_z'] ?? maxZ; + }); + } + } catch (e) { + // Provider rethrows on error; surface a dialog from the screen + // instead of letting the provider call notifyListeners during + // widget build. setState(() { - maxZ = provider.config?.machine?['printer']?['max_z'] ?? maxZ; + _apiErrorState = true; }); + if (mounted) showErrorDialog(context, 'BLUE-BANANA'); + _logger.severe('Failed to refresh config: $e'); } } } catch (e) { @@ -97,7 +110,10 @@ class MoveZScreenState extends State { @override void initState() { super.initState(); - getMaxZ(); + // Defer config refresh work until after the first frame so that any + // notifyListeners() from providers won't run during the widget build + // phase and cause 'setState() or markNeedsBuild() called during build'. + WidgetsBinding.instance.addPostFrameCallback((_) => getMaxZ()); } @override @@ -204,7 +220,13 @@ class MoveZScreenState extends State { children: [ Expanded( child: GlassButton( - onPressed: _apiErrorState ? null : () => moveZ(step), + onPressed: _apiErrorState + ? null + : () { + final manual = + Provider.of(context, listen: false); + manual.moveDelta(step); + }, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15)), @@ -216,7 +238,13 @@ class MoveZScreenState extends State { const SizedBox(height: 30), Expanded( child: GlassButton( - onPressed: _apiErrorState ? null : () => moveZ(-step), + onPressed: _apiErrorState + ? null + : () { + final manual = + Provider.of(context, listen: false); + manual.moveDelta(-step); + }, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15)), @@ -236,45 +264,70 @@ class MoveZScreenState extends State { Expanded( child: Consumer( builder: (context, manual, _) { - return GlassButton( - onPressed: _apiErrorState || manual.busy - ? null - : () async { - _logger.info('Moving to ZMAX'); - final ok = await manual.move(maxZ); - if (!ok && mounted) - showErrorDialog(context, 'Failed to move to top'); - }, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - minimumSize: const Size(double.infinity, double.infinity), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 16), - const Icon(Icons.arrow_upward, size: 30), - const Expanded( - child: AutoSizeText( - 'Move to Top Limit', - style: TextStyle(fontSize: 24), - minFontSize: 20, - maxLines: 1, - overflowReplacement: Padding( - padding: EdgeInsets.only(right: 20.0), - child: Center( - child: Text( - 'Top', - style: TextStyle(fontSize: 24), + return FutureBuilder( + future: manual.canMoveToTop(), + builder: (ctx, snap) { + final supportsTop = snap.data == true; + final enabled = !_apiErrorState && + !manual.busy && + (maxZ > 0.0 || supportsTop); + return GlassButton( + onPressed: !enabled + ? null + : () async { + try { + if (supportsTop) { + _logger.info( + 'Moving to device Top via moveToTop()'); + final ok = await manual.moveToTop(); + if (!ok && mounted) + showErrorDialog( + context, 'Failed to move to top'); + } else { + _logger.info('Moving to ZMAX (maxZ=$maxZ)'); + final ok = await manual.move(maxZ); + if (!ok && mounted) + showErrorDialog( + context, 'Failed to move to top'); + } + } catch (e) { + if (mounted) + showErrorDialog( + context, 'Failed to move to top'); + } + }, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + minimumSize: const Size(double.infinity, double.infinity), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 16), + const Icon(Icons.arrow_upward, size: 30), + const Expanded( + child: AutoSizeText( + 'Move to Top Limit', + style: TextStyle(fontSize: 24), + minFontSize: 20, + maxLines: 1, + overflowReplacement: Padding( + padding: EdgeInsets.only(right: 20.0), + child: Center( + child: Text( + 'Top', + style: TextStyle(fontSize: 24), + ), + ), ), + textAlign: TextAlign.center, ), ), - textAlign: TextAlign.center, - ), + ], ), - ], - ), + ); + }, ); }, ), From dfcb1b511e009c91c3a6eaa0c541b6709825d2dc Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 01:57:29 +0200 Subject: [PATCH 09/47] fix(status): update app bar title to display device status message or fallback to display status --- lib/status/status_screen.dart | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/lib/status/status_screen.dart b/lib/status/status_screen.dart index 5dad78b..39485d7 100644 --- a/lib/status/status_screen.dart +++ b/lib/status/status_screen.dart @@ -167,24 +167,17 @@ class StatusScreenState extends State { child: Scaffold( appBar: AppBar( automaticallyImplyLeading: false, - title: RichText( - text: TextSpan( - children: [ - TextSpan( - text: 'Print Status', - style: Theme.of(context).appBarTheme.titleTextStyle, - ), - TextSpan( - text: ' - ', - style: Theme.of(context).appBarTheme.titleTextStyle, - ), - TextSpan( - text: provider.displayStatus, - style: Theme.of(context).appBarTheme.titleTextStyle, - ), - ], - ), - ), + title: Builder(builder: (context) { + final deviceMsg = provider.deviceStatusMessage; + final statusText = + (deviceMsg != null && deviceMsg.trim().isNotEmpty) + ? deviceMsg + : provider.displayStatus; + return Text( + statusText, + style: Theme.of(context).appBarTheme.titleTextStyle, + ); + }), ), body: Center( child: LayoutBuilder( @@ -257,7 +250,7 @@ class StatusScreenState extends State { 'Print Layers', layerCurrent == null || layerTotal == null ? '- / -' - : '${layerCurrent + 1} / ${layerTotal + 1}', + : '$layerCurrent / $layerTotal', ), ), ]), @@ -302,7 +295,7 @@ class StatusScreenState extends State { 'Print Layers', layerCurrent == null || layerTotal == null ? '- / -' - : '${layerCurrent + 1} / ${layerTotal + 1}', + : '$layerCurrent / $layerTotal', ), _buildInfoCard('Estimated Print Time', elapsedStr), _buildInfoCard( From 10a4d926a2b30a1f67d4013c4397ba6ccaa79f8b Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 01:59:00 +0200 Subject: [PATCH 10/47] feat(nanodlp): Implement NanoDLP HTTP client and status model - Added NanoStatus model to represent the status of the NanoDLP device. - Created NanoDlpHttpClient to handle communication with the NanoDLP API, including status retrieval and file management. - Implemented mapping functions to convert NanoDLP data structures to Odyssey-compatible formats. - Introduced thumbnail generation functionality for visual representation of files. --- lib/backend_service/backend_service.dart | 15 +- .../nanodlp/models/nano_file.dart | 305 ++++++ .../nanodlp/models/nano_manual.dart | 33 + .../nanodlp/models/nano_status.dart | 189 ++++ .../nanodlp/nanodlp_http_client.dart | 912 ++++++++++++++++++ .../nanodlp/nanodlp_mappers.dart | 66 ++ .../nanodlp/nanodlp_thumbnail_generator.dart | 78 ++ 7 files changed, 1595 insertions(+), 3 deletions(-) create mode 100644 lib/backend_service/nanodlp/models/nano_file.dart create mode 100644 lib/backend_service/nanodlp/models/nano_manual.dart create mode 100644 lib/backend_service/nanodlp/models/nano_status.dart create mode 100644 lib/backend_service/nanodlp/nanodlp_http_client.dart create mode 100644 lib/backend_service/nanodlp/nanodlp_mappers.dart create mode 100644 lib/backend_service/nanodlp/nanodlp_thumbnail_generator.dart diff --git a/lib/backend_service/backend_service.dart b/lib/backend_service/backend_service.dart index 680ab35..1f7be52 100644 --- a/lib/backend_service/backend_service.dart +++ b/lib/backend_service/backend_service.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; import 'package:orion/backend_service/odyssey/odyssey_client.dart'; import 'package:orion/backend_service/odyssey/odyssey_http_client.dart'; -//import 'package:orion/backend_service/nanodlp/nanodlp_http_client.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_http_client.dart'; import 'package:orion/util/orion_config.dart'; /// BackendService is a small façade that selects a concrete @@ -24,8 +24,7 @@ class BackendService implements OdysseyClient { // Return the NanoDLP adapter when explicitly requested in config. // Add a small log to aid debugging in cases where config isn't applied. // Note: avoid bringing logging package into this file if not used - //return NanoDlpHttpClient(); - return OdysseyHttpClient(); // Until NanoDLP support is ready + return NanoDlpHttpClient(); } } catch (_) { // ignore config errors and fall back @@ -81,6 +80,16 @@ class BackendService implements OdysseyClient { @override Future> move(double height) => _delegate.move(height); + @override + Future> moveDelta(double deltaMm) => + _delegate.moveDelta(deltaMm); + + @override + Future canMoveToTop() => _delegate.canMoveToTop(); + + @override + Future> moveToTop() => _delegate.moveToTop(); + @override Future> manualCure(bool cure) => _delegate.manualCure(cure); diff --git a/lib/backend_service/nanodlp/models/nano_file.dart b/lib/backend_service/nanodlp/models/nano_file.dart new file mode 100644 index 0000000..525b453 --- /dev/null +++ b/lib/backend_service/nanodlp/models/nano_file.dart @@ -0,0 +1,305 @@ +// DTO for a NanoDLP "plate" or file as returned by status or plates endpoints. +// +// Centralises parsing/normalisation so higher layers (like the HTTP client) +// stay small and simply convert to Odyssey-shaped maps. +class NanoFile { + final String? path; // full path or filename + final String? name; + final int? layerCount; + final double? printTime; // seconds + + // Extended metadata commonly returned by /plates/list/json or similar + final int? lastModified; + final String? parentPath; + final int? fileSize; + final String? materialName; + final double? usedMaterial; + final double? layerHeight; // millimetres + final String? locationCategory; + final int? plateId; + final bool previewAvailable; + + // Keep a reference to the source map for advanced consumers/debug + final Map? raw; + + const NanoFile({ + this.path, + this.name, + this.layerCount, + this.printTime, + this.lastModified, + this.parentPath, + this.fileSize, + this.materialName, + this.usedMaterial, + this.layerHeight, + this.locationCategory, + this.plateId, + this.previewAvailable = false, + this.raw, + }); + + factory NanoFile.fromJson(Map json) { + int? parseInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v); + return null; + } + + // parseDouble removed — volume parsing is handled by parseVolume below. + + bool parseBool(dynamic v) { + if (v == null) return false; + if (v is bool) return v; + if (v is num) return v != 0; + if (v is String) { + final lowered = v.toLowerCase().trim(); + if (lowered == 'true' || lowered == 't' || lowered == 'yes') { + return true; + } + final numeric = int.tryParse(lowered); + if (numeric != null) return numeric != 0; + } + return false; + } + + double? parsePrintTime(dynamic v) { + if (v == null) return null; + if (v is num) return v.toDouble(); + if (v is String) { + final trimmed = v.trim(); + final durationMatch = + RegExp(r'~?(\d{1,2}):(\d{1,2}):(\d{1,2})').firstMatch(trimmed); + if (durationMatch != null) { + final h = int.parse(durationMatch.group(1)!); + final m = int.parse(durationMatch.group(2)!); + final s = int.parse(durationMatch.group(3)!); + return (h * 3600 + m * 60 + s).toDouble(); + } + return double.tryParse(trimmed.replaceAll(RegExp(r'[^0-9+\-.]'), '')); + } + return null; + } + + double? parseLayerHeight(dynamic value, {bool assumeMicrons = false}) { + if (value == null) return null; + double? numeric; + String source = ''; + if (value is num) { + numeric = value.toDouble(); + } else { + source = value.toString(); + numeric = double.tryParse(source.replaceAll(RegExp(r'[^0-9+\-.]'), '')); + } + if (numeric == null) return null; + + final src = source.toLowerCase(); + final hintMicrons = + assumeMicrons || src.contains('µ') || src.contains('micron'); + if (hintMicrons) { + return numeric / 1000.0; + } + if (src.contains('mm')) { + return numeric; + } + if (source.isEmpty && assumeMicrons) { + return numeric / 1000.0; + } + // If no units and numeric seems large (e.g. 50), treat as microns. + if (!src.contains(RegExp(r'[a-z]')) && numeric >= 10) { + return numeric / 1000.0; + } + return numeric; + } + + String? path = json['path']?.toString() ?? + json['Path']?.toString() ?? + json['file_path']?.toString() ?? + json['File']?.toString(); + String? name = json['name']?.toString() ?? json['Name']?.toString(); + if (name == null && path != null) { + final parts = path.split('/'); + if (parts.isNotEmpty) name = parts.last; + } + if (path == null && name != null) { + path = name; + } + + final layerCount = parseInt( + json['layer_count'] ?? json['LayerCount'] ?? json['layerCount']); + final printTime = parsePrintTime( + json['print_time'] ?? json['printTime'] ?? json['PrintTime']); + final lastModified = parseInt(json['last_modified'] ?? + json['LastModified'] ?? + json['Updated'] ?? + json['UpdatedOn'] ?? + json['CreatedDate']); + String? parentPath = + (json['parent_path'] ?? json['parentPath'])?.toString(); + final fileSize = parseInt( + json['file_size'] ?? json['FileSize'] ?? json['size'] ?? json['Size']); + double? parseVolume(dynamic v) { + if (v == null) return null; + // If it's numeric, assume it's already in mL + if (v is num) return v.toDouble(); + final s = v.toString().trim(); + if (s.isEmpty) return null; + + final lower = s.toLowerCase(); + // Extract numeric portion + final numStr = lower.replaceAll(RegExp(r'[^0-9+\-\.eE]'), ''); + final parsed = double.tryParse(numStr); + if (parsed == null) return null; + + // Unit detection + if (lower.contains('µ') || + lower.contains('ul') || + lower.contains('microl')) { + // micro-liters -> mL + return parsed / 1000.0; + } + if (lower.contains('l') && + !lower.contains('ml') && + !lower.contains('ul')) { + // liters -> mL + return parsed * 1000.0; + } + if (lower.contains('ml') || + lower.contains('cc') || + lower.contains('cm3')) { + return parsed; + } + // Heuristic: if the number is very large (>1000) and no unit, it might be µL + if (parsed >= 1000.0) return parsed / 1000.0; + return parsed; + } + + final materialName = json['ProfileName'] ?? 'N/A'; + final usedMaterial = parseVolume(json['used_material'] ?? + json['usedMaterial'] ?? + json['UsedMaterial'] ?? + json['UsedMaterialMl'] ?? + json['UsedResin'] ?? + json['ResinVolume'] ?? + json['UsedVolume'] ?? + json['Volume'] ?? + json['TotalSolidArea'] ?? + 0); + + double? layerHeight = parseLayerHeight( + json['layer_height'] ?? json['layerHeight'] ?? json['PlateHeight']); + layerHeight ??= + parseLayerHeight(json['LayerThickness'], assumeMicrons: true) ?? + parseLayerHeight(json['ZRes'], assumeMicrons: true); + + final locationCategory = + json['location_category']?.toString() ?? json['location']?.toString(); + final plateId = parseInt(json['PlateID'] ?? json['plate_id']); + final previewAvailable = + parseBool(json['Preview'] ?? json['preview'] ?? json['HasPreview']); + + final resolvedPath = path ?? ''; + final resolvedName = name ?? resolvedPath; + if ((parentPath == null || parentPath.isEmpty) && + resolvedPath.contains('/')) { + parentPath = resolvedPath.substring(0, resolvedPath.lastIndexOf('/')); + } + + return NanoFile( + path: resolvedPath, + name: resolvedName, + layerCount: layerCount, + printTime: printTime, + lastModified: lastModified, + parentPath: parentPath ?? '', + fileSize: fileSize, + materialName: materialName, + usedMaterial: usedMaterial, + layerHeight: layerHeight, + locationCategory: locationCategory ?? 'Local', + plateId: plateId, + previewAvailable: previewAvailable, + raw: json, + ); + } + + Map toJson() => { + 'path': path, + 'name': name, + 'layer_count': layerCount, + 'print_time': printTime, + 'last_modified': lastModified, + 'parent_path': parentPath, + 'file_size': fileSize, + 'material_name': materialName, + 'used_material': usedMaterial, + 'layer_height': layerHeight, + 'location_category': locationCategory, + 'plate_id': plateId, + 'preview': previewAvailable, + }; + + String? _formatSecondsToHMS(double? seconds) { + if (seconds == null) return null; + final secs = seconds.toInt(); + final h = secs ~/ 3600; + final m = (secs % 3600) ~/ 60; + final s = secs % 60; + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(h)}:${two(m)}:${two(s)}'; + } + + String get resolvedPath => path ?? name ?? ''; + + Map toOdysseyFileEntry() { + final basePath = resolvedPath; + final resolvedName = name ?? basePath; + final resolvedParent = (parentPath != null && parentPath!.isNotEmpty) + ? parentPath! + : (basePath.contains('/') + ? basePath.substring(0, basePath.lastIndexOf('/')) + : ''); + + final entry = { + 'file_data': { + 'path': basePath, + 'name': resolvedName, + 'last_modified': lastModified ?? 0, + 'parent_path': resolvedParent, + 'file_size': fileSize, + }, + 'location_category': locationCategory ?? 'Local', + 'material_name': materialName ?? 'N/A', + 'used_material': usedMaterial ?? 0.0, + 'print_time': printTime ?? 0.0, + if (printTime != null) + 'print_time_formatted': _formatSecondsToHMS(printTime), + 'layer_count': layerCount ?? 0, + }; + if (layerHeight != null) { + entry['layer_height'] = layerHeight; + } + if (plateId != null) { + entry['plate_id'] = plateId; + } + entry['preview_available'] = previewAvailable; + return entry; + } + + Map toOdysseyMetadata() { + final meta = { + 'file_data': toOdysseyFileEntry()['file_data'], + 'layer_height': layerHeight, + 'material_name': materialName ?? 'N/A', + 'used_material': usedMaterial ?? 0.0, + 'print_time': printTime ?? 0.0, + if (printTime != null) + 'print_time_formatted': _formatSecondsToHMS(printTime), + 'plate_id': plateId, + 'preview_available': previewAvailable, + }; + return meta; + } +} diff --git a/lib/backend_service/nanodlp/models/nano_manual.dart b/lib/backend_service/nanodlp/models/nano_manual.dart new file mode 100644 index 0000000..dac077a --- /dev/null +++ b/lib/backend_service/nanodlp/models/nano_manual.dart @@ -0,0 +1,33 @@ +class NanoManualResult { + NanoManualResult({required this.ok, this.message}); + + final bool ok; + final String? message; + + Map toMap() => { + 'ok': ok, + if (message != null) 'message': message, + }; + + @override + String toString() => 'NanoManualResult(ok: $ok, message: $message)'; + + static NanoManualResult fromDynamic(dynamic src) { + if (src == null) return NanoManualResult(ok: true); + if (src is NanoManualResult) return src; + if (src is Map) { + final m = src; + final ok = m['ok'] is bool ? m['ok'] as bool : (m['result'] == 'ok'); + final message = m['message']?.toString(); + return NanoManualResult(ok: ok, message: message); + } + if (src is String) { + // Some NanoDLP endpoints return plain text + final s = src.trim(); + if (s.isEmpty) return NanoManualResult(ok: true); + return NanoManualResult(ok: true, message: s); + } + if (src is bool) return NanoManualResult(ok: src); + return NanoManualResult(ok: true); + } +} diff --git a/lib/backend_service/nanodlp/models/nano_status.dart b/lib/backend_service/nanodlp/models/nano_status.dart new file mode 100644 index 0000000..2dce8a9 --- /dev/null +++ b/lib/backend_service/nanodlp/models/nano_status.dart @@ -0,0 +1,189 @@ +import 'nano_file.dart'; + +// Minimal NanoDLP status DTO +class NanoStatus { + // Raw fields from NanoDLP /status + final bool printing; + final bool paused; + final String? statusMessage; + final int? currentHeight; // possibly microns or device units + final int? layerId; + final int? layersCount; + final double? resinLevel; // mm or percent depending on device + final double? temp; + final double? mcuTemp; + final String? rawJsonStatus; + + // Existing convenience fields + final String state; // 'printing' | 'paused' | 'idle' + final double? progress; // 0.0 - 1.0 + final NanoFile? file; // not always present in NanoDLP status + final double? z; // z position (converted if needed) + final bool curing; + + NanoStatus({ + required this.printing, + required this.paused, + this.statusMessage, + this.currentHeight, + this.layerId, + this.layersCount, + this.resinLevel, + this.temp, + this.mcuTemp, + this.rawJsonStatus, + required this.state, + this.progress, + this.file, + this.z, + this.curing = false, + }); + + factory NanoStatus.fromJson(Map json) { + // The NanoDLP /status payloads vary between installs. File/plate metadata + // may appear under different keys (lower/upper case or different names). + // Search a set of likely candidate keys and pick the first Map-like value. + NanoFile? nf; + final candidateFileKeys = [ + 'file', + 'File', + 'plate', + 'Plate', + 'file_data', + 'FileData', + 'fileData', + 'current_file', + 'CurrentFile', + 'job', + 'Job', + ]; + for (final k in candidateFileKeys) { + final val = json[k]; + if (val is Map) { + nf = NanoFile.fromJson(Map.from(val)); + break; + } + if (val is Map) { + try { + nf = NanoFile.fromJson(Map.from(val)); + break; + } catch (_) { + // ignore and continue + } + } + } + + // Helpers + int? parseInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v); + return null; + } + + double? parseDouble(dynamic v) { + if (v == null) return null; + if (v is double) return v; + if (v is num) return v.toDouble(); + if (v is String) { + // Some devices send '24.85°C' — strip non-numeric + final cleaned = v.replaceAll(RegExp(r'[^0-9+\-\.]'), ''); + return double.tryParse(cleaned); + } + return null; + } + + final printing = json['Printing'] == true || + json['printing'] == true || + json['Started'] == 1 || + json['started'] == 1; + final paused = json['Paused'] == true || json['paused'] == true; + final statusMessage = + json['Status']?.toString() ?? json['status']?.toString(); + final currentHeight = parseInt(json['CurrentHeight'] ?? + json['current_height'] ?? + json['CurrentHeight']); + final layerId = + parseInt(json['LayerID'] ?? json['layer_id'] ?? json['LayerID']); + final layersCount = parseInt( + json['LayersCount'] ?? json['layers_count'] ?? json['LayersCount']); + final resinLevel = parseDouble( + json['resin'] ?? json['ResinLevelMm'] ?? json['resin_level_mm']); + final temp = parseDouble(json['temp']); + final mcuTemp = parseDouble(json['mcu']); + final curing = json['Curing'] == true || json['curing'] == true; + + // Map to simple state + String state; + if (printing) { + state = 'printing'; + } else if (paused) { + state = 'paused'; + } else { + state = 'idle'; + } + + double? progress; + if (layerId != null && layersCount != null && layersCount > 0) { + progress = (layerId / layersCount).clamp(0.0, 1.0).toDouble(); + } + + double? z; + if (currentHeight != null) { + // CurrentHeight units vary between NanoDLP installs. Common possibilities: + // - already in mm (small integers, e.g. 150) + // - in microns (e.g. 150000 -> 150 mm) + // - in nanometers (e.g. 1504000 -> 1.504 mm) + // Heuristic: pick the conversion that yields a plausible Z (<= 300 mm). + final asMmIfMicrons = currentHeight / 1000.0; // microns -> mm + final asMmIfNanometers = currentHeight / 1000000.0; // nm -> mm + if (currentHeight <= 1000) { + // Small values are likely already mm + z = currentHeight.toDouble(); + } else if (asMmIfMicrons <= 300.0) { + z = asMmIfMicrons; + } else if (asMmIfNanometers <= 300.0) { + z = asMmIfNanometers; + } else { + // Fallback: assume microns + z = asMmIfMicrons; + } + } + + return NanoStatus( + printing: printing, + paused: paused, + statusMessage: statusMessage, + currentHeight: currentHeight, + layerId: layerId, + layersCount: layersCount, + resinLevel: resinLevel, + temp: temp, + mcuTemp: mcuTemp, + rawJsonStatus: json['Status']?.toString() ?? json.toString(), + state: state, + progress: progress, + file: nf, + z: z, + curing: curing, + ); + } + + Map toJson() => { + 'printing': printing, + 'paused': paused, + 'statusMessage': statusMessage, + 'currentHeight': currentHeight, + 'layerId': layerId, + 'layersCount': layersCount, + 'resinLevel': resinLevel, + 'temp': temp, + 'mcuTemp': mcuTemp, + 'state': state, + 'progress': progress, + 'file': file?.toJson(), + 'z': z, + 'curing': curing, + }; +} diff --git a/lib/backend_service/nanodlp/nanodlp_http_client.dart b/lib/backend_service/nanodlp/nanodlp_http_client.dart new file mode 100644 index 0000000..6d8f732 --- /dev/null +++ b/lib/backend_service/nanodlp/nanodlp_http_client.dart @@ -0,0 +1,912 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_file.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_manual.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; +import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/util/orion_config.dart'; + +/// NanoDLP adapter (initial implementation) +/// +/// Implements a small subset of the `OdysseyClient` contract needed for +/// StatusProvider and thumbnail fetching. Other methods remain unimplemented +/// and should be added as needed. +class NanoDlpHttpClient implements OdysseyClient { + late final String apiUrl; + final _log = Logger('NanoDlpHttpClient'); + final http.Client Function() _clientFactory; + // Increase plates cache TTL so we don't re-query the plates list on every + // frequent status poll. Plates metadata is relatively static during a + // print session so a 2-minute cache avoids needless network load. + static const Duration _platesCacheTtl = Duration(seconds: 120); + List? _platesCacheData; + DateTime? _platesCacheTime; + Future>? _platesCacheFuture; + // Cache a resolved PlateID -> NanoFile mapping so we don't repeatedly + // perform list lookups for the same active plate while status is polled. + int? _resolvedPlateId; + NanoFile? _resolvedPlateFile; + DateTime? _resolvedPlateTime; + static const Duration _thumbnailCacheTtl = Duration(seconds: 30); + static const Duration _thumbnailPlaceholderCacheTtl = Duration(seconds: 5); + final Map _thumbnailCache = {}; + final Map> _thumbnailInFlight = {}; + + NanoDlpHttpClient({http.Client Function()? clientFactory}) + : _clientFactory = clientFactory ?? http.Client.new { + _createdAt = DateTime.now(); + try { + final cfg = OrionConfig(); + final base = cfg.getString('nanodlp.base_url', category: 'advanced'); + final useCustom = cfg.getFlag('useCustomUrl', category: 'advanced'); + final custom = cfg.getString('customUrl', category: 'advanced'); + + if (base.isNotEmpty) { + apiUrl = base; + } else if (useCustom && custom.isNotEmpty) { + apiUrl = custom; + } else { + apiUrl = 'http://localhost'; + } + } catch (e) { + apiUrl = 'http://localhost'; + } + _log.info('constructed NanoDlpHttpClient apiUrl=$apiUrl'); + } + + // Timestamp when this client instance was created. Used to avoid + // performing potentially expensive plate-list resolution during the + // very first status poll immediately at app startup. + late final DateTime _createdAt; + + // --- Minimal implemented APIs --- + @override + Future> getStatus() async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/status'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + throw Exception('NanoDLP status call failed: ${resp.statusCode}'); + } + final decoded = json.decode(resp.body) as Map; + var nano = NanoStatus.fromJson(decoded); + + // If the status payload doesn't include file metadata but does include + // a PlateID, try to resolve the plate from the plates list so we can + // populate file metadata & enable thumbnail lookup on the UI. + if (nano.file == null) { + int? plateId; + try { + final candidate = decoded['PlateID'] ?? + decoded['plate_id'] ?? + decoded['Plateid'] ?? + decoded['plateId']; + if (candidate is int) plateId = candidate; + if (candidate is String) plateId = int.tryParse(candidate); + } catch (_) { + plateId = null; + } + if (plateId != null) { + try { + // If we previously resolved this plate and the cache is fresh, + // reuse it. + if (_resolvedPlateId == plateId && + _resolvedPlateFile != null && + _resolvedPlateTime != null && + DateTime.now().difference(_resolvedPlateTime!) < + _platesCacheTtl) { + final found = _resolvedPlateFile!; + nano = NanoStatus( + printing: nano.printing, + paused: nano.paused, + statusMessage: nano.statusMessage, + currentHeight: nano.currentHeight, + layerId: nano.layerId, + layersCount: nano.layersCount, + resinLevel: nano.resinLevel, + temp: nano.temp, + mcuTemp: nano.mcuTemp, + rawJsonStatus: nano.rawJsonStatus, + state: nano.state, + progress: nano.progress, + file: found, + z: nano.z, + curing: nano.curing, + ); + } else { + // Only attempt to resolve plate metadata when a job is active + // (printing). Avoid fetching the plates list while the device + // is idle to minimize network traffic at startup. + if (!nano.printing) { + // Skipping PlateID $plateId resolve because printer is not printing + } else { + // Don't block status fetch on plates list resolution. Schedule + // a background task to refresh plates and populate the + // resolved-plate cache for subsequent polls and thumbnail + // lookups. However, avoid doing this immediately at startup + // (many installs poll status right away) — only schedule the + // async resolve if this client was created more than 2s ago. + final age = DateTime.now().difference(_createdAt); + if (age < const Duration(seconds: 2)) { + _log.fine( + 'Skipping PlateID $plateId resolve during startup (age=${age.inMilliseconds}ms)'); + } else { + _log.fine('Scheduling async resolve for PlateID $plateId'); + Future(() async { + try { + final plates = await _fetchPlates(); + final found = plates.firstWhere( + (p) => p.plateId != null && p.plateId == plateId, + orElse: () => const NanoFile()); + if (found.plateId != null) { + // Cache resolved plate for future polls + _log.fine('Resolved PlateID $plateId -> ${found.name}'); + _resolvedPlateId = plateId; + _resolvedPlateFile = found; + _resolvedPlateTime = DateTime.now(); + } + } catch (e, st) { + _log.fine('Async PlateID resolve failed', e, st); + } + }); + } + } + } + } catch (e, st) { + _log.fine( + 'Failed to resolve PlateID $plateId to plate metadata', e, st); + } + } + } + + return nanoStatusToOdysseyMap(nano); + } finally { + client.close(); + } + } + + @override + Future usbAvailable() async { + // NanoDLP runs on a networked device, USB availability is not applicable. + return false; + } + + @override + Stream> getStatusStream() async* { + const pollInterval = Duration(seconds: 2); + while (true) { + try { + final m = await getStatus(); + yield m; + } catch (_) { + // ignore and continue + } + await Future.delayed(pollInterval); + } + } + + @override + Future getFileThumbnail( + String location, String filePath, String size) async { + final dims = _thumbnailDimensions(size); + Uint8List placeholder() { + _log.fine( + 'Using placeholder thumbnail for $filePath ($size -> ${dims.$1}x${dims.$2})'); + return NanoDlpThumbnailGenerator.generatePlaceholder(dims.$1, dims.$2); + } + + final normalizedPath = + filePath.replaceAll(RegExp(r'^/+'), '').toLowerCase(); + var cacheKey = + _thumbnailCacheKey('missing:$normalizedPath', dims.$1, dims.$2); + final cachedBeforeLookup = _getCachedThumbnail(cacheKey); + if (cachedBeforeLookup != null) { + return cachedBeforeLookup; + } + + NanoFile? plate; + try { + plate = await _findPlateForPath(filePath); + } catch (e, st) { + _log.warning( + 'NanoDLP failed locating plate for thumbnail: $filePath', e, st); + final bytes = placeholder(); + _storeThumbnail(cacheKey, bytes, placeholder: true); + return bytes; + } + + if (plate == null) { + _log.fine('NanoDLP thumbnail lookup found no plate for $filePath'); + final bytes = placeholder(); + _storeThumbnail(cacheKey, bytes, placeholder: true); + return bytes; + } + cacheKey = + _thumbnailCacheKeyForPlate(plate, normalizedPath, dims.$1, dims.$2); + final cached = _getCachedThumbnail(cacheKey); + if (cached != null) { + return cached; + } + + if (plate.plateId == null || !plate.previewAvailable) { + _log.fine( + 'NanoDLP plate ${plate.resolvedPath} has no preview (plateId=${plate.plateId}, preview=${plate.previewAvailable})'); + final bytes = placeholder(); + _storeThumbnail(cacheKey, bytes, placeholder: true); + return bytes; + } + + final inflight = _thumbnailInFlight[cacheKey]; + if (inflight != null) { + final entry = await inflight; + final cachedEntry = _getCachedThumbnail(cacheKey); + if (cachedEntry != null) { + return cachedEntry; + } + _storeThumbnailEntry(cacheKey, entry); + return entry.bytes; + } + + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/static/plates/${plate.plateId}/3d.png'); + final future = _downloadThumbnail(uri, 'plate ${plate.plateId}'); + _thumbnailInFlight[cacheKey] = future; + try { + final entry = await future; + _storeThumbnailEntry(cacheKey, entry); + return entry.bytes; + } finally { + if (identical(_thumbnailInFlight[cacheKey], future)) { + _thumbnailInFlight.remove(cacheKey); + } + } + } + + // --- Unimplemented / TODOs --- + @override + Future cancelPrint() async { + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/printer/stop'); + _log.info('NanoDLP stopPrint request: $uri'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP stopPrint failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP stopPrint failed: ${resp.statusCode}'); + } + // Some installs return JSON or plain text; ignore body and treat 200 as success + return; + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP stopPrint error', e, st); + rethrow; + } + } + + @override + Future> deleteFile( + String location, String filePath) async => + throw UnimplementedError('NanoDLP deleteFile not implemented'); + + @override + Future> getFileMetadata( + String location, String filePath) async { + try { + final plate = await _findPlateForPath(filePath); + if (plate != null) { + return plate.toOdysseyMetadata(); + } + return { + 'file_data': { + 'path': filePath, + 'name': filePath, + 'last_modified': 0, + 'parent_path': '', + }, + }; + } catch (e, st) { + _log.warning('NanoDLP getFileMetadata failed for $filePath', e, st); + throw Exception('NanoDLP getFileMetadata failed: $e'); + } + } + + @override + Future> getConfig() async => + // NanoDLP doesn't have a separate /config endpoint in many setups. + // Use /status as a best-effort source for device info and expose a + // minimal config-shaped map expected by ConfigModel.fromJson. + () async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/status'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + throw Exception('NanoDLP status call failed: ${resp.statusCode}'); + } + final decoded = json.decode(resp.body) as Map; + + // Map relevant keys into a config-shaped map + final general = { + 'hostname': decoded['Hostname'] ?? decoded['hostname'] ?? '', + 'ip': decoded['IP'] ?? decoded['ip'] ?? '', + 'status': decoded['Status'] ?? decoded['status'] ?? '', + }; + + final advanced = { + 'backend': 'nanodlp', + 'nanodlp': { + 'build': decoded['Build'] ?? decoded['build'], + 'version': decoded['Version'] ?? decoded['version'], + } + }; + + final machine = { + 'disk': decoded['disk'] ?? decoded['Disk'], + 'wifi': decoded['Wifi'] ?? decoded['wifi'], + 'resin_level': decoded['resin'] ?? + decoded['ResinLevelMm'] ?? + decoded['resin_level_mm'], + }; + + return { + 'general': general, + 'advanced': advanced, + 'machine': machine, + 'vendor': {}, + }; + } finally { + client.close(); + } + }(); + + @override + Future> listItems( + String location, int pageSize, int pageIndex, String subdirectory) async { + try { + final plates = await _fetchPlates(); + final files = plates.map((p) => p.toOdysseyFileEntry()).toList(); + _log.info('listItems: mapped ${files.length} files from NanoDLP payload'); + return { + 'files': files, + 'dirs': >[], + 'page_index': pageIndex, + 'page_size': pageSize, + }; + } catch (e, st) { + _log.warning('NanoDLP listItems failed', e, st); + return { + 'files': >[], + 'dirs': >[], + 'page_index': pageIndex, + 'page_size': pageSize, + }; + } + } + + @override + Future> move(double height) async { + // NanoDLP's /z-axis/move endpoint expects a relative micron distance. + // Our callers may pass an absolute target (Odyssey contract) or a + // relative delta. We'll behave as follows: + // - If we can read current Z from /status, treat `height` as an absolute + // target and compute delta = height - currentZ (mm). + // - If we cannot read status, treat `height` as a relative delta (mm). + // Always read current Z from /status. If we cannot read status, + // fail the move so callers can surface the error. We must compute + // a delta against the true device position. + double currentZ = 0.0; + try { + final statusMap = await getStatus(); + final phys = statusMap['physical_state']; + if (phys is Map && phys['z'] != null) { + final zVal = phys['z']; + if (zVal is num) { + currentZ = zVal.toDouble(); + } + } + } catch (e, st) { + _log.warning('Failed to read NanoDLP status; cannot compute move', e, st); + throw Exception('Failed to read NanoDLP status: $e'); + } + + // Compute delta in mm: callers provide an absolute target; compute + // delta = target - currentZ + final deltaMm = (height - currentZ); + + // Convert to microns (NanoDLP expects integer micron distances). Use + // rounding to nearest micron. If the resulting delta is zero, no-op. + final deltaMicrons = deltaMm == 0.0 ? 0 : (deltaMm * 1000).round(); + if (deltaMicrons == 0) { + return NanoManualResult(ok: true, message: 'no-op').toMap(); + } + + final direction = deltaMicrons > 0 ? 'up' : 'down'; + final distanceMicrons = deltaMicrons.abs(); + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse( + '$baseNoSlash/z-axis/move/$direction/micron/$distanceMicrons'); + _log.info( + 'NanoDLP relative move request: $uri (deltaMm=$deltaMm currentZ=$currentZ)'); + + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning('NanoDLP move failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP move failed: ${resp.statusCode}'); + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + } + + @override + Future> moveDelta(double deltaMm) async { + // Send a dumb relative move command in microns (NanoDLP expects an + // integer micron distance). Positive = up, negative = down. + final deltaMicrons = (deltaMm * 1000).round(); + if (deltaMicrons == 0) + return NanoManualResult(ok: true, message: 'no-op').toMap(); + + final direction = deltaMicrons > 0 ? 'up' : 'down'; + final distance = deltaMicrons.abs(); + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = + Uri.parse('$baseNoSlash/z-axis/move/$direction/micron/$distance'); + _log.info('NanoDLP relative moveDelta request: $uri (deltaMm=$deltaMm)'); + + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP moveDelta failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP moveDelta failed: ${resp.statusCode}'); + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + } + + @override + Future canMoveToTop() async { + // Best-effort: check /status to see if device exposes z-axis controls + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/status'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) return false; + // If status contains physical_state or similar keys, assume support. + final decoded = json.decode(resp.body) as Map; + return decoded.containsKey('CurrentHeight') || + decoded.containsKey('physical_state'); + } finally { + client.close(); + } + } catch (_) { + return false; + } + } + + @override + Future> moveToTop() async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/z-axis/top'); + _log.info('NanoDLP moveToTop request: $uri'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP moveToTop failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP moveToTop failed: ${resp.statusCode}'); + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + } + + @override + Future> manualCommand(String command) async => + throw UnimplementedError('NanoDLP manualCommand not implemented'); + + @override + Future> manualCure(bool cure) async => + throw UnimplementedError('NanoDLP manualCure not implemented'); + + @override + Future> manualHome() async => () async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/z-axis/calibrate'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP manualHome failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP manualHome failed: ${resp.statusCode}'); + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + // Some NanoDLP installs return empty body; treat 200 as success. + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + }(); + + @override + Future pausePrint() async { + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/printer/pause'); + _log.info('NanoDLP pausePrint request: $uri'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP pausePrint failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP pausePrint failed: ${resp.statusCode}'); + } + // Some installs return JSON or plain text; ignore body and treat 200 as success + return; + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP pausePrint error', e, st); + rethrow; + } + } + + @override + Future resumePrint() async { + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/printer/unpause'); + _log.info('NanoDLP resumePrint request: $uri'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP resumePrint failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP resumePrint failed: ${resp.statusCode}'); + } + // Some installs return JSON or plain text; ignore body and treat 200 as success + return; + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP resumePrint error', e, st); + rethrow; + } + } + + @override + @override + Future startPrint(String location, String filePath) async { + try { + // Resolve the plate ID. `filePath` may be a path or already a plateId. + String? plateId; + try { + final plate = await _findPlateForPath(filePath); + if (plate != null && plate.plateId != null) { + plateId = plate.plateId!.toString(); + } + } catch (_) { + // ignore and try treating filePath as an ID + } + + final idToUse = plateId ?? filePath; + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/printer/start/$idToUse'); + _log.info('NanoDLP startPrint request: $uri'); + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP startPrint failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP startPrint failed: ${resp.statusCode}'); + } + // Some installs return JSON or plain text; ignore body and treat 200 as success + // Kick off a background plates prefetch so the server-side plate list + // is refreshed and thumbnails / file metadata become available faster + // for the UI immediately after a start request. + Future(() async { + try { + final plates = await _fetchPlates(forceRefresh: true); + // If we already resolved a plate id for this path, prefer that. + if (plateId != null) { + final found = plates.firstWhere( + (p) => p.plateId != null && p.plateId!.toString() == plateId, + orElse: () => const NanoFile()); + if (found.plateId != null) { + _resolvedPlateId = found.plateId; + _resolvedPlateFile = found; + _resolvedPlateTime = DateTime.now(); + _log.fine( + 'Prefetched and resolved PlateID $plateId -> ${found.name}'); + } + } else { + // Try to match by path/name in case caller passed a path. + final normalized = filePath.replaceAll(RegExp(r'^/+'), ''); + final found = plates.firstWhere( + (p) => + _matchesPath(p.resolvedPath, normalized) || + (p.name != null && _matchesPath(p.name, normalized)), + orElse: () => const NanoFile()); + if (found.plateId != null) { + _resolvedPlateId = found.plateId; + _resolvedPlateFile = found; + _resolvedPlateTime = DateTime.now(); + _log.fine( + 'Prefetched and resolved path $filePath -> ${found.name}'); + } + } + } catch (e, st) { + _log.fine('Prefetch plates after startPrint failed', e, st); + } + }); + + return; + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP startPrint error', e, st); + rethrow; + } + } + + @override + Future displayTest(String test) async => + throw UnimplementedError('NanoDLP displayTest not implemented'); + + bool _matchesPath(String? lhs, String rhs) { + if (lhs == null) return false; + return lhs.trim().toLowerCase() == rhs.trim().toLowerCase(); + } + + String _thumbnailCacheKey(String identifier, int width, int height) => + '$identifier|$width|$height'; + + String _thumbnailCacheKeyForPlate( + NanoFile plate, String fallbackPath, int width, int height) { + final id = plate.plateId; + final resolvedPath = plate.resolvedPath.isNotEmpty + ? plate.resolvedPath.toLowerCase() + : fallbackPath; + final identifier = id != null ? 'plate:$id' : 'path:$resolvedPath'; + return _thumbnailCacheKey(identifier, width, height); + } + + Uint8List? _getCachedThumbnail(String cacheKey) { + final entry = _thumbnailCache[cacheKey]; + if (entry == null) return null; + final ttl = + entry.placeholder ? _thumbnailPlaceholderCacheTtl : _thumbnailCacheTtl; + if (DateTime.now().difference(entry.timestamp) >= ttl) { + _thumbnailCache.remove(cacheKey); + return null; + } + return entry.bytes; + } + + void _storeThumbnail(String cacheKey, Uint8List bytes, + {required bool placeholder}) { + _storeThumbnailEntry( + cacheKey, _ThumbnailCacheEntry(bytes, DateTime.now(), placeholder)); + } + + void _storeThumbnailEntry(String cacheKey, _ThumbnailCacheEntry entry) { + _thumbnailCache[cacheKey] = entry; + } + + Future<_ThumbnailCacheEntry> _downloadThumbnail( + Uri uri, String debugLabel) async { + final client = _clientFactory(); + try { + final resp = await client.get(uri); + if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { + // Return raw bytes from server. Decoding/resizing will be done by the + // caller in a background isolate (e.g. via compute) to avoid jank. + return _ThumbnailCacheEntry(resp.bodyBytes, DateTime.now(), false); + } + _log.fine( + 'NanoDLP preview request returned ${resp.statusCode} for $debugLabel; returning empty bytes to let caller generate placeholder.'); + return _ThumbnailCacheEntry(Uint8List(0), DateTime.now(), true); + } catch (e, st) { + _log.warning('NanoDLP preview request error for $debugLabel', e, st); + return _ThumbnailCacheEntry(Uint8List(0), DateTime.now(), true); + } finally { + client.close(); + } + } + + Future> _fetchPlates({bool forceRefresh = false}) { + if (!forceRefresh) { + final cached = _platesCacheData; + final cachedTime = _platesCacheTime; + if (cached != null && + cachedTime != null && + DateTime.now().difference(cachedTime) < _platesCacheTtl) { + return Future.value(cached); + } + final inflight = _platesCacheFuture; + if (inflight != null) { + return inflight; + } + } else { + _platesCacheData = null; + _platesCacheTime = null; + } + + final future = _loadPlatesFromNetwork(); + _platesCacheFuture = future; + return future.then((plates) { + if (identical(_platesCacheFuture, future)) { + _platesCacheFuture = null; + _platesCacheData = plates; + _platesCacheTime = DateTime.now(); + } + return plates; + }).catchError((error, stack) { + if (identical(_platesCacheFuture, future)) { + _platesCacheFuture = null; + } + throw error; + }); + } + + Future> _loadPlatesFromNetwork() async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/plates/list/json'); + _log.fine('Requesting NanoDLP plates list (no query params): $uri'); + + final client = _clientFactory(); + try { + http.Response resp; + try { + resp = await client.get(uri); + } catch (e, st) { + _log.warning('NanoDLP plates list request failed', e, st); + return const []; + } + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP plates list call failed: ${resp.statusCode} ${resp.body}'); + return const []; + } + + dynamic decoded; + try { + decoded = json.decode(resp.body); + } catch (e) { + _log.warning('Failed to decode plates/list/json response', e); + return const []; + } + + final rawEntries = _extractPlateEntries(decoded); + final plates = []; + for (final entry in rawEntries) { + if (entry == null) continue; + if (entry is Map) { + try { + plates.add(NanoFile.fromJson(entry)); + } catch (e, st) { + _log.warning('Failed to parse NanoDLP plate entry', e, st); + } + continue; + } + if (entry is Map) { + try { + plates.add(NanoFile.fromJson(Map.from(entry))); + } catch (e, st) { + _log.warning( + 'Failed to parse NanoDLP plate entry (Map cast)', e, st); + } + } + } + return plates; + } finally { + client.close(); + } + } + + List _extractPlateEntries(dynamic decoded) { + if (decoded is List) return decoded; + if (decoded is Map) { + for (final key in ['plates', 'files', 'data']) { + final value = decoded[key]; + if (value is List) return value; + } + final values = decoded.values.whereType().toList(); + if (values.isNotEmpty) return values; + return [decoded]; + } + _log.fine( + 'plates/list/json returned unexpected type: ${decoded.runtimeType}'); + return const []; + } + + Future _findPlateForPath(String filePath) async { + final normalized = filePath.replaceAll(RegExp(r'^/+'), ''); + final plates = await _fetchPlates(); + for (final plate in plates) { + final platePath = plate.resolvedPath; + final plateName = plate.name ?? ''; + if (_matchesPath(platePath, filePath) || + _matchesPath(platePath, normalized) || + _matchesPath('/$platePath', filePath) || + (plateName.isNotEmpty && + (_matchesPath(plateName, filePath) || + _matchesPath(plateName, normalized)))) { + return plate; + } + } + return null; + } + + (int, int) _thumbnailDimensions(String size) { + switch (size) { + case 'Large': + return (800, 480); + case 'Small': + default: + return (400, 400); + } + } +} + +class _ThumbnailCacheEntry { + _ThumbnailCacheEntry(this.bytes, this.timestamp, this.placeholder); + + final Uint8List bytes; + final DateTime timestamp; + final bool placeholder; +} diff --git a/lib/backend_service/nanodlp/nanodlp_mappers.dart b/lib/backend_service/nanodlp/nanodlp_mappers.dart new file mode 100644 index 0000000..3785cf4 --- /dev/null +++ b/lib/backend_service/nanodlp/nanodlp_mappers.dart @@ -0,0 +1,66 @@ +import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; + +/// Map NanoDLP DTOs into Odyssey-shaped maps expected by StatusModel.fromJson +Map nanoStatusToOdysseyMap(NanoStatus ns) { + // Map NanoDLP-like state strings to Odyssey status strings. + // If the device reports `paused` explicitly prefer the paused label so + // the UI can reflect a paused state even when printing flag is present. + String status; + if (ns.paused == true) { + status = 'Paused'; + } else { + switch (ns.state.toLowerCase()) { + case 'printing': + case 'print': + status = 'Printing'; + break; + case 'idle': + default: + status = 'Idle'; + } + } + + final file = ns.file; + Map? printData; + if (file != null) { + printData = { + // Prefer explicit layer_count from the file metadata; otherwise + // fall back to the LayersCount reported in the status payload. + 'layer_count': file.layerCount ?? ns.layersCount ?? 0, + 'used_material': (file.usedMaterial ?? 0.0), + 'print_time': file.printTime ?? 0, + 'file_data': { + 'name': file.name ?? (file.path ?? ''), + 'path': file.path ?? (file.name ?? ''), + 'location_category': 'Local' + } + }; + } else if (ns.printing || + ns.paused || + ns.layerId != null || + ns.layersCount != null) { + // Backend reports an active job but did not include file metadata yet. + // Return a minimal PrintData object so the UI can render the status + // screen (avoids showing "No Print Data Available" while job starts). + printData = { + 'layer_count': ns.layersCount ?? 0, + 'used_material': 0.0, + 'print_time': 0, + 'file_data': null, + }; + } + + return { + 'status': status, + // Preserve the explicit paused boolean from the NanoStatus so the + // StatusModel can determine paused vs printing correctly. + 'paused': ns.paused == true, + 'layer': ns.layerId, + 'print_data': printData, + // Include the raw device 'Status' message (if present) so UI layers + // can surface device-provided status text as an override for titles + // or dialogs when appropriate. + 'device_status_message': ns.statusMessage, + 'physical_state': {'z': ns.z ?? 0.0, 'curing': ns.curing} + }; +} diff --git a/lib/backend_service/nanodlp/nanodlp_thumbnail_generator.dart b/lib/backend_service/nanodlp/nanodlp_thumbnail_generator.dart new file mode 100644 index 0000000..a687f69 --- /dev/null +++ b/lib/backend_service/nanodlp/nanodlp_thumbnail_generator.dart @@ -0,0 +1,78 @@ +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +class NanoDlpThumbnailGenerator { + const NanoDlpThumbnailGenerator._(); + + // Canonical large thumbnail size used in DetailsScreen. + static const int largeWidth = 800; + static const int largeHeight = 480; + + static Uint8List generatePlaceholder(int width, int height) { + final image = img.Image(width: width, height: height); + final background = img.ColorRgb8(32, 36, 43); + final accent = img.ColorRgb8(63, 74, 88); + final highlight = img.ColorRgb8(90, 104, 122); + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final band = ((x ~/ 16) + (y ~/ 16)) % 3; + switch (band) { + case 0: + image.setPixel(x, y, background); + break; + case 1: + image.setPixel(x, y, accent); + break; + default: + image.setPixel(x, y, highlight); + } + } + } + + for (var x = width ~/ 4; x < (width * 3) ~/ 4; x++) { + final y1 = height ~/ 4; + final y2 = (height * 3) ~/ 4; + image.setPixel(x, y1, highlight); + image.setPixel(x, y2, highlight); + } + + for (var y = height ~/ 4; y < (height * 3) ~/ 4; y++) { + final x1 = width ~/ 4; + final x2 = (width * 3) ~/ 4; + image.setPixel(x1, y, highlight); + image.setPixel(x2, y, highlight); + } + + return Uint8List.fromList(img.encodePng(image)); + } + + static Uint8List resizeOrPlaceholder( + Uint8List? sourceBytes, int width, int height) { + if (sourceBytes != null && sourceBytes.isNotEmpty) { + try { + final decoded = img.decodeImage(sourceBytes); + if (decoded != null) { + img.Image resized; + if (decoded.width == width && decoded.height == height) { + resized = decoded; + } else { + resized = img.copyResize(decoded, + width: width, + height: height, + interpolation: img.Interpolation.cubic); + } + return Uint8List.fromList(img.encodePng(resized)); + } + } catch (_) { + // fall back to placeholder below + } + } + return generatePlaceholder(width, height); + } + + /// Convenience helper to force the canonical NanoDLP large size. + static Uint8List resizeToLarge(Uint8List? sourceBytes) => + resizeOrPlaceholder(sourceBytes, largeWidth, largeHeight); +} From 02daccb814e24b1c73ba39e0b7954a117b728f57 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 01:59:22 +0200 Subject: [PATCH 11/47] refactor(util): ThumbnailUtil: Reintroduce NanoDLP dependency and update thumbnail generation logic --- lib/util/sl1_thumbnail.dart | 53 +++++++------------------------------ 1 file changed, 9 insertions(+), 44 deletions(-) diff --git a/lib/util/sl1_thumbnail.dart b/lib/util/sl1_thumbnail.dart index 1c7b734..a28df41 100644 --- a/lib/util/sl1_thumbnail.dart +++ b/lib/util/sl1_thumbnail.dart @@ -21,14 +21,9 @@ import 'package:orion/backend_service/backend_service.dart'; import 'package:orion/backend_service/odyssey/odyssey_client.dart'; import 'package:flutter/foundation.dart'; import 'dart:typed_data'; -// Temporarily remove NanoDLP dependency so this file can be included -// in an Odyssey-only refactor PR. Replacement fallbacks are provided -// below (small embedded placeholder and simple pass-through resize) -// to avoid importing NanoDLP-specific helpers here. -// import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; -import 'dart:convert'; class ThumbnailUtil { static final _logger = Logger('ThumbnailUtil'); @@ -156,33 +151,27 @@ class ThumbnailUtil { final bytes = await odysseyClient.getFileThumbnail(location, finalLocation, size); - // Use conservative defaults for sizes. NanoDLP-specific canonical - // sizes were removed from this file to keep NanoDLP out of the - // Odyssey-only refactor; we use reasonable defaults here. int width = 400, height = 400; if (size == 'Large') { - width = _largeWidth; - height = _largeHeight; + width = NanoDlpThumbnailGenerator.largeWidth; + height = NanoDlpThumbnailGenerator.largeHeight; } - // Use compute to run the resize on a background isolate. The - // isolate implementation below is intentionally minimal: if the - // backend-provided bytes exist we pass them through unchanged; - // otherwise we return a tiny embedded placeholder PNG. This keeps - // this file free of NanoDLP helpers while preserving safe behavior. + // Use compute to run the resize on a background isolate. final resized = await compute(_resizeBytesEntry, { 'bytes': bytes, 'width': width, 'height': height, }); + // Return resized bytes; callers may optionally write to disk. return resized as Uint8List; } catch (e) { _logger.warning('Failed to fetch/resize thumbnail bytes', e); } - // Fallback: return a tiny embedded placeholder PNG. - return _placeholderBytes(); + // Fallback: return a generated placeholder using the canonical small size. + return NanoDlpThumbnailGenerator.generatePlaceholder(400, 400); } } @@ -200,32 +189,8 @@ dynamic _resizeBytesEntry(Map msg) { final bytes = msg['bytes'] as Uint8List; width = msg['width'] as int? ?? width; height = msg['height'] as int? ?? height; - // For the Odyssey-only refactor we avoid calling into NanoDLP - // helpers. If bytes are present, return them unchanged (no-op - // resize). If bytes are missing or invalid, return the embedded - // placeholder. - return _resizeOrPlaceholder(bytes, width, height); + return NanoDlpThumbnailGenerator.resizeOrPlaceholder(bytes, width, height); } catch (_) { - return _placeholderBytes(); + return NanoDlpThumbnailGenerator.generatePlaceholder(width, height); } } - -// Local canonical large size used while NanoDLP helpers are excluded. -const int _largeWidth = 800; -const int _largeHeight = 480; - -// A minimal 1x1 PNG (base64) used as a safe fallback placeholder. -Uint8List _placeholderBytes() { - // 1x1 transparent PNG - const String b64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII='; - return base64.decode(b64); -} - -// Minimal resize-or-placeholder: we do not perform actual image -// manipulation here to avoid importing the image package. If bytes -// are present, return them as-is; otherwise return the placeholder. -Uint8List _resizeOrPlaceholder(Uint8List? bytes, int width, int height) { - if (bytes == null || bytes.isEmpty) return _placeholderBytes(); - return bytes; -} From a5bc46e2f9bd2c487703c39397697dcc241c3fd7 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 01:59:47 +0200 Subject: [PATCH 12/47] test(nanodlp): Add unit tests for NanoFile, NanoDlpHttpClient, mappers, and thumbnail generator --- test/nano_file_test.dart | 61 +++++++++++ test/nanodlp/nanodlp_http_client_test.dart | 122 +++++++++++++++++++++ test/nanodlp/nanodlp_mappers_test.dart | 38 +++++++ test/nanodlp_status_mapping_test.dart | 28 +++++ test/nanodlp_thumbnail_generator_test.dart | 47 ++++++++ 5 files changed, 296 insertions(+) create mode 100644 test/nano_file_test.dart create mode 100644 test/nanodlp/nanodlp_http_client_test.dart create mode 100644 test/nanodlp/nanodlp_mappers_test.dart create mode 100644 test/nanodlp_status_mapping_test.dart create mode 100644 test/nanodlp_thumbnail_generator_test.dart diff --git a/test/nano_file_test.dart b/test/nano_file_test.dart new file mode 100644 index 0000000..2e83b79 --- /dev/null +++ b/test/nano_file_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_file.dart'; + +void main() { + group('NanoFile.fromJson', () { + test('parses print time, layer height, and odyssey mapping', () { + final file = NanoFile.fromJson({ + 'Path': 'plates/example.ctb', + 'LayerCount': '125', + 'PrintTime': '~01:02:03', + 'LastModified': '1690000000', + 'LayerThickness': '50.00µ', + 'Size': '123456', + 'used_material': '12.34', + 'Preview': true, + 'PlateID': '42', + }); + + expect(file.resolvedPath, 'plates/example.ctb'); + expect(file.name, 'example.ctb'); + expect(file.layerCount, 125); + expect(file.printTime, closeTo(3723, 0.001)); + expect(file.layerHeight, closeTo(0.05, 1e-6)); + expect(file.previewAvailable, isTrue); + expect(file.plateId, 42); + + final entry = file.toOdysseyFileEntry(); + expect(entry['file_data'], { + 'path': 'plates/example.ctb', + 'name': 'example.ctb', + 'last_modified': 1690000000, + 'parent_path': 'plates', + 'file_size': 123456, + }); + expect(entry['print_time'], closeTo(3723, 0.001)); + expect(entry['layer_count'], 125); + expect(entry['layer_height'], closeTo(0.05, 1e-6)); + expect(entry['used_material'], closeTo(12.34, 1e-6)); + expect(entry['preview_available'], isTrue); + expect(entry['plate_id'], 42); + + final meta = file.toOdysseyMetadata(); + expect(meta['plate_id'], 42); + expect(meta['preview_available'], isTrue); + }); + + test('derives defaults when name missing', () { + final file = NanoFile.fromJson({ + 'path': 'just-a-plate.cbddlp', + }); + + expect(file.name, 'just-a-plate.cbddlp'); + expect(file.resolvedPath, 'just-a-plate.cbddlp'); + expect(file.parentPath, isEmpty); + + final entry = file.toOdysseyFileEntry(); + expect(entry['file_data']['name'], 'just-a-plate.cbddlp'); + expect(entry['file_data']['path'], 'just-a-plate.cbddlp'); + }); + }); +} diff --git a/test/nanodlp/nanodlp_http_client_test.dart b/test/nanodlp/nanodlp_http_client_test.dart new file mode 100644 index 0000000..4be38c5 --- /dev/null +++ b/test/nanodlp/nanodlp_http_client_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:image/image.dart' as img; +import 'package:orion/backend_service/nanodlp/nanodlp_http_client.dart'; + +void main() { + group('NanoDlpHttpClient caching', () { + test('reuses thumbnail bytes within cache TTL', () async { + var plateRequests = 0; + var thumbnailRequests = 0; + + final sampleImage = img.Image(width: 64, height: 64); + img.fill(sampleImage, color: img.ColorRgb8(10, 20, 30)); + final sampleBytes = Uint8List.fromList(img.encodePng(sampleImage)); + + http.Client mockFactory() => MockClient((request) async { + if (request.url.path.endsWith('/plates/list/json')) { + plateRequests++; + return http.Response( + json.encode([ + { + 'PlateID': 123, + 'path': 'plates/test_plate.cws', + 'Preview': true, + } + ]), + 200, + headers: {'content-type': 'application/json'}); + } + if (request.url.path.endsWith('/static/plates/123/3d.png')) { + thumbnailRequests++; + return http.Response.bytes(sampleBytes, 200, + headers: {'content-type': 'image/png'}); + } + return http.Response('not found', 404); + }); + + final client = NanoDlpHttpClient(clientFactory: mockFactory); + + final first = await client.getFileThumbnail( + 'local', 'plates/test_plate.cws', 'Small'); + final second = await client.getFileThumbnail( + 'local', 'plates/test_plate.cws', 'Small'); + + expect(plateRequests, 1); + expect(thumbnailRequests, 1); + expect(second, equals(first)); + }); + + test('caches placeholder after failed preview fetch for short period', + () async { + var plateRequests = 0; + var thumbnailRequests = 0; + + http.Client mockFactory() => MockClient((request) async { + if (request.url.path.endsWith('/plates/list/json')) { + plateRequests++; + return http.Response( + json.encode([ + { + 'PlateID': 456, + 'path': 'plates/failed_plate.cws', + 'Preview': true, + } + ]), + 200, + headers: {'content-type': 'application/json'}); + } + if (request.url.path.endsWith('/static/plates/456/3d.png')) { + thumbnailRequests++; + return http.Response('error', 500); + } + return http.Response('not found', 404); + }); + + final client = NanoDlpHttpClient(clientFactory: mockFactory); + + final first = await client.getFileThumbnail( + 'local', 'plates/failed_plate.cws', 'Small'); + final second = await client.getFileThumbnail( + 'local', 'plates/failed_plate.cws', 'Small'); + + expect(plateRequests, 1); + expect(thumbnailRequests, 1); + expect(second, equals(first)); + }); + + test('caches plate list responses within TTL window', () async { + var plateRequests = 0; + + http.Client mockFactory() => MockClient((request) async { + if (request.url.path.endsWith('/plates/list/json')) { + plateRequests++; + return http.Response( + json.encode([ + { + 'PlateID': 789, + 'path': 'plates/cache_test.cws', + 'Preview': false, + } + ]), + 200, + headers: {'content-type': 'application/json'}); + } + return http.Response('not found', 404); + }); + + final client = NanoDlpHttpClient(clientFactory: mockFactory); + + final first = await client.listItems('local', 20, 0, '/'); + final second = await client.listItems('local', 20, 0, '/'); + + expect(first['files'], isNotEmpty); + expect(second['files'], isNotEmpty); + expect(plateRequests, 1); + }); + }); +} diff --git a/test/nanodlp/nanodlp_mappers_test.dart b/test/nanodlp/nanodlp_mappers_test.dart new file mode 100644 index 0000000..0c17962 --- /dev/null +++ b/test/nanodlp/nanodlp_mappers_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_file.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; + +void main() { + test('nanoStatusToOdysseyMap maps printing status correctly', () { + final file = NanoFile( + path: '/files/model.stl', + name: 'model.stl', + layerCount: 120, + printTime: 3600); + final ns = NanoStatus( + printing: true, + paused: false, + state: 'printing', + progress: 0.5, + file: file, + z: 12.34, + curing: true); + + final mapped = nanoStatusToOdysseyMap(ns); + + expect(mapped['status'], equals('Printing')); + expect(mapped['paused'], equals(false)); + expect(mapped['physical_state'], isA>()); + final phys = mapped['physical_state'] as Map; + expect(phys['z'], equals(12.34)); + expect(phys['curing'], equals(true)); + + final pd = mapped['print_data'] as Map?; + expect(pd, isNotNull); + expect(pd!['layer_count'], equals(120)); + final fileData = pd['file_data'] as Map; + expect(fileData['name'], equals('model.stl')); + expect(fileData['path'], equals('/files/model.stl')); + }); +} diff --git a/test/nanodlp_status_mapping_test.dart b/test/nanodlp_status_mapping_test.dart new file mode 100644 index 0000000..f66dfd1 --- /dev/null +++ b/test/nanodlp_status_mapping_test.dart @@ -0,0 +1,28 @@ +import 'package:test/test.dart'; +import 'dart:convert'; + +import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; +import 'package:orion/backend_service/odyssey/models/status_models.dart'; + +void main() { + test('nano status mapping includes print_data when printing without file', + () { + final raw = { + 'Printing': true, + 'LayerID': 5, + 'LayersCount': 100, + // No 'file' key present to simulate backend delay + 'CurrentHeight': 150000 + }; + + final ns = NanoStatus.fromJson(raw); + final mapped = nanoStatusToOdysseyMap(ns); + final statusModel = StatusModel.fromJson(mapped); + + expect(statusModel.isPrinting, isTrue); + // We expect printData to be non-null because mapper supplies minimal + // print_data when backend reports printing but lacks file metadata. + expect(statusModel.printData, isNotNull); + }); +} diff --git a/test/nanodlp_thumbnail_generator_test.dart b/test/nanodlp_thumbnail_generator_test.dart new file mode 100644 index 0000000..383964a --- /dev/null +++ b/test/nanodlp_thumbnail_generator_test.dart @@ -0,0 +1,47 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; + +void main() { + group('NanoDlpThumbnailGenerator', () { + test('generates 400x400 placeholder', () { + final bytes = NanoDlpThumbnailGenerator.generatePlaceholder(400, 400); + final decoded = img.decodePng(bytes); + expect(decoded, isNotNull); + expect(decoded!.width, 400); + expect(decoded.height, 400); + }); + + test('generates 800x480 placeholder', () { + final bytes = NanoDlpThumbnailGenerator.generatePlaceholder(800, 480); + final decoded = img.decodePng(bytes); + expect(decoded, isNotNull); + expect(decoded!.width, 800); + expect(decoded.height, 480); + }); + + test('resizes source image to requested dimensions', () { + final original = img.Image(width: 200, height: 100); + img.fill(original, color: img.ColorRgb8(255, 0, 0)); + final originalBytes = Uint8List.fromList(img.encodePng(original)); + + final resized = NanoDlpThumbnailGenerator.resizeOrPlaceholder( + originalBytes, 400, 400); + final decoded = img.decodePng(resized); + expect(decoded, isNotNull); + expect(decoded!.width, 400); + expect(decoded.height, 400); + }); + + test('falls back to placeholder when decode fails', () { + final resized = NanoDlpThumbnailGenerator.resizeOrPlaceholder( + Uint8List.fromList([0, 1, 2, 3]), 400, 400); + final decoded = img.decodePng(resized); + expect(decoded, isNotNull); + expect(decoded!.width, 400); + expect(decoded.height, 400); + }); + }); +} From 08eacdcec695b43f169e62bc5768701c65325051 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sat, 4 Oct 2025 01:59:55 +0200 Subject: [PATCH 13/47] feat(fake-client): Add moveDelta and canMoveToTop methods to FakeOdysseyClient and FakeOdysseyClientForThumbnailTest --- test/fakes/fake_odyssey_client.dart | 20 +++++++++++++++++++ ...ake_odyssey_client_for_thumbnail_test.dart | 15 ++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/test/fakes/fake_odyssey_client.dart b/test/fakes/fake_odyssey_client.dart index fdb9707..765fd1c 100644 --- a/test/fakes/fake_odyssey_client.dart +++ b/test/fakes/fake_odyssey_client.dart @@ -88,4 +88,24 @@ class FakeOdysseyClient implements OdysseyClient { Future displayTest(String test) async { displayTestCalled = true; } + + @override + Future> moveDelta(double deltaMm) async { + // Minimal fake implementation: record as a move and return empty map. + moveCalled = true; + lastMoveHeight = deltaMm; + return {}; + } + + @override + Future canMoveToTop() async { + // Default fake: not supported + return false; + } + + @override + Future> moveToTop() async { + // No-op fake implementation + return {}; + } } diff --git a/test/fakes/fake_odyssey_client_for_thumbnail_test.dart b/test/fakes/fake_odyssey_client_for_thumbnail_test.dart index 2ed0a60..7b515c9 100644 --- a/test/fakes/fake_odyssey_client_for_thumbnail_test.dart +++ b/test/fakes/fake_odyssey_client_for_thumbnail_test.dart @@ -95,5 +95,20 @@ class FakeOdysseyClientForThumbnailTest implements OdysseyClient { throw UnimplementedError(); } + @override + Future> moveDelta(double deltaMm) { + throw UnimplementedError(); + } + + @override + Future canMoveToTop() { + return Future.value(false); + } + + @override + Future> moveToTop() { + throw UnimplementedError(); + } + void main() {} } From 7fad14bf035f8314ba5f3c61d3f04981589467cb Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:29:59 +0200 Subject: [PATCH 14/47] feat(util): Implement ThumbnailCache for efficient thumbnail management and prefetching --- lib/files/details_screen.dart | 71 +++-- lib/files/grid_files_screen.dart | 100 ++++--- lib/util/thumbnail_cache.dart | 448 +++++++++++++++++++++++++++++++ 3 files changed, 563 insertions(+), 56 deletions(-) create mode 100644 lib/util/thumbnail_cache.dart diff --git a/lib/files/details_screen.dart b/lib/files/details_screen.dart index 3004b85..662db4d 100644 --- a/lib/files/details_screen.dart +++ b/lib/files/details_screen.dart @@ -15,8 +15,6 @@ * limitations under the License. */ -import 'dart:io'; - import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -29,7 +27,9 @@ import 'package:orion/backend_service/odyssey/models/files_models.dart'; import 'package:orion/glasser/glasser.dart'; import 'package:orion/status/status_screen.dart'; -import 'package:orion/util/sl1_thumbnail.dart'; +import 'dart:typed_data'; +import 'package:orion/util/thumbnail_cache.dart'; +import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; import 'package:orion/util/providers/theme_provider.dart'; class DetailScreen extends StatefulWidget { @@ -59,7 +59,7 @@ class DetailScreenState extends State { int maxNameLength = 0; bool loading = true; // Add loading state FileMetadata? _meta; - Future? _thumbnailFuture; + Future? _thumbnailFuture; bool _isThumbnailLoading = false; @override @@ -93,10 +93,17 @@ class DetailScreenState extends State { // Kick off thumbnail extraction but render metadata directly from the // typed model in build(). This mirrors the approach used in StatusScreen // where presentation derives values directly from the provider model. - final thumbFuture = ThumbnailUtil.extractThumbnail( - widget.fileLocation, - widget.fileSubdirectory, - widget.fileName, + final thumbFuture = ThumbnailCache.instance.getThumbnail( + location: widget.fileLocation, + subdirectory: widget.fileSubdirectory, + fileName: widget.fileName, + file: OrionApiFile( + path: widget.fileSubdirectory == '' + ? widget.fileName + : '${widget.fileSubdirectory}/${widget.fileName}', + name: widget.fileName, + parentPath: widget.fileSubdirectory, + ), size: 'Large', ); @@ -136,7 +143,7 @@ class DetailScreenState extends State { return GlassApp( child: Scaffold( appBar: AppBar( - title: Text(_meta?.fileData.name ?? widget.fileName), + title: Text('Print Details'), centerTitle: true, ), body: Center( @@ -194,13 +201,10 @@ class DetailScreenState extends State { ), Expanded( child: buildInfoCard( - 'Material & Volume', - _meta?.usedMaterial != null - ? _meta?.materialName != null - ? '${_meta?.materialName} - ${_meta!.usedMaterial!.toStringAsFixed(2)} mL' - : 'N/A - ${_meta!.usedMaterial!.toStringAsFixed(2)} mL' - : 'N/A - -', - ), + 'Estimated Print Volume', + _meta?.usedMaterial != null + ? '${_meta!.usedMaterial!.toStringAsFixed(2)} mL' + : 'N/A'), ), ], ), @@ -261,13 +265,10 @@ class DetailScreenState extends State { ? '${_meta!.layerHeight!.toStringAsFixed(3)} mm' : '-'), buildInfoCard( - 'Material & Volume', - _meta?.usedMaterial != null - ? _meta?.materialName != null - ? '${_meta?.materialName} - ${_meta!.usedMaterial!.toStringAsFixed(2)} mL' - : 'N/A - ${_meta!.usedMaterial!.toStringAsFixed(2)} mL' - : 'N/A - -', - ), + 'Estimated Print Volume', + _meta?.usedMaterial != null + ? '${_meta!.usedMaterial!.toStringAsFixed(2)} mL' + : 'N/A'), buildInfoCard( 'Print Time', _meta?.printTime != null @@ -373,7 +374,7 @@ class DetailScreenState extends State { Widget buildThumbnailView(BuildContext context) { final Widget imageWidget = _thumbnailFuture == null ? const Center(child: CircularProgressIndicator()) - : FutureBuilder( + : FutureBuilder( future: _thumbnailFuture, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { @@ -382,7 +383,7 @@ class DetailScreenState extends State { if (snap.hasError || snap.data == null || snap.data!.isEmpty) { return const Center(child: Icon(Icons.broken_image)); } - return Image.file(File(snap.data!)); + return Image.memory(snap.data!, fit: BoxFit.contain); }, ); @@ -488,14 +489,32 @@ class DetailScreenState extends State { DetailScreen._isDefaultDir(widget.fileSubdirectory) ? widget.fileName : path.join(widget.fileSubdirectory, widget.fileName); + + // Attempt to obtain the already-fetched Large thumbnail bytes + // so StatusScreen can render immediately. This is best-effort + // and will not block the startPrint call for long. + Uint8List? thumbBytes; + try { + if (_thumbnailFuture != null) { + thumbBytes = await _thumbnailFuture!.timeout( + const Duration(milliseconds: 500), + onTimeout: () => null, + ); + } + } catch (_) { + thumbBytes = null; + } + final ok = await provider.startPrint(widget.fileLocation, filePath); if (ok) { Navigator.push( context, MaterialPageRoute( - builder: (context) => const StatusScreen( + builder: (context) => StatusScreen( newPrint: true, + initialThumbnailBytes: thumbBytes, + initialFilePath: filePath, ), )); } else { diff --git a/lib/files/grid_files_screen.dart b/lib/files/grid_files_screen.dart index 880201a..5707e25 100644 --- a/lib/files/grid_files_screen.dart +++ b/lib/files/grid_files_screen.dart @@ -39,7 +39,7 @@ import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; import 'package:orion/util/orion_api_filesystem/orion_api_item.dart'; import 'package:orion/util/orion_config.dart'; import 'package:orion/util/providers/theme_provider.dart'; -import 'package:orion/util/sl1_thumbnail.dart'; +import 'package:orion/util/thumbnail_cache.dart'; import 'dart:typed_data'; ScrollController _scrollController = ScrollController(); @@ -200,6 +200,20 @@ class GridFilesScreenState extends State { return "$directory ${_isUSB ? '(USB)' : '(Internal)'}"; } + String _resolveSubdirectoryForFile(OrionApiFile file) { + if (_defaultDirectory.isEmpty) return _subdirectory; + try { + final parentDir = path.dirname(file.path); + final relative = path.relative(parentDir, from: _defaultDirectory); + if (relative == '.' || relative == _defaultDirectory) { + return ''; + } + return relative; + } catch (_) { + return _subdirectory; + } + } + @override Widget build(BuildContext context) { return GlassApp( @@ -262,10 +276,10 @@ class GridFilesScreenState extends State { return _buildParentCard(context); } final OrionApiItem item = itemsList[index - 1]; - return _buildItemCard(context, item); + return _buildItemCard(context, item, provider); } else { final OrionApiItem item = itemsList[index]; - return _buildItemCard(context, item); + return _buildItemCard(context, item, provider); } }, ), @@ -289,12 +303,15 @@ class GridFilesScreenState extends State { final provider = Provider.of(context, listen: false); final newLocation = _isUSB ? 'Usb' : 'Local'; - final subdir = _defaultDirectory.isEmpty + final rawSubdir = _defaultDirectory.isEmpty ? '' : path.relative(_directory, from: _defaultDirectory); + final subdir = rawSubdir == '.' ? '' : rawSubdir; await provider.loadItems(newLocation, subdir); await _syncAfterLoad(provider, newLocation); - setState(() {}); + setState(() { + _subdirectory = subdir; + }); } : () async { try { @@ -306,15 +323,17 @@ class GridFilesScreenState extends State { }); final provider = Provider.of(context, listen: false); - final subdir = parentDirectory == _defaultDirectory + final rawSubdir = parentDirectory == _defaultDirectory ? '' : path.relative(parentDirectory, from: _defaultDirectory); + final subdir = rawSubdir == '.' ? '' : rawSubdir; await provider.loadItems( _isUSB ? 'Usb' : 'Local', subdir); await _syncAfterLoad(provider, _isUSB ? 'Usb' : 'Local'); setState(() { _isNavigating = false; + _subdirectory = subdir; }); } catch (e) { _logger.severe( @@ -367,9 +386,15 @@ class GridFilesScreenState extends State { ); } - Widget _buildItemCard(BuildContext context, OrionApiItem item) { + Widget _buildItemCard( + BuildContext context, OrionApiItem item, FilesProvider provider) { final String fileName = path.basename(item.path); final String displayName = fileName; + final OrionApiFile? fileItem = item is OrionApiFile ? item : null; + final bool isFile = fileItem != null; + final String fileSubdirectory = fileItem != null + ? _resolveSubdirectoryForFile(fileItem) + : _subdirectory; final themeProvider = Provider.of(context); return GlassCard( @@ -383,7 +408,6 @@ class GridFilesScreenState extends State { _isNavigating = true; _directory = item.path; }); - final provider = Provider.of(context, listen: false); final subdir = item.path == _defaultDirectory ? '' : path.relative(item.path, from: _defaultDirectory); @@ -393,14 +417,27 @@ class GridFilesScreenState extends State { _isNavigating = false; _subdirectory = subdir; }); - } else if (item is OrionApiFile) { - final provider = Provider.of(context, listen: false); + } else if (fileItem != null) { + // Prefetch large thumbnail for the details screen so it's ready + // (or already in-flight) by the time the DetailScreen mounts. + try { + ThumbnailCache.instance.getThumbnail( + location: provider.location, + subdirectory: fileSubdirectory, + fileName: fileName, + file: fileItem, + size: 'Large', + ); + } catch (_) { + // best-effort; ignore prefetch failures + } + Navigator.push( context, MaterialPageRoute( builder: (context) => DetailScreen( fileName: fileName, - fileSubdirectory: _subdirectory, + fileSubdirectory: fileSubdirectory, fileLocation: provider.location, ), ), @@ -412,7 +449,7 @@ class GridFilesScreenState extends State { child: _isNavigating ? const Center(child: CircularProgressIndicator()) : GridTile( - footer: item is OrionApiFile + footer: isFile ? _buildFileFooter(context, displayName) : _buildDirectoryFooter(context, displayName), child: item is OrionApiDirectory @@ -425,33 +462,36 @@ class GridFilesScreenState extends State { ) : Padding( padding: const EdgeInsets.all(4.5), - child: FutureBuilder( - future: ThumbnailUtil.extractThumbnailBytes( - Provider.of(context, listen: false) - .location, - _subdirectory, - fileName), + child: FutureBuilder( + future: ThumbnailCache.instance.getThumbnail( + location: provider.location, + subdirectory: fileSubdirectory, + fileName: fileName, + file: fileItem!, + ), builder: (BuildContext context, - AsyncSnapshot snapshot) { + AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Padding( padding: EdgeInsets.all(20), child: Center( child: CircularProgressIndicator())); - } else if (snapshot.hasError || - snapshot.data == null || - snapshot.data!.isEmpty) { + } else if (snapshot.hasError) { + return const Icon(Icons.error); + } + + final bytes = snapshot.data; + if (bytes == null || bytes.isEmpty) { return const Icon(Icons.error); - } else { - return ClipRRect( - borderRadius: themeProvider.isGlassTheme - ? BorderRadius.circular(10.5) - : BorderRadius.circular(7.75), - child: Image.memory(snapshot.data!, - fit: BoxFit.cover), - ); } + + return ClipRRect( + borderRadius: themeProvider.isGlassTheme + ? BorderRadius.circular(10.5) + : BorderRadius.circular(7.75), + child: Image.memory(bytes, fit: BoxFit.cover), + ); }, ), ), diff --git a/lib/util/thumbnail_cache.dart b/lib/util/thumbnail_cache.dart new file mode 100644 index 0000000..becf32d --- /dev/null +++ b/lib/util/thumbnail_cache.dart @@ -0,0 +1,448 @@ +/* +* Orion - Thumbnail Cache +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'dart:async'; +import 'dart:collection'; +import 'dart:typed_data'; +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'package:logging/logging.dart'; +import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; +import 'package:orion/util/sl1_thumbnail.dart'; +import 'package:orion/util/orion_config.dart'; + +class ThumbnailCache { + ThumbnailCache._internal(); + + static final ThumbnailCache instance = ThumbnailCache._internal(); + static const Duration _entryTtl = Duration(minutes: 10); + + final _log = Logger('ThumbnailCache'); + final LinkedHashMap _cache = LinkedHashMap(); + final Map> _inFlight = {}; + Directory? _diskCacheDir; + Duration? + _diskEntryTtl; // null = disabled, otherwise time-based expiry for disk entries + bool _pruning = false; + static const int _diskCacheMaxBytes = + 512 * 1024 * 1024; // 512 MiB rolling cache + static const int _memoryCacheMaxBytes = 50 * 1024 * 1024; // 50 MiB + int _memoryCacheBytes = 0; + + Future getThumbnail({ + required String location, + required String subdirectory, + required String fileName, + required OrionApiFile file, + String size = 'Small', + }) async { + _pruneExpired(); + + // Try to find any cached entry for the same path+size regardless of + // lastModified. This avoids cache misses when the provider recreates + // OrionApiFile instances with differing lastModified values while the + // file itself hasn't changed on disk. We'll return the cached bytes + // immediately (if not expired) and schedule a background refresh if + // the reported lastModified differs. + final prefix = '$location|${file.path}|'; + String? foundKey; + for (final k in _cache.keys) { + if (k.startsWith(prefix) && k.endsWith('|$size')) { + foundKey = k; + break; + } + } + if (foundKey != null) { + final existing = _cache.remove(foundKey); + if (existing != null) { + if (!_isExpired(existing.timestamp)) { + // refresh LRU order by reinserting at tail + _cache[foundKey] = existing; + + // If the cached entry's lastModified differs from the current + // file.lastModified, schedule a background refresh so we update + // the cache without delaying the UI. + try { + final parts = foundKey.split('|'); + int existingLm = 0; + if (parts.length >= 3) { + existingLm = int.tryParse(parts[2]) ?? 0; + } + final currentLm = file.lastModified ?? 0; + if (currentLm != 0 && existingLm != currentLm) { + final newKey = _cacheKey(location, file, size); + if (!_inFlight.containsKey(newKey)) { + // start but don't await + _inFlight[newKey] = ThumbnailUtil.extractThumbnailBytes( + location, + subdirectory, + fileName, + size: size, + ).then((bytes) { + _store(newKey, bytes); + _inFlight.remove(newKey); + return bytes; + }).catchError((_, __) { + _inFlight.remove(newKey); + return null; + }); + } + } + } catch (_) { + // ignore parsing errors and continue returning cached bytes + } + + // Intentionally suppress memory-load debug logs to keep runtime logs concise. + + return Future.value(existing.bytes); + } + _inFlight.remove(foundKey); + } + } + + // If nothing found in memory, try disk cache before fetching. + try { + final diskBytes = await _readFromDiskIfFresh(location, file, size); + if (diskBytes != null) { + // store in memory LRU and return + final key = _cacheKey(location, file, size); + _store(key, diskBytes); + return Future.value(diskBytes); + } + } catch (_) { + // ignore disk errors and continue to fetch + } + + final key = _cacheKey(location, file, size); + final inFlight = _inFlight[key]; + if (inFlight != null) return inFlight; + + _evictAlternateVersions(location, file.path, keepKey: key); + + final future = ThumbnailUtil.extractThumbnailBytes( + location, + subdirectory, + fileName, + size: size, + ).then((bytes) { + _store(key, bytes); + _inFlight.remove(key); + return bytes; + }).catchError((Object error, StackTrace stack) { + _log.fine('Thumbnail fetch failed for ${file.path}', error, stack); + _store(key, null); + _inFlight.remove(key); + return null; + }); + + _inFlight[key] = future; + return future; + } + + void clear() { + _cache.clear(); + _inFlight.clear(); + } + + void clearLocation(String location) { + final prefix = '$location|'; + final removeKeys = _cache.keys + .where((key) => key.startsWith(prefix)) + .toList(growable: false); + for (final key in removeKeys) { + _cache.remove(key); + _inFlight.remove(key); + } + } + + String _cacheKey(String location, OrionApiFile file, String size) { + final lastModified = file.lastModified ?? 0; + return '$location|${file.path}|$lastModified|$size'; + } + + void _store(String key, Uint8List? bytes) { + // If replacing an existing entry, adjust tracked memory size. + final prev = _cache.remove(key); + if (prev?.bytes != null) { + _memoryCacheBytes -= prev!.bytes!.length; + } + _cache[key] = _CacheEntry(bytes, DateTime.now()); + if (bytes != null) { + _memoryCacheBytes += bytes.length; + } + + // Evict least-recently-used entries until under memory limit. + while (_memoryCacheBytes > _memoryCacheMaxBytes && _cache.isNotEmpty) { + final oldestKey = _cache.keys.first; + final oldest = _cache.remove(oldestKey); + if (oldest?.bytes != null) { + _memoryCacheBytes -= oldest!.bytes!.length; + } + _inFlight.remove(oldestKey); + // If all entries have null bytes, break to avoid infinite loop. + if (_cache.values.every((e) => e.bytes == null)) break; + } + // Persist to disk asynchronously (best-effort). + if (bytes != null) { + _writeToDiskSafe(key, bytes); + } + } + + void _pruneExpired() { + if (_cache.isEmpty) return; + final now = DateTime.now(); + final expired = _cache.entries + .where((entry) => now.difference(entry.value.timestamp) > _entryTtl) + .map((entry) => entry.key) + .toList(growable: false); + for (final key in expired) { + _cache.remove(key); + _inFlight.remove(key); + } + } + + Future _ensureDiskCacheDir() async { + if (_diskCacheDir != null) return _diskCacheDir!; + try { + // Prefer native per-user cache directories. + Directory dir; + if (Platform.isLinux) { + final xdg = Platform.environment['XDG_CACHE_HOME'] ?? + (Platform.environment['HOME'] != null + ? p.join(Platform.environment['HOME']!, '.cache') + : null); + if (xdg != null && xdg.isNotEmpty) { + dir = Directory(p.join(xdg, 'orion_thumbnail_cache')); + } else { + final tmp = await getTemporaryDirectory(); + dir = Directory(p.join(tmp.path, 'orion_thumbnail_cache')); + } + } else if (Platform.isMacOS) { + final home = Platform.environment['HOME'] ?? '.'; + dir = Directory( + p.join(home, 'Library', 'Caches', 'orion_thumbnail_cache')); + } else if (Platform.isWindows) { + final local = Platform.environment['LOCALAPPDATA'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + dir = Directory(p.join(local, 'orion_thumbnail_cache')); + } else { + // Unknown platform: use temporary directory + final tmp = await getTemporaryDirectory(); + dir = Directory(p.join(tmp.path, 'orion_thumbnail_cache')); + } + + if (!await dir.exists()) await dir.create(recursive: true); + _diskCacheDir = dir; + // Suppress disk cache directory log to avoid noisy startup logs. + // Read TTL from OrionConfig (category: 'cache', key: 'thumbnailDiskTtlDays') + // If the config key is missing or empty, default to 7 days. If the + // config value parses to 0 or a negative number, TTL is disabled. + try { + final cfg = OrionConfig(); + final ttlStr = cfg.getString('thumbnailDiskTtlDays', category: 'cache'); + if (ttlStr.isEmpty) { + _diskEntryTtl = Duration(days: 7); + } else { + final days = int.tryParse(ttlStr); + if (days == null) { + _diskEntryTtl = Duration(days: 7); + } else if (days <= 0) { + _diskEntryTtl = null; // disabled + } else { + _diskEntryTtl = Duration(days: days); + } + } + // TTL configuration read; suppressing output for cleanliness. + } catch (e) { + // Failed to read config; fall back to default silently. + _diskEntryTtl = Duration(days: 7); + } + // Ensure disk size constraints on startup (best-effort). + scheduleMicrotask(() => _enforceDiskCacheSizeLimit()); + return dir; + } catch (e, st) { + _log.warning( + 'Failed to ensure disk cache dir, falling back to system temp: $e', + e, + st); + // fallback to system temp directory + final dir = Directory.systemTemp; + _diskCacheDir = dir; + return dir; + } + } + + String _diskFileNameForKey(String key) { + // Safe filename using URI encoding to avoid illegal chars. + return Uri.encodeComponent(key); + } + + Future _writeToDiskSafe(String key, Uint8List bytes) async { + try { + final dir = await _ensureDiskCacheDir(); + final fname = _diskFileNameForKey(key); + final file = File(p.join(dir.path, fname)); + // Write atomically by writing to a temp file and renaming. + final tmpFile = File(p.join(dir.path, '\$${fname}.tmp')); + await tmpFile.writeAsBytes(bytes, flush: true); + await tmpFile.rename(file.path); + // Suppress successful disk write logs. + // Enforce rolling disk cache size (best-effort, async). + scheduleMicrotask(() => _enforceDiskCacheSizeLimit()); + } catch (e, st) { + // Warn about write failures - keep stack visible to aid debugging. + _log.warning('Failed to write thumbnail to disk for key $key: $e', e, st); + // best-effort: ignore disk write failures + } + } + + Future _enforceDiskCacheSizeLimit() async { + if (_pruning) return; + _pruning = true; + try { + final dir = await _ensureDiskCacheDir(); + final files = dir.listSync().whereType().toList(growable: false); + if (files.isEmpty) return; + // Compute total size + int total = 0; + final entries = {}; + for (final f in files) { + try { + final stat = f.statSync(); + total += stat.size; + entries[f] = stat.modified; + } catch (_) { + // ignore individual file stat errors + } + } + if (total <= _diskCacheMaxBytes) return; + + // Sort by modification time ascending (oldest first) and delete until + // we're under the limit. + final sorted = entries.keys.toList() + ..sort((a, b) => entries[a]!.compareTo(entries[b]!)); + for (final f in sorted) { + if (total <= _diskCacheMaxBytes) break; + try { + final stat = f.statSync(); + total -= stat.size; + f.deleteSync(); + } catch (_) { + // ignore deletion errors and continue + } + } + } catch (_) { + // ignore enforcement errors + } finally { + _pruning = false; + } + } + + Future _readFromDiskIfFresh( + String location, OrionApiFile file, String size) async { + try { + final keyPrefix = '$location|${file.path}|'; + // Attempt to find any matching disk entry for this path+size. + final dir = await _ensureDiskCacheDir(); + final candidates = dir.listSync().whereType(); + for (final f in candidates) { + final decoded = Uri.decodeComponent(p.basename(f.path)); + if (decoded.startsWith(keyPrefix) && decoded.endsWith('|$size')) { + try { + // If TTL is enabled, check file age and treat as stale if older + // than configured TTL. Stale files are scheduled for deletion + // asynchronously and ignored for serving. + if (_diskEntryTtl != null) { + try { + final mtime = f.lastModifiedSync(); + if (DateTime.now().difference(mtime) > _diskEntryTtl!) { + // schedule background deletion of the stale file + scheduleMicrotask(() async { + try { + await f.delete(); + } catch (_) { + // ignore deletion errors + } + }); + // skip this file and continue searching + continue; + } + } catch (_) { + // If we cannot stat the file, skip it and continue + continue; + } + } + + final bytes = await f.readAsBytes(); + return bytes; + } catch (e, st) { + _log.fine( + 'Failed to read thumbnail from disk ${f.path}: $e', e, st); + // continue searching other candidates + continue; + } + } + } + } catch (_) { + // ignore disk read errors + } + return null; + } + + bool _isExpired(DateTime timestamp) { + return DateTime.now().difference(timestamp) > _entryTtl; + } + + void _evictAlternateVersions(String location, String filePath, + {required String keepKey}) { + final prefix = '$location|$filePath|'; + final toRemove = _cache.keys + .where((key) => key.startsWith(prefix) && key != keepKey) + .toList(growable: false); + for (final key in toRemove) { + _cache.remove(key); + _inFlight.remove(key); + } + final inflightRemove = _inFlight.keys + .where((key) => key.startsWith(prefix) && key != keepKey) + .toList(growable: false); + for (final key in inflightRemove) { + _inFlight.remove(key); + } + } + + /// Extracts the lastModified component (epoch seconds) from a cache key + /// shaped as '`|||' and returns a + /// human-readable UTC timestamp. If parsing fails, throws. + String _dateFromCacheKey(String keyOrDecoded) { + final parts = keyOrDecoded.split('|'); + if (parts.length < 3) throw Exception('invalid cache key'); + final lm = int.tryParse(parts[2]) ?? 0; + if (lm == 0) return 'epoch:0'; + final dt = DateTime.fromMillisecondsSinceEpoch(lm * 1000, isUtc: true); + return dt.toIso8601String(); + } +} + +class _CacheEntry { + _CacheEntry(this.bytes, this.timestamp); + final Uint8List? bytes; + final DateTime timestamp; +} From dcbb4163bc4165440fd9ec90c604828b50710a7b Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:30:35 +0200 Subject: [PATCH 15/47] feat(status): Update StatusProvider and StatusScreen to use thumbnail bytes and improve snapshot handling --- .../providers/status_provider.dart | 107 ++++++++++++++---- lib/status/status_screen.dart | 107 ++++++++++++++---- lib/util/status_card.dart | 37 ++++-- 3 files changed, 200 insertions(+), 51 deletions(-) diff --git a/lib/backend_service/providers/status_provider.dart b/lib/backend_service/providers/status_provider.dart index dff9472..0322df1 100644 --- a/lib/backend_service/providers/status_provider.dart +++ b/lib/backend_service/providers/status_provider.dart @@ -1,8 +1,18 @@ /* * Orion - Status Provider -* Centralized state management & polling for printer status. -* Converts raw API maps into strongly-typed models and exposes -* derived UI-friendly properties & transition flags. +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'dart:async'; @@ -15,7 +25,9 @@ import 'package:orion/backend_service/odyssey/odyssey_client.dart'; import 'package:orion/backend_service/backend_service.dart'; import 'package:orion/util/orion_config.dart'; import 'package:orion/backend_service/odyssey/models/status_models.dart'; -import 'package:orion/util/sl1_thumbnail.dart'; +import 'dart:typed_data'; +import 'package:orion/util/thumbnail_cache.dart'; +import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; /// Polls the backend printer `/status` endpoint and exposes a typed [StatusModel]. /// @@ -63,8 +75,8 @@ class StatusProvider extends ChangeNotifier { // We deliberately removed expected file name gating; re-printing the same // file should still show a clean loading phase. - String? _thumbnailPath; - String? get thumbnailPath => _thumbnailPath; + Uint8List? _thumbnailBytes; + Uint8List? get thumbnailBytes => _thumbnailBytes; bool _thumbnailReady = false; // becomes true once we have a path OR decide none needed bool get thumbnailReady => _thumbnailReady; @@ -232,7 +244,9 @@ class StatusProvider extends ChangeNotifier { // parsed payload directly. // Lazy thumbnail acquisition (same rules as refresh) - if (parsed.isPrinting && _thumbnailPath == null && !_thumbnailReady) { + if (parsed.isPrinting && + _thumbnailBytes == null && + !_thumbnailReady) { final fileData = parsed.printData?.fileData; if (fileData != null) { final path = fileData.path; @@ -244,10 +258,18 @@ class StatusProvider extends ChangeNotifier { subdir = path.substring(0, path.lastIndexOf('/')); } try { - _thumbnailPath = await ThumbnailUtil.extractThumbnail( - fileData.locationCategory ?? 'Local', - subdir, - fileData.name, + final file = OrionApiFile( + path: path, + name: fileData.name, + parentPath: subdir, + lastModified: 0, + locationCategory: fileData.locationCategory, + ); + _thumbnailBytes = await ThumbnailCache.instance.getThumbnail( + location: fileData.locationCategory ?? 'Local', + subdirectory: subdir, + fileName: fileData.name, + file: file, size: 'Large', ); _thumbnailReady = true; @@ -393,6 +415,7 @@ class StatusProvider extends ChangeNotifier { Future refresh() async { if (_fetchInFlight) return; // simple re-entrancy guard _fetchInFlight = true; + final startedAt = DateTime.now(); // Snapshot fields to avoid emitting notifications on every successful // polling refresh when nothing meaningful changed. This reduces churn // for listeners (e.g., ConnectionErrorWatcher) in polling-only backends @@ -452,7 +475,7 @@ class StatusProvider extends ChangeNotifier { // Transitional clears will be based on the freshly parsed payload. // Attempt lazy thumbnail acquisition (only while printing and not yet fetched) - if (parsed.isPrinting && _thumbnailPath == null && !_thumbnailReady) { + if (parsed.isPrinting && _thumbnailBytes == null && !_thumbnailReady) { final fileData = parsed.printData?.fileData; if (fileData != null) { final path = fileData.path; @@ -463,10 +486,18 @@ class StatusProvider extends ChangeNotifier { subdir = path.substring(0, path.lastIndexOf('/')); } try { - _thumbnailPath = await ThumbnailUtil.extractThumbnail( - fileData.locationCategory ?? 'Local', - subdir, - fileData.name, + final file = OrionApiFile( + path: path, + name: fileData.name, + parentPath: subdir, + lastModified: 0, + locationCategory: fileData.locationCategory, + ); + _thumbnailBytes = await ThumbnailCache.instance.getThumbnail( + location: fileData.locationCategory ?? 'Local', + subdirectory: subdir, + fileName: fileData.name, + file: file, size: 'Large', ); _thumbnailReady = true; @@ -506,10 +537,20 @@ class StatusProvider extends ChangeNotifier { final timedOut = _awaitingSince != null && DateTime.now().difference(_awaitingSince!) > _awaitingTimeout; // If backend reports active printing/paused we clear awaiting early - // (do not strictly require file metadata or thumbnail). This avoids - // leaving the UI stuck on a spinner when the backend delays - // populating print_data or thumbnails after a start request. - if (newPrintReady || parsed.isPrinting || parsed.isPaused || timedOut) { + // (do not strictly require file metadata or thumbnail). Additionally, + // if the backend reports a finished snapshot (idle with layer data) + // or a canceled snapshot we should also clear awaiting so the UI + // doesn't remain stuck on a spinner for backends (like NanoDLP) + // that may briefly lose the 'printing' flag during transition. + if (newPrintReady || + parsed.isPrinting || + parsed.isPaused || + // Treat a finished snapshot (idle but with layers) as valid + // to clear awaiting so the UI can present the final state. + (parsed.isIdle && parsed.layer != null) || + // Canceled snapshots should also clear awaiting. + parsed.isCanceled || + timedOut) { _awaitingNewPrintData = false; _awaitingSince = null; } @@ -544,8 +585,13 @@ class StatusProvider extends ChangeNotifier { Future.delayed(Duration(seconds: backoff), () { _nextPollRetryAt = null; }); + final elapsed = DateTime.now().difference(startedAt); + final millis = elapsed.inMilliseconds; + final elapsedStr = millis >= 1000 + ? '${(millis / 1000).toStringAsFixed(1)}s' + : '${millis}ms'; _log.warning( - 'Status refresh failed; backing off polling for ${_pollIntervalSeconds}s (attempt $_consecutiveErrors)'); + 'Status refresh failed after $elapsedStr; backing off polling for ${_pollIntervalSeconds}s (attempt $_consecutiveErrors)'); } finally { _fetchInFlight = false; if (!_disposed) { @@ -692,16 +738,29 @@ class StatusProvider extends ChangeNotifier { /// Clear current status so UI shows a neutral/loading state prior to the /// next print starting. Polling continues and will repopulate on refresh. - void resetStatus() { + /// Reset the provider to a neutral/loading state. Optionally provide an + /// initial thumbnail (bytes) and file path or plate id so the UI can + /// immediately render a cached preview while the backend populates + /// active job metadata. This is useful when starting a print from the + /// DetailsScreen where the thumbnail is already available. + void resetStatus({ + Uint8List? initialThumbnailBytes, + String? initialFilePath, + int? initialPlateId, + }) { _status = null; - _thumbnailPath = null; - _thumbnailReady = false; + _thumbnailBytes = initialThumbnailBytes; + _thumbnailReady = initialThumbnailBytes != null; _error = null; _loading = true; // so consumer screens can show a spinner if they mount _isCanceling = false; _isPausing = false; _awaitingNewPrintData = true; // begin awaiting active print _awaitingSince = DateTime.now(); + // If an initial file path or plate id is provided we may use it to + // resolve thumbnails faster in NanoDLP adapters; store on status + // model is not needed here but provider consumers can access + // _thumbnailBytes immediately. notifyListeners(); } diff --git a/lib/status/status_screen.dart b/lib/status/status_screen.dart index 39485d7..0549fbd 100644 --- a/lib/status/status_screen.dart +++ b/lib/status/status_screen.dart @@ -15,11 +15,12 @@ * limitations under the License. */ -import 'dart:io'; +// dart:io not needed once thumbnails are rendered from memory import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'dart:typed_data'; import 'package:orion/files/grid_files_screen.dart'; import 'package:orion/glasser/glasser.dart'; @@ -32,7 +33,17 @@ import 'package:orion/backend_service/odyssey/models/status_models.dart'; class StatusScreen extends StatefulWidget { final bool newPrint; - const StatusScreen({super.key, required this.newPrint}); + final Uint8List? initialThumbnailBytes; + final String? initialFilePath; + final int? initialPlateId; + + const StatusScreen({ + super.key, + required this.newPrint, + this.initialThumbnailBytes, + this.initialFilePath, + this.initialPlateId, + }); @override StatusScreenState createState() => StatusScreenState(); @@ -45,6 +56,7 @@ class StatusScreenState extends State { // this widget (which previously caused a FlutterError) while still ensuring // a clean spinner instead of flashing the prior job. bool _suppressOldStatus = false; + String? _frozenFileName; // Presentation-local state (derived values computed per build instead of storing) bool get _isLandscape => MediaQuery.of(context).orientation == Orientation.landscape; @@ -63,7 +75,11 @@ class StatusScreenState extends State { _suppressOldStatus = true; // force spinner for fresh print session WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - context.read().resetStatus(); + context.read().resetStatus( + initialThumbnailBytes: widget.initialThumbnailBytes, + initialFilePath: widget.initialFilePath, + initialPlateId: widget.initialPlateId, + ); setState(() => _suppressOldStatus = false); }); } @@ -76,18 +92,49 @@ class StatusScreenState extends State { final StatusModel? status = provider.status; final awaiting = provider.awaitingNewPrintData; final newPrintReady = provider.newPrintReady; + + // If we're awaiting a new print session, clear any previously + // frozen filename so the next job can set it when available. + if (awaiting) { + _frozenFileName = null; + } + + // Freeze the file name once we observe it for the active print so + // it does not change mid-print if backend later updates metadata. + if (_frozenFileName == null && + status?.printData?.fileData?.name != null) { + final name = status!.printData!.fileData!.name; + // Only freeze when a job is active (printing or paused) so we + // don't persist names for idle snapshots. + if (status.isPrinting || status.isPaused) { + _frozenFileName = name; + } + } // We do not expose elapsed awaiting time (private); could add later via provider getter. const int waitMillis = 0; // Provider handles polling, transitional flags (pause/cancel), thumbnail caching, and // exposes a typed StatusModel. The screen now focuses solely on presentation. // Show global loading while provider indicates loading, we have no status yet, - // or we are in the transitional window awaiting initial print data to avoid - // an empty flicker state. + // or (for new prints) until the provider signals the print is ready + // (we have active job+file metadata+thumbnail). However, if the + // backend reports the job has already finished (idle with layer data) + // or is canceled we should not remain in a spinner indefinitely — + // render the final status instead. + final bool finishedSnapshot = + status?.isIdle == true && status?.layer != null; + final bool canceledSnapshot = status?.isCanceled == true; + if (_suppressOldStatus || provider.isLoading || status == null || - (awaiting && !newPrintReady)) { + // If this screen was opened as a new print, wait until the + // provider reports the job is ready to display. But allow + // finished/canceled snapshots through so the UI doesn't lock up. + ((widget.newPrint || awaiting) && + !newPrintReady && + !finishedSnapshot && + !canceledSnapshot)) { return GlassApp( child: Scaffold( body: Center( @@ -161,7 +208,8 @@ class StatusScreenState extends State { } final elapsedStr = status.formattedElapsedPrintTime; - final fileName = status.printData?.fileData?.name ?? ''; + final fileName = + _frozenFileName ?? status.printData?.fileData?.name ?? ''; return GlassApp( child: Scaffold( @@ -335,7 +383,13 @@ class StatusScreenState extends State { Widget _buildNameCard(String fileName, StatusProvider provider) { final truncated = _truncateFileName(fileName); - final color = provider.statusColor(context); + final statusModel = provider.status; + final finishedSnapshot = + statusModel?.isIdle == true && statusModel?.layer != null; + final effectivelyFinished = provider.progress >= 0.999; + final color = (finishedSnapshot && !effectivelyFinished) + ? Theme.of(context).colorScheme.error + : provider.statusColor(context); return GlassCard( outlined: true, child: ListTile( @@ -359,10 +413,17 @@ class StatusScreenState extends State { Widget _buildThumbnailView( BuildContext context, StatusProvider provider, StatusModel? status) { - final thumbnail = provider.thumbnailPath; + final thumbnail = provider.thumbnailBytes; final themeProvider = Provider.of(context); final progress = provider.progress; final statusColor = provider.statusColor(context); + final statusModel = provider.status; + final finishedSnapshot = + statusModel?.isIdle == true && statusModel?.layer != null; + final effectivelyFinished = progress >= 0.999; + final effectiveStatusColor = (finishedSnapshot && !effectivelyFinished) + ? Theme.of(context).colorScheme.error + : statusColor; return Center( child: Stack( children: [ @@ -384,11 +445,16 @@ class StatusScreenState extends State { 0, 0, 0, 1, 0, ]), child: thumbnail != null && thumbnail.isNotEmpty - ? Image.file( - File(thumbnail), + ? Image.memory( + thumbnail, fit: BoxFit.cover, ) - : const Center(child: CircularProgressIndicator()), + : Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + effectiveStatusColor), + ), + ), ), Positioned.fill( child: Container( @@ -403,12 +469,15 @@ class StatusScreenState extends State { alignment: Alignment.bottomCenter, heightFactor: progress, child: thumbnail != null && thumbnail.isNotEmpty - ? Image.file( - File(thumbnail), + ? Image.memory( + thumbnail, fit: BoxFit.cover, ) - : const Center( - child: CircularProgressIndicator(), + : Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + effectiveStatusColor), + ), ), ), ), @@ -425,7 +494,7 @@ class StatusScreenState extends State { isCanceling: provider.isCanceling, isPausing: provider.isPausing, progress: progress, - statusColor: statusColor, + statusColor: effectiveStatusColor, status: status, ), ), @@ -464,10 +533,10 @@ class StatusScreenState extends State { quarterTurns: 3, child: LinearProgressIndicator( minHeight: 30, - color: statusColor, + color: effectiveStatusColor, value: progress, backgroundColor: isGlassTheme - ? Colors.white.withValues(alpha: 0.1) + ? effectiveStatusColor.withValues(alpha: 0.1) : null, ), ), diff --git a/lib/util/status_card.dart b/lib/util/status_card.dart index bc1ccd9..afb2169 100644 --- a/lib/util/status_card.dart +++ b/lib/util/status_card.dart @@ -46,9 +46,21 @@ class StatusCardState extends State { @override Widget build(BuildContext context) { final s = widget.status; - if (s != null && s.isIdle && s.layer != null) { + // Determine whether a finished snapshot actually indicates a successful + // completion or a cancellation. Some backends (NanoDLP) do not provide + // an explicit 'finished' flag; when a snapshot shows idle with a layer + // but the progress is not 100% we should treat this as a cancel. + final bool finishedSnapshot = s != null && s.isIdle && s.layer != null; + final bool effectivelyFinished = + finishedSnapshot && (widget.progress >= 0.999); + + if (effectivelyFinished) { cardIcon = const Icon(Icons.check); - } else if (widget.isCanceling || (s?.layer == null)) { + } else if (widget.isCanceling || + (s?.layer == null) || + (finishedSnapshot && !effectivelyFinished)) { + // show stop when actively canceling, when there's no layer (canceled), + // or when a finished-looking snapshot has progress < 100% (treat as canceled) cardIcon = const Icon(Icons.stop); } else if (widget.isPausing || s?.isPaused == true) { cardIcon = const Icon(Icons.pause); @@ -73,14 +85,23 @@ class StatusCardState extends State { showSpinner = true; // Actively canceling from printing (not from paused) } - // Show full circle when canceled or canceling from paused + // Show full circle when canceled or canceling from paused. Also treat + // a finished-looking snapshot with progress < 100% as canceled (full). if (s?.layer == null) { showFullCircle = true; // Already canceled } else if (widget.isCanceling && widget.isPausing) { - showFullCircle = - true; // Canceling from paused state (isPausing is still true) + showFullCircle = true; // Canceling from paused state + } else if (finishedSnapshot && !effectivelyFinished) { + showFullCircle = true; // Treated as canceled because progress < 100% } + // If we determined a finished-looking snapshot is actually a cancel + // (progress < 100%), render using the error/canceled color so the + // progress ring and icon match the stop semantics. + final effectiveStatusColor = (finishedSnapshot && !effectivelyFinished) + ? Theme.of(context).colorScheme.error + : widget.statusColor; + final circleProgress = showSpinner ? null : showFullCircle @@ -138,9 +159,9 @@ class StatusCardState extends State { value: circleProgress, strokeWidth: 6, valueColor: AlwaysStoppedAnimation( - widget.statusColor), + effectiveStatusColor), backgroundColor: - widget.statusColor.withValues(alpha: 0.5), + effectiveStatusColor.withValues(alpha: 0.5), ), ), ), @@ -148,7 +169,7 @@ class StatusCardState extends State { padding: const EdgeInsets.all(25), child: Icon( cardIcon.icon, - color: widget.statusColor, + color: effectiveStatusColor, size: 70, ), ) From e4b11edcdec7a91c9493c996debd58de9cc9d13c Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:31:59 +0200 Subject: [PATCH 16/47] chore(backend_service): Update license information and comments in ConfigModel file --- .../odyssey/models/config_models.dart | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/backend_service/odyssey/models/config_models.dart b/lib/backend_service/odyssey/models/config_models.dart index a5cb998..d7c9c18 100644 --- a/lib/backend_service/odyssey/models/config_models.dart +++ b/lib/backend_service/odyssey/models/config_models.dart @@ -1,8 +1,24 @@ -/** - * Orion - Config Models - * Lightweight typed wrapper for the /config endpoint. We keep nested - * sections as Map to avoid coupling to a rigid schema. - */ +/* +* Orion - Odyssey Config Models +* Copyright (C) 2025 Open Resin Alliance +* +* 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. +*/ + +/// Orion - Config Models +/// Lightweight typed wrapper for the /config endpoint. We keep nested +/// sections as Map` to avoid coupling to a rigid schema. +library; import 'package:json_annotation/json_annotation.dart'; From 8caf869c337f5a072e248ecbf60b91063530841e Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:32:30 +0200 Subject: [PATCH 17/47] refactor(backend_service): NanoDLP_Status: Update height conversion logic for improved accuracy in z position calculation --- .../nanodlp/models/nano_status.dart | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lib/backend_service/nanodlp/models/nano_status.dart b/lib/backend_service/nanodlp/models/nano_status.dart index 2dce8a9..35a06be 100644 --- a/lib/backend_service/nanodlp/models/nano_status.dart +++ b/lib/backend_service/nanodlp/models/nano_status.dart @@ -1,3 +1,20 @@ +/* +* Orion - NanoDLP Status Model +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'nano_file.dart'; // Minimal NanoDLP status DTO @@ -131,24 +148,11 @@ class NanoStatus { double? z; if (currentHeight != null) { - // CurrentHeight units vary between NanoDLP installs. Common possibilities: - // - already in mm (small integers, e.g. 150) - // - in microns (e.g. 150000 -> 150 mm) - // - in nanometers (e.g. 1504000 -> 1.504 mm) - // Heuristic: pick the conversion that yields a plausible Z (<= 300 mm). - final asMmIfMicrons = currentHeight / 1000.0; // microns -> mm - final asMmIfNanometers = currentHeight / 1000000.0; // nm -> mm - if (currentHeight <= 1000) { - // Small values are likely already mm - z = currentHeight.toDouble(); - } else if (asMmIfMicrons <= 300.0) { - z = asMmIfMicrons; - } else if (asMmIfNanometers <= 300.0) { - z = asMmIfNanometers; - } else { - // Fallback: assume microns - z = asMmIfMicrons; - } + // NanoDLP reports current height in device-specific units on the + // target installs we support. For these devices 1 mm == 6400 units. + // Convert device units to millimeters so downstream mappers/consumers + // receive a sensible `z` value. Example: 320 -> 320 / 6400 = 0.05 mm. + z = currentHeight / 6400.0; } return NanoStatus( From e5102d467a1f7bc8183ca6840903f425b4a19e97 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:33:50 +0200 Subject: [PATCH 18/47] feat(backend_service): Enhance NanoDLP and Odyssey HTTP clients with timeout handling and improved client creation --- .../nanodlp/nanodlp_http_client.dart | 99 +++++++++++++++---- .../odyssey/odyssey_http_client.dart | 64 +++++++++++- 2 files changed, 140 insertions(+), 23 deletions(-) diff --git a/lib/backend_service/nanodlp/nanodlp_http_client.dart b/lib/backend_service/nanodlp/nanodlp_http_client.dart index 6d8f732..fdab578 100644 --- a/lib/backend_service/nanodlp/nanodlp_http_client.dart +++ b/lib/backend_service/nanodlp/nanodlp_http_client.dart @@ -1,3 +1,20 @@ +/* +* Orion - NanoDLP HTTP Client +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'dart:async'; import 'dart:typed_data'; import 'dart:convert'; @@ -21,6 +38,7 @@ class NanoDlpHttpClient implements OdysseyClient { late final String apiUrl; final _log = Logger('NanoDlpHttpClient'); final http.Client Function() _clientFactory; + final Duration _requestTimeout; // Increase plates cache TTL so we don't re-query the plates list on every // frequent status poll. Plates metadata is relatively static during a // print session so a 2-minute cache avoids needless network load. @@ -38,8 +56,10 @@ class NanoDlpHttpClient implements OdysseyClient { final Map _thumbnailCache = {}; final Map> _thumbnailInFlight = {}; - NanoDlpHttpClient({http.Client Function()? clientFactory}) - : _clientFactory = clientFactory ?? http.Client.new { + NanoDlpHttpClient( + {http.Client Function()? clientFactory, Duration? requestTimeout}) + : _clientFactory = clientFactory ?? http.Client.new, + _requestTimeout = requestTimeout ?? const Duration(seconds: 2) { _createdAt = DateTime.now(); try { final cfg = OrionConfig(); @@ -60,6 +80,11 @@ class NanoDlpHttpClient implements OdysseyClient { _log.info('constructed NanoDlpHttpClient apiUrl=$apiUrl'); } + http.Client _createClient() { + final inner = _clientFactory(); + return _TimeoutHttpClient(inner, _requestTimeout, _log, 'NanoDLP'); + } + // Timestamp when this client instance was created. Used to avoid // performing potentially expensive plate-list resolution during the // very first status poll immediately at app startup. @@ -70,13 +95,22 @@ class NanoDlpHttpClient implements OdysseyClient { Future> getStatus() async { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/status'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { throw Exception('NanoDLP status call failed: ${resp.statusCode}'); } final decoded = json.decode(resp.body) as Map; + // Remove very large fields we don't need (e.g. FillAreas) to avoid + // costly parsing and memory churn when polling /status frequently. + if (decoded.containsKey('FillAreas')) { + try { + decoded.remove('FillAreas'); + } catch (_) { + // ignore any removal errors; parsing will continue without it + } + } var nano = NanoStatus.fromJson(decoded); // If the status payload doesn't include file metadata but does include @@ -277,7 +311,7 @@ class NanoDlpHttpClient implements OdysseyClient { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/printer/stop'); _log.info('NanoDLP stopPrint request: $uri'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -331,7 +365,7 @@ class NanoDlpHttpClient implements OdysseyClient { () async { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/status'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -442,7 +476,7 @@ class NanoDlpHttpClient implements OdysseyClient { _log.info( 'NanoDLP relative move request: $uri (deltaMm=$deltaMm currentZ=$currentZ)'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -466,8 +500,9 @@ class NanoDlpHttpClient implements OdysseyClient { // Send a dumb relative move command in microns (NanoDLP expects an // integer micron distance). Positive = up, negative = down. final deltaMicrons = (deltaMm * 1000).round(); - if (deltaMicrons == 0) + if (deltaMicrons == 0) { return NanoManualResult(ok: true, message: 'no-op').toMap(); + } final direction = deltaMicrons > 0 ? 'up' : 'down'; final distance = deltaMicrons.abs(); @@ -476,7 +511,7 @@ class NanoDlpHttpClient implements OdysseyClient { Uri.parse('$baseNoSlash/z-axis/move/$direction/micron/$distance'); _log.info('NanoDLP relative moveDelta request: $uri (deltaMm=$deltaMm)'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -502,7 +537,7 @@ class NanoDlpHttpClient implements OdysseyClient { try { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/status'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) return false; @@ -523,7 +558,7 @@ class NanoDlpHttpClient implements OdysseyClient { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/z-axis/top'); _log.info('NanoDLP moveToTop request: $uri'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -555,7 +590,7 @@ class NanoDlpHttpClient implements OdysseyClient { Future> manualHome() async => () async { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/z-axis/calibrate'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -582,7 +617,7 @@ class NanoDlpHttpClient implements OdysseyClient { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/printer/pause'); _log.info('NanoDLP pausePrint request: $uri'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -607,7 +642,7 @@ class NanoDlpHttpClient implements OdysseyClient { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/printer/unpause'); _log.info('NanoDLP resumePrint request: $uri'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -645,7 +680,7 @@ class NanoDlpHttpClient implements OdysseyClient { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/printer/start/$idToUse'); _log.info('NanoDLP startPrint request: $uri'); - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode != 200) { @@ -717,11 +752,14 @@ class NanoDlpHttpClient implements OdysseyClient { String _thumbnailCacheKeyForPlate( NanoFile plate, String fallbackPath, int width, int height) { - final id = plate.plateId; + // PlateID can change when files are deleted/replaced on the device. + // Prefer stable identifiers based on path + lastModified (if available) + // so cache keys remain valid across plateId churn. final resolvedPath = plate.resolvedPath.isNotEmpty ? plate.resolvedPath.toLowerCase() : fallbackPath; - final identifier = id != null ? 'plate:$id' : 'path:$resolvedPath'; + final lm = plate.lastModified ?? 0; + final identifier = 'path:$resolvedPath|lm:$lm'; return _thumbnailCacheKey(identifier, width, height); } @@ -749,7 +787,7 @@ class NanoDlpHttpClient implements OdysseyClient { Future<_ThumbnailCacheEntry> _downloadThumbnail( Uri uri, String debugLabel) async { - final client = _clientFactory(); + final client = _createClient(); try { final resp = await client.get(uri); if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { @@ -808,7 +846,7 @@ class NanoDlpHttpClient implements OdysseyClient { final uri = Uri.parse('$baseNoSlash/plates/list/json'); _log.fine('Requesting NanoDLP plates list (no query params): $uri'); - final client = _clientFactory(); + final client = _createClient(); try { http.Response resp; try { @@ -903,6 +941,31 @@ class NanoDlpHttpClient implements OdysseyClient { } } +class _TimeoutHttpClient extends http.BaseClient { + _TimeoutHttpClient(this._inner, this._timeout, this._log, this._label); + + final http.Client _inner; + final Duration _timeout; + final Logger _log; + final String _label; + + @override + Future send(http.BaseRequest request) { + final future = _inner.send(request); + return future.timeout(_timeout, onTimeout: () { + final msg = + '$_label ${request.method} ${request.url} timed out after ${_timeout.inSeconds}s'; + _log.warning(msg); + throw TimeoutException(msg); + }); + } + + @override + void close() { + _inner.close(); + } +} + class _ThumbnailCacheEntry { _ThumbnailCacheEntry(this.bytes, this.timestamp, this.placeholder); diff --git a/lib/backend_service/odyssey/odyssey_http_client.dart b/lib/backend_service/odyssey/odyssey_http_client.dart index a7672fa..fbb6ea0 100644 --- a/lib/backend_service/odyssey/odyssey_http_client.dart +++ b/lib/backend_service/odyssey/odyssey_http_client.dart @@ -1,14 +1,38 @@ +/* +* Orion - NanoDLP HTTP Client +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'dart:typed_data'; import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; import 'package:orion/backend_service/odyssey/odyssey_client.dart'; import 'package:orion/util/orion_config.dart'; class OdysseyHttpClient implements OdysseyClient { late final String apiUrl; + final _log = Logger('OdysseyHttpClient'); + final http.Client Function() _clientFactory; + final Duration _requestTimeout; - OdysseyHttpClient() { + OdysseyHttpClient( + {http.Client Function()? clientFactory, Duration? requestTimeout}) + : _clientFactory = clientFactory ?? http.Client.new, + _requestTimeout = requestTimeout ?? const Duration(seconds: 2) { try { OrionConfig config = OrionConfig(); final customUrl = config.getString('customUrl', category: 'advanced'); @@ -19,6 +43,11 @@ class OdysseyHttpClient implements OdysseyClient { } } + http.Client _createClient() { + final inner = _clientFactory(); + return _TimeoutHttpClient(inner, _requestTimeout, _log, 'Odyssey'); + } + @override Future> listItems( String location, int pageSize, int pageIndex, String subdirectory) async { @@ -73,7 +102,7 @@ class OdysseyHttpClient implements OdysseyClient { final uri = _dynUri(apiUrl, '/status/stream', {}); final request = http.Request('GET', uri); - final client = http.Client(); + final client = _createClient(); try { final streamed = await client.send(request); final stream = streamed.stream @@ -205,7 +234,7 @@ class OdysseyHttpClient implements OdysseyClient { Future _odysseyGet( String endpoint, Map queryParams) async { final uri = _dynUri(apiUrl, endpoint, queryParams); - final client = http.Client(); + final client = _createClient(); try { final response = await client.get(uri); if (response.statusCode == 200) return response; @@ -219,7 +248,7 @@ class OdysseyHttpClient implements OdysseyClient { Future _odysseyPost( String endpoint, Map queryParams) async { final uri = _dynUri(apiUrl, endpoint, queryParams); - final client = http.Client(); + final client = _createClient(); try { final response = await client.post(uri); if (response.statusCode == 200) return response; @@ -233,7 +262,7 @@ class OdysseyHttpClient implements OdysseyClient { Future _odysseyDelete( String endpoint, Map queryParams) async { final uri = _dynUri(apiUrl, endpoint, queryParams); - final client = http.Client(); + final client = _createClient(); try { final response = await client.delete(uri); if (response.statusCode == 200) return response; @@ -244,3 +273,28 @@ class OdysseyHttpClient implements OdysseyClient { } } } + +class _TimeoutHttpClient extends http.BaseClient { + _TimeoutHttpClient(this._inner, this._timeout, this._log, this._label); + + final http.Client _inner; + final Duration _timeout; + final Logger _log; + final String _label; + + @override + Future send(http.BaseRequest request) { + final future = _inner.send(request); + return future.timeout(_timeout, onTimeout: () { + final msg = + '$_label ${request.method} ${request.url} timed out after ${_timeout.inSeconds}s'; + _log.warning(msg); + throw TimeoutException(msg); + }); + } + + @override + void close() { + _inner.close(); + } +} From 081df55ab4462239ce54daff62b445ab9a61fe3f Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:34:16 +0200 Subject: [PATCH 19/47] feat(backend_service): NanoDLP: Enhance lastModified handling to support parsing from human-readable date strings --- .../nanodlp/models/nano_file.dart | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/lib/backend_service/nanodlp/models/nano_file.dart b/lib/backend_service/nanodlp/models/nano_file.dart index 525b453..f9292c7 100644 --- a/lib/backend_service/nanodlp/models/nano_file.dart +++ b/lib/backend_service/nanodlp/models/nano_file.dart @@ -1,3 +1,20 @@ +/* +* Orion - NanoDLP File Model +* Copyright (C) 2025 Open Resin Alliance +* +* 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. +*/ + // DTO for a NanoDLP "plate" or file as returned by status or plates endpoints. // // Centralises parsing/normalisation so higher layers (like the HTTP client) @@ -136,6 +153,51 @@ class NanoFile { json['Updated'] ?? json['UpdatedOn'] ?? json['CreatedDate']); + // If lastModified is not present as an integer it may be provided as a + // human-readable date string (e.g. 'Date': '2025-10-04 22:11'). Attempt + // to parse common date string formats into epoch seconds so callers can + // key caches reliably by file+date rather than unstable plate IDs. + int? lastModifiedFromString() { + final candidates = [ + json['Date'], + json['date'], + json['created_date'], + json['CreatedDate'], + json['UpdatedOn'], + json['Updated'], + ]; + for (final c in candidates) { + if (c == null) continue; + if (c is int) return c; + if (c is num) return c.toInt(); + if (c is String) { + final s = c.trim(); + if (s.isEmpty) continue; + try { + // Try ISO8601 parsing first + DateTime dt = DateTime.parse(s); + return dt.toUtc().millisecondsSinceEpoch ~/ 1000; + } catch (_) { + // Try common variant 'YYYY-MM-DD HH:MM' by replacing space + // with 'T' and appending seconds if missing. + try { + var alt = s.replaceFirst(' ', 'T'); + if (RegExp(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$").hasMatch(alt)) { + alt = '$alt:00'; + } + DateTime dt = DateTime.parse(alt); + return dt.toUtc().millisecondsSinceEpoch ~/ 1000; + } catch (_) { + // ignore and continue + } + } + } + } + return null; + } + + final lastModifiedFromStr = lastModifiedFromString(); + final finalLastModified = lastModified ?? lastModifiedFromStr; String? parentPath = (json['parent_path'] ?? json['parentPath'])?.toString(); final fileSize = parseInt( @@ -212,7 +274,7 @@ class NanoFile { name: resolvedName, layerCount: layerCount, printTime: printTime, - lastModified: lastModified, + lastModified: finalLastModified, parentPath: parentPath ?? '', fileSize: fileSize, materialName: materialName, From 6c919f8ac0a0e2d1b8da674aa0080bdf3df03feb Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:34:31 +0200 Subject: [PATCH 20/47] feat(tests): Add timeout handling tests for OdysseyHttpClient and improve thumbnail utility tests --- test/nanodlp/nanodlp_http_client_test.dart | 44 +++++++++++++++++++ test/nanodlp_status_mapping_test.dart | 18 +++++++- test/odyssey_http_client_test.dart | 49 ++++++++++++++++++++++ test/thumbnail_util_test.dart | 30 +++++++++++-- 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 test/odyssey_http_client_test.dart diff --git a/test/nanodlp/nanodlp_http_client_test.dart b/test/nanodlp/nanodlp_http_client_test.dart index 4be38c5..1afe679 100644 --- a/test/nanodlp/nanodlp_http_client_test.dart +++ b/test/nanodlp/nanodlp_http_client_test.dart @@ -1,3 +1,21 @@ +/* +* Orion - NanoDLP HTTP Client Test +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -119,4 +137,30 @@ void main() { expect(plateRequests, 1); }); }); + + group('NanoDlpHttpClient timeout', () { + test('getStatus fails fast when backend is unresponsive', () async { + final client = NanoDlpHttpClient( + clientFactory: () => _NeverCompletesClient(), + requestTimeout: const Duration(milliseconds: 25), + ); + + final sw = Stopwatch()..start(); + final future = client.getStatus(); + await expectLater(future, throwsA(isA())); + sw.stop(); + + expect(sw.elapsed, lessThan(const Duration(milliseconds: 300))); + }); + }); +} + +class _NeverCompletesClient extends http.BaseClient { + @override + Future send(http.BaseRequest request) { + return Completer().future; + } + + @override + void close() {} } diff --git a/test/nanodlp_status_mapping_test.dart b/test/nanodlp_status_mapping_test.dart index f66dfd1..2d8242b 100644 --- a/test/nanodlp_status_mapping_test.dart +++ b/test/nanodlp_status_mapping_test.dart @@ -1,5 +1,21 @@ +/* +* Orion - NanoDLP Status Mapping Test +* Copyright (C) 2025 Open Resin Alliance +* +* 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:test/test.dart'; -import 'dart:convert'; import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; diff --git a/test/odyssey_http_client_test.dart b/test/odyssey_http_client_test.dart new file mode 100644 index 0000000..a08f17a --- /dev/null +++ b/test/odyssey_http_client_test.dart @@ -0,0 +1,49 @@ +/* +* Orion - Odyssey HTTP Client Test +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:orion/backend_service/odyssey/odyssey_http_client.dart'; + +void main() { + test('OdysseyHttpClient getStatus fails fast when backend is unresponsive', + () async { + final client = OdysseyHttpClient( + clientFactory: () => _NeverCompletesClient(), + requestTimeout: const Duration(milliseconds: 25), + ); + + final sw = Stopwatch()..start(); + final future = client.getStatus(); + await expectLater(future, throwsA(isA())); + sw.stop(); + + expect(sw.elapsed, lessThan(const Duration(milliseconds: 300))); + }); +} + +class _NeverCompletesClient extends http.BaseClient { + @override + Future send(http.BaseRequest request) { + return Completer().future; + } + + @override + void close() {} +} diff --git a/test/thumbnail_util_test.dart b/test/thumbnail_util_test.dart index 2ee8847..1c62e51 100644 --- a/test/thumbnail_util_test.dart +++ b/test/thumbnail_util_test.dart @@ -1,5 +1,21 @@ +/* +* Orion - Thumbnail Utility Test +* Copyright (C) 2025 Open Resin Alliance +* +* 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 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -24,11 +40,19 @@ void main() { final path = await ThumbnailUtil.extractThumbnail('local', '', 'test.sl1', client: fake); - // The function returns a path; ensure file exists and contents match + // The function returns a path; ensure file exists and contents look like a PNG final f = File(path); expect(await f.exists(), isTrue); final bytes = await f.readAsBytes(); - expect(bytes, equals(data)); + // Thumbnail extraction produces an encoded image (PNG). Ensure we got + // non-empty bytes and that they start with the PNG signature. + expect(bytes, isNotEmpty); + expect(bytes.length, greaterThanOrEqualTo(8)); + // PNG signature: 137 80 78 71 13 10 26 10 + expect(bytes[0], equals(137)); + expect(bytes[1], equals(80)); + expect(bytes[2], equals(78)); + expect(bytes[3], equals(71)); // Clean up try { From a829519d897a1d1551ba71a4caa26ea2ad05ae0d Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 00:34:42 +0200 Subject: [PATCH 21/47] feat(scripts): Add temporary installer for Orion UI on Athena 2 --- scripts/install_orion_athena2.sh | 305 +++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 scripts/install_orion_athena2.sh diff --git a/scripts/install_orion_athena2.sh b/scripts/install_orion_athena2.sh new file mode 100644 index 0000000..56207b7 --- /dev/null +++ b/scripts/install_orion_athena2.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash + +# Temporary Orion installer for Athena 2 +# Performs the following actions: +# 1. Ensures the script runs with sudo/root privileges. +# 2. Copies /root/nanodlp/hmi/dsi to the invoking user's home directory. +# 3. Downloads Orion release v0.3.2, extracts to ~/orion, and adjusts ownership. +# 4. Creates systemd unit orion.service pointing at the copied dsi binary. +# 5. Disables nanodlp-dsi service and enables the new orion.service. + +set -euo pipefail + +tmp_tar="" +tmp_dir="" + +cleanup() { + if [[ -n "$tmp_tar" && -f "$tmp_tar" ]]; then + rm -f "$tmp_tar" + fi + if [[ -n "$tmp_dir" && -d "$tmp_dir" ]]; then + rm -rf "$tmp_dir" + fi +} + +trap cleanup EXIT + +SCRIPT_NAME="$(basename "$0")" +ORION_URL="https://github.com/Open-Resin-Alliance/Orion/releases/download/BRANCH_nanodlp_basic_support/orion_armv7.tar.gz" +DOWNSAMPLED_RES="200, 200" + +uninstall_orion() { + local mode=${1:-manual} + local enable_nano=1 + if [[ $mode == "reinstall" ]]; then + enable_nano=0 + fi + + printf '\n[%s] Removing existing Orion installation...\n' "$SCRIPT_NAME" + + if systemctl list-unit-files | grep -q '^orion.service'; then + systemctl disable --now orion.service || true + else + systemctl stop orion.service 2>/dev/null || true + systemctl disable orion.service 2>/dev/null || true + fi + rm -f "$SERVICE_PATH" + + systemctl daemon-reload + + if (( enable_nano )); then + if systemctl list-unit-files | grep -q '^nanodlp-dsi.service'; then + systemctl enable nanodlp-dsi.service || true + systemctl start nanodlp-dsi.service || true + fi + fi + + rm -rf "$ORION_DIR" + rm -f "$DEST_DSI" + if [[ -f "$CONFIG_PATH" ]]; then + rm -f "$CONFIG_PATH" + fi + + rm -f "$ACTIVATE_PATH" "$REVERT_PATH" + + if [[ $mode != "reinstall" ]]; then + printf '\n[%s] Orion has been removed from this system.\n' "$SCRIPT_NAME" + else + printf '[%s] Previous installation cleared; continuing with reinstall...\n' "$SCRIPT_NAME" + fi +} + +require_root() { + if [[ $EUID -ne 0 ]]; then + if ! command -v sudo >/dev/null 2>&1; then + printf '\n[%s] This script must be run as root or via sudo.\n' "$SCRIPT_NAME" >&2 + exit 1 + fi + printf '\n[%s] Elevating privileges with sudo...\n' "$SCRIPT_NAME" + exec sudo -E bash "$0" "$@" + fi +} + +main() { + require_root "$@" + + ORIGINAL_USER=${SUDO_USER:-} + if [[ -z "$ORIGINAL_USER" || "$ORIGINAL_USER" == "root" ]]; then + printf '\n[%s] Unable to determine invoking non-root user. Please run with sudo from the target account.\n' "$SCRIPT_NAME" >&2 + exit 1 + fi + + for dep in curl install systemctl tar; do + if ! command -v "$dep" >/dev/null 2>&1; then + printf '\n[%s] Required command "%s" not found in PATH.\n' "$SCRIPT_NAME" "$dep" >&2 + exit 1 + fi + done + + TARGET_HOME=$(eval echo "~${ORIGINAL_USER}") + if [[ ! -d "$TARGET_HOME" ]]; then + printf '\n[%s] Home directory for %s not found at %s.\n' "$SCRIPT_NAME" "$ORIGINAL_USER" "$TARGET_HOME" >&2 + exit 1 + fi + + if [[ "$TARGET_HOME" != /home/* ]]; then + printf '\n[%s] Expected %s to reside under /home (got %s). Adjust the script before proceeding.\n' "$SCRIPT_NAME" "$ORIGINAL_USER" "$TARGET_HOME" >&2 + exit 1 + fi + + SRC_DSI="/root/nanodlp/hmi/dsi" + DEST_DSI="${TARGET_HOME}/dsi" + ORION_DIR="${TARGET_HOME}/orion" + SERVICE_PATH="/etc/systemd/system/orion.service" + CONFIG_PATH="${TARGET_HOME}/orion.cfg" + BIN_DIR="/usr/local/bin" + ACTIVATE_PATH="${BIN_DIR}/activate_orion" + REVERT_PATH="${BIN_DIR}/revert_orion" + + printf '\nTemporary Orion installer for Athena 2\n========================================\n' + printf ' - Target user : %s\n' "$ORIGINAL_USER" + printf ' - Target home : %s\n' "$TARGET_HOME" + printf ' - Orion source : %s\n\n' "$ORION_URL" + + read -r -p "Continue with installation? [y/N] " reply + reply=${reply:-N} + if [[ ! $reply =~ ^[Yy]$ ]]; then + printf '\n[%s] Installation aborted by user.\n' "$SCRIPT_NAME" + exit 0 + fi + + local existing=false + if [[ -d "$ORION_DIR" || -f "$SERVICE_PATH" || -f "$CONFIG_PATH" || -f "$DEST_DSI" ]]; then + existing=true + fi + + if [[ $existing == true ]]; then + printf '\n[%s] Existing Orion installation detected.\n' "$SCRIPT_NAME" + while true; do + read -r -p "Choose: [O]verride & reinstall / [U]ninstall / [C]ancel: " choice + choice=${choice:-C} + case "$choice" in + [Oo]) + uninstall_orion reinstall + break + ;; + [Uu]) + uninstall_orion manual + exit 0 + ;; + [Cc]) + printf '\n[%s] Operation cancelled.\n' "$SCRIPT_NAME" + exit 0 + ;; + *) + printf ' Invalid selection. Please choose O, U, or C.\n' + ;; + esac + done + fi + + if [[ ! -x "$SRC_DSI" ]]; then + printf '\n[%s] Required source binary %s not found or not executable.\n' "$SCRIPT_NAME" "$SRC_DSI" >&2 + exit 1 + fi + + printf '\n[%s] Copying dsi binary to %s...\n' "$SCRIPT_NAME" "$DEST_DSI" + install -m 0755 "$SRC_DSI" "$DEST_DSI" + chown "$ORIGINAL_USER":"$ORIGINAL_USER" "$DEST_DSI" + + tmp_tar=$(mktemp) + tmp_dir=$(mktemp -d) + + printf '\n[%s] Downloading Orion release...\n' "$SCRIPT_NAME" + curl -Lf "$ORION_URL" -o "$tmp_tar" + + printf '[%s] Extracting archive to %s...\n' "$SCRIPT_NAME" "$ORION_DIR" + rm -rf "$ORION_DIR" + mkdir -p "$ORION_DIR" + tar -xzf "$tmp_tar" -C "$tmp_dir" + if [[ -d "$tmp_dir/orion" ]]; then + cp -a "$tmp_dir/orion/." "$ORION_DIR/" + else + cp -a "$tmp_dir/." "$ORION_DIR/" + fi + chown -R "$ORIGINAL_USER":"$ORIGINAL_USER" "$ORION_DIR" + + if [[ -f "$CONFIG_PATH" ]]; then + local ts + ts=$(date +%s) + cp "$CONFIG_PATH" "${CONFIG_PATH}.bak.${ts}" + fi + HOSTNAME_VALUE=$(hostname) + printf '\n[%s] Writing default Orion configuration to %s...\n' "$SCRIPT_NAME" "$CONFIG_PATH" + cat >"$CONFIG_PATH" <"$SERVICE_PATH" </dev/null || true + systemctl disable nanodlp-dsi.service 2>/dev/null || true + fi + + printf '\n[%s] Enabling and starting orion.service...\n' "$SCRIPT_NAME" + systemctl daemon-reload + systemctl enable orion.service + systemctl start orion.service + + mkdir -p "$BIN_DIR" + + printf '\n[%s] Installing helper commands: %s and %s...\n' "$SCRIPT_NAME" "$ACTIVATE_PATH" "$REVERT_PATH" + + cat >"$ACTIVATE_PATH" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ $EUID -ne 0 ]]; then + if command -v sudo >/dev/null 2>&1; then + exec sudo -E "$0" "$@" + else + echo "activate_orion must be run as root or via sudo" >&2 + exit 1 + fi +fi + +systemctl stop nanodlp-dsi.service 2>/dev/null || true +systemctl disable nanodlp-dsi.service 2>/dev/null || true +systemctl daemon-reload +systemctl enable orion.service +systemctl start orion.service +EOF + chmod 0755 "$ACTIVATE_PATH" + + cat >"$REVERT_PATH" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ $EUID -ne 0 ]]; then + if command -v sudo >/dev/null 2>&1; then + exec sudo -E "$0" "$@" + else + echo "revert_orion must be run as root or via sudo" >&2 + exit 1 + fi +fi + +systemctl stop orion.service 2>/dev/null || true +systemctl disable orion.service 2>/dev/null || true +systemctl daemon-reload +systemctl enable nanodlp-dsi.service +systemctl start nanodlp-dsi.service +EOF + chmod 0755 "$REVERT_PATH" + + printf '\nInstallation complete!\n' + printf ' Default config written to %s.\n' "$CONFIG_PATH" + printf ' Use "activate_orion" to launch Orion and "revert_orion" to restore NanoDLP.\n' + systemctl status orion.service --no-pager +} + +main "$@" From ca281663643d8304699f7d8174b07ab826b0e559 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 01:06:14 +0200 Subject: [PATCH 22/47] TEMPORARY: feat(settings): Add beta testing section with revert functionality in GeneralCfgScreen --- lib/settings/general_screen.dart | 136 +++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/lib/settings/general_screen.dart b/lib/settings/general_screen.dart index 26e3612..1aad32c 100644 --- a/lib/settings/general_screen.dart +++ b/lib/settings/general_screen.dart @@ -55,6 +55,8 @@ class GeneralCfgScreenState extends State { late bool verboseLogging; late bool selfDestructMode; late String machineName; + bool _isReverting = false; + String? _revertOutput; late String originalRotation; @@ -100,6 +102,77 @@ class GeneralCfgScreenState extends State { machineName = config.getString('machineName', category: 'machine'); } + GlassCard _buildBetaTestingSection() { + return GlassCard( + outlined: true, + elevation: 1, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Beta Testing', + style: TextStyle( + fontSize: 28.0, + ), + ), + const SizedBox(height: 12.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox( + height: 48, + child: GlassButton( + style: ElevatedButton.styleFrom(elevation: 2), + onPressed: _isReverting + ? null + : () async { + final confirm = await showDialog( + context: context, + builder: (context) => GlassAlertDialog( + title: const Text('Confirm NanoDLP Revert'), + content: const Text( + 'This will revert from the Orion Beta to the NanoDLP HMI. Do you want to continue?\n\nRe-activating Orion later will require SSH access.\nRun "activate_orion" to re-enable Orion.'), + actions: [ + GlassButton( + onPressed: () => + Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + GlassButton( + onPressed: () => + Navigator.of(context).pop(true), + child: const Text('Run'), + ), + ], + ), + ); + if (confirm != true) return; + _runRevertOrion(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.restore, size: 20), + const SizedBox(width: 8), + _isReverting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Revert to NanoDLP HMI'), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -513,6 +586,8 @@ class GeneralCfgScreenState extends State { /// Developer Section for build overrides. if (developerMode) _buildDeveloperSection(), + if (developerMode) const SizedBox(height: 12.0), + if (developerMode) _buildBetaTestingSection(), ], ), ), @@ -669,6 +744,67 @@ class GeneralCfgScreenState extends State { super.dispose(); } + Future _runRevertOrion() async { + setState(() { + _isReverting = true; + _revertOutput = null; + }); + try { + // Use Process.run to execute the system command. This is intended for + // developer machines where the CLI helper `revert_orion` is available. + final result = await Process.run('revert_orion', [], runInShell: true); + final output = []; + if (result.stdout != null && result.stdout.toString().isNotEmpty) { + output.add('STDOUT:\n${result.stdout}'); + } + if (result.stderr != null && result.stderr.toString().isNotEmpty) { + output.add('STDERR:\n${result.stderr}'); + } + output.add('Exit code: ${result.exitCode}'); + _revertOutput = output.join('\n\n'); + // Show the output to the user + if (mounted) { + await showDialog( + context: context, + builder: (context) => GlassAlertDialog( + title: const Text('Revert Orion Result'), + content: SingleChildScrollView( + child: SelectableText(_revertOutput ?? 'No output'), + ), + actions: [ + GlassButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + } catch (e) { + if (mounted) { + await showDialog( + context: context, + builder: (context) => GlassAlertDialog( + title: const Text('Error'), + content: Text('Failed to run revert_orion: $e'), + actions: [ + GlassButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isReverting = false; + }); + } + } + } + void _showReleaseDialog() { // Reset state before showing dialog setState(() { From 27731901c54facf34b18ef869bc779401a47caec Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 13:26:50 +0200 Subject: [PATCH 23/47] feat(scripts): Update Orion installer to ensure NanoDLP service is enabled before disabling Orion service - use correct update branch --- scripts/install_orion_athena2.sh | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/install_orion_athena2.sh b/scripts/install_orion_athena2.sh index 56207b7..b942126 100644 --- a/scripts/install_orion_athena2.sh +++ b/scripts/install_orion_athena2.sh @@ -209,7 +209,7 @@ main() { }, "developer": { "releaseOverride": true, - "overrideRelease": "BRANCH_api_refactor", + "overrideRelease": "BRANCH_nanodlp_basic_support", "overrideUpdateCheck": false }, "topsecret": { @@ -287,12 +287,21 @@ if [[ $EUID -ne 0 ]]; then exit 1 fi fi +# Ensure NanoDLP service is enabled and started first so the system has a +# functioning UI/service to use once Orion is removed. +systemctl enable nanodlp-dsi.service || true +systemctl start nanodlp-dsi.service || true -systemctl stop orion.service 2>/dev/null || true -systemctl disable orion.service 2>/dev/null || true -systemctl daemon-reload -systemctl enable nanodlp-dsi.service -systemctl start nanodlp-dsi.service +# Reload systemd configuration in case units changed. +systemctl daemon-reload || true + +# Schedule disabling and stopping of the Orion service asynchronously. +# We run this in a detached background process (via nohup) so that if +# this script was invoked from the running Orion service, stopping Orion +# won't kill this helper before it can finish and return output. +nohup bash -c 'sleep 2; systemctl disable --now orion.service >/dev/null 2>&1 || true' >/dev/null 2>&1 & + +echo "NanoDLP enabled and started. Orion will be disabled/stopped shortly." EOF chmod 0755 "$REVERT_PATH" From 50216ec74faeb53919e8471691aa3133a39990b2 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 14:05:18 +0200 Subject: [PATCH 24/47] feat(util): ThumbnailCache: add forceRefresh option to getThumbnail for fetching fresh thumbnails --- lib/util/thumbnail_cache.dart | 40 +++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/lib/util/thumbnail_cache.dart b/lib/util/thumbnail_cache.dart index becf32d..b713943 100644 --- a/lib/util/thumbnail_cache.dart +++ b/lib/util/thumbnail_cache.dart @@ -51,9 +51,37 @@ class ThumbnailCache { required String fileName, required OrionApiFile file, String size = 'Small', + bool forceRefresh = false, }) async { _pruneExpired(); + if (forceRefresh) { + // Bypass memory/disk caches and fetch fresh bytes. Still dedupe in-flight + // requests by key to avoid duplicate network work. + final key = _cacheKey(location, file, size); + final inFlight = _inFlight[key]; + if (inFlight != null) return inFlight; + + final future = ThumbnailUtil.extractThumbnailBytes( + location, + subdirectory, + fileName, + size: size, + ).then((bytes) { + _store(key, bytes); + _inFlight.remove(key); + return bytes; + }).catchError((Object error, StackTrace stack) { + _log.fine('Thumbnail fetch failed for ${file.path}', error, stack); + _store(key, null); + _inFlight.remove(key); + return null; + }); + + _inFlight[key] = future; + return future; + } + // Try to find any cached entry for the same path+size regardless of // lastModified. This avoids cache misses when the provider recreates // OrionApiFile instances with differing lastModified values while the @@ -428,17 +456,7 @@ class ThumbnailCache { } } - /// Extracts the lastModified component (epoch seconds) from a cache key - /// shaped as '`|||' and returns a - /// human-readable UTC timestamp. If parsing fails, throws. - String _dateFromCacheKey(String keyOrDecoded) { - final parts = keyOrDecoded.split('|'); - if (parts.length < 3) throw Exception('invalid cache key'); - final lm = int.tryParse(parts[2]) ?? 0; - if (lm == 0) return 'epoch:0'; - final dt = DateTime.fromMillisecondsSinceEpoch(lm * 1000, isUtc: true); - return dt.toIso8601String(); - } + // _dateFromCacheKey removed (unused) } class _CacheEntry { From 1f0e9d66573484b364d5be5a985a1d367b040e92 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 5 Oct 2025 14:06:20 +0200 Subject: [PATCH 25/47] feat(main): add status listener to instantly show active prints when discovered --- .../providers/status_provider.dart | 104 +++++++++++- lib/main.dart | 148 +++++++++++++----- lib/status/status_screen.dart | 60 ++++++- 3 files changed, 265 insertions(+), 47 deletions(-) diff --git a/lib/backend_service/providers/status_provider.dart b/lib/backend_service/providers/status_provider.dart index 0322df1..28728cf 100644 --- a/lib/backend_service/providers/status_provider.dart +++ b/lib/backend_service/providers/status_provider.dart @@ -27,6 +27,7 @@ import 'package:orion/util/orion_config.dart'; import 'package:orion/backend_service/odyssey/models/status_models.dart'; import 'dart:typed_data'; import 'package:orion/util/thumbnail_cache.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; /// Polls the backend printer `/status` endpoint and exposes a typed [StatusModel]. @@ -80,6 +81,9 @@ class StatusProvider extends ChangeNotifier { bool _thumbnailReady = false; // becomes true once we have a path OR decide none needed bool get thumbnailReady => _thumbnailReady; + // Retry tracking for placeholder thumbnails. Keyed by file path. + final Map _thumbnailRetries = {}; + static const int _maxThumbnailRetries = 3; /// Readiness for displaying a new print after a reset. /// Requirements: @@ -265,16 +269,16 @@ class StatusProvider extends ChangeNotifier { lastModified: 0, locationCategory: fileData.locationCategory, ); - _thumbnailBytes = await ThumbnailCache.instance.getThumbnail( + await _fetchAndHandleThumbnail( location: fileData.locationCategory ?? 'Local', subdirectory: subdir, fileName: fileData.name, file: file, size: 'Large', ); - _thumbnailReady = true; } catch (e, st) { _log.warning('Thumbnail fetch failed (SSE)', e, st); + // Mark ready to avoid indefinite spinner when fetch errors _thumbnailReady = true; } } @@ -493,14 +497,13 @@ class StatusProvider extends ChangeNotifier { lastModified: 0, locationCategory: fileData.locationCategory, ); - _thumbnailBytes = await ThumbnailCache.instance.getThumbnail( + await _fetchAndHandleThumbnail( location: fileData.locationCategory ?? 'Local', subdirectory: subdir, fileName: fileData.name, file: file, size: 'Large', ); - _thumbnailReady = true; } catch (e, st) { _log.warning('Thumbnail fetch failed', e, st); // Even on failure we mark ready to avoid indefinite spinner. @@ -757,6 +760,8 @@ class StatusProvider extends ChangeNotifier { _isPausing = false; _awaitingNewPrintData = true; // begin awaiting active print _awaitingSince = DateTime.now(); + // Clear thumbnail retry counters for fresh session + _thumbnailRetries.clear(); // If an initial file path or plate id is provided we may use it to // resolve thumbnails faster in NanoDLP adapters; store on status // model is not needed here but provider consumers can access @@ -764,6 +769,88 @@ class StatusProvider extends ChangeNotifier { notifyListeners(); } + Future _fetchAndHandleThumbnail({ + required String location, + required String subdirectory, + required String fileName, + required OrionApiFile file, + String size = 'Large', + }) async { + final pathKey = file.path; + try { + final bytes = await ThumbnailCache.instance.getThumbnail( + location: location, + subdirectory: subdirectory, + fileName: fileName, + file: file, + size: size, + ); + + if (bytes == null) { + _thumbnailBytes = null; + _thumbnailReady = true; + return; + } + + final placeholder = NanoDlpThumbnailGenerator.generatePlaceholder( + NanoDlpThumbnailGenerator.largeWidth, + NanoDlpThumbnailGenerator.largeHeight); + final isPlaceholder = + bytes.length == placeholder.length && _bytesEqual(bytes, placeholder); + + // If we already have a real thumbnail cached in provider, keep it. + if (isPlaceholder) { + if (_thumbnailBytes != null && + !_bytesEqual(_thumbnailBytes!, placeholder)) { + _thumbnailReady = true; + return; + } + + final tried = (_thumbnailRetries[pathKey] ?? 0); + if (tried < _maxThumbnailRetries) { + _thumbnailRetries[pathKey] = tried + 1; + final fresh = await ThumbnailCache.instance.getThumbnail( + location: location, + subdirectory: subdirectory, + fileName: fileName, + file: file, + size: size, + forceRefresh: true, + ); + if (fresh != null) { + final freshIsPlaceholder = fresh.length == placeholder.length && + _bytesEqual(fresh, placeholder); + if (!freshIsPlaceholder) { + _thumbnailBytes = fresh; + _thumbnailReady = true; + return; + } + } + // keep trying on subsequent polls; don't mark ready yet + return; + } + + // Exhausted retries: accept placeholder if we don't have a real one + if (_thumbnailBytes == null || + _bytesEqual(_thumbnailBytes!, placeholder)) { + _thumbnailBytes = bytes; + } + _thumbnailReady = true; + return; + } + + // Normal: use the non-placeholder bytes + _thumbnailBytes = bytes; + _thumbnailReady = true; + return; + } catch (e, st) { + _log.warning('Thumbnail fetch failed', e, st); + _thumbnailBytes = null; + _thumbnailReady = true; + return; + } + } + @override void dispose() { _disposed = true; @@ -771,3 +858,12 @@ class StatusProvider extends ChangeNotifier { super.dispose(); } } + +bool _bytesEqual(Uint8List a, Uint8List b) { + if (identical(a, b)) return true; + if (a.lengthInBytes != b.lengthInBytes) return false; + for (int i = 0; i < a.lengthInBytes; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/lib/main.dart b/lib/main.dart index 4950f84..9da7266 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -177,6 +177,9 @@ class OrionMainAppState extends State { late final GoRouter _router; ConnectionErrorWatcher? _connWatcher; final GlobalKey _navKey = GlobalKey(); + bool _statusListenerAttached = false; + bool _wasPrinting = false; + bool _isStatusShown = false; // navigatorKey removed; using MaterialApp.router builder context instead @override @@ -259,46 +262,119 @@ class OrionMainAppState extends State { return Consumer2( builder: (context, localeProvider, themeProvider, child) { return Provider.value( - value: - themeProvider.setThemeMode, // Use ThemeProvider's method directly - child: GlassApp( - child: Builder(builder: (innerCtx) { - // Use MaterialApp.router's builder to get a context that has - // MaterialLocalizations and a Navigator. Install the watcher - // after the first frame using that context. - return MaterialApp.router( - title: 'Orion', - debugShowCheckedModeBanner: false, - routerConfig: _router, - theme: themeProvider.lightTheme, - builder: (ctx, child) { - WidgetsBinding.instance.addPostFrameCallback((_) { - try { - final navCtx = _navKey.currentContext; - if (_connWatcher == null && navCtx != null) { - _connWatcher = ConnectionErrorWatcher.install(navCtx); - } - } catch (_) {} - }); - return child ?? const SizedBox.shrink(); - }, - darkTheme: themeProvider.darkTheme, - themeMode: themeProvider.themeMode, - locale: localeProvider.locale, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - ); - }), - ), + value: themeProvider.setThemeMode, + child: _buildAppShell(localeProvider, themeProvider), ); }, ); } + + Widget _buildAppShell( + LocaleProvider localeProvider, ThemeProvider themeProvider) { + return GlassApp( + child: Builder(builder: (innerCtx) { + // Use MaterialApp.router's builder to get a context that has + // MaterialLocalizations and a Navigator. Install the watcher + // after the first frame using that context. + return MaterialApp.router( + title: 'Orion', + debugShowCheckedModeBanner: false, + routerConfig: _router, + theme: themeProvider.lightTheme, + builder: (ctx, child) { + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + final navCtx = _navKey.currentContext; + if (_connWatcher == null && navCtx != null) { + _connWatcher = ConnectionErrorWatcher.install(navCtx); + } + // Attach a listener to StatusProvider so we can auto-open + // the StatusScreen when a print becomes active (remote start). + try { + if (!_statusListenerAttached && navCtx != null) { + final statusProv = + Provider.of(navCtx, listen: false); + statusProv.addListener(() { + try { + final s = statusProv.status; + final active = + (s?.isPrinting == true) || (s?.isPaused == true); + if (active && !_wasPrinting) { + // Only navigate if not already on /status + // Navigate to status on transition to active print. + try { + final navState = _navKey.currentState; + final sModel = statusProv.status; + final initialThumb = statusProv.thumbnailBytes; + final initialPath = + sModel?.printData?.fileData?.path; + // Avoid pushing if we're already showing the + // Status screen or we've already pushed it. + try { + if (_isStatusShown) { + // nothing to do + } else if (navState != null) { + _isStatusShown = true; + navState + .push(MaterialPageRoute( + builder: (ctx) => StatusScreen( + newPrint: false, + initialThumbnailBytes: initialThumb, + initialFilePath: initialPath, + ), + )) + .then((_) { + // cleared when the pushed route is popped + _isStatusShown = false; + }); + } else { + _isStatusShown = true; + _router.go('/status'); + // best-effort: we can't await router navigation + // easily here, so keep the flag set briefly + Future.delayed(const Duration(seconds: 1), () { + _isStatusShown = false; + }); + } + } catch (_) { + // Fallback to router navigation if push fails + if (!_isStatusShown) { + _isStatusShown = true; + _router.go('/status'); + Future.delayed(const Duration(seconds: 1), () { + _isStatusShown = false; + }); + } + } + } catch (_) { + // Fallback to router navigation if push fails + _router.go('/status'); + } + } + _wasPrinting = active; + } catch (_) {} + }); + _statusListenerAttached = true; + } + } catch (_) {} + } catch (_) {} + }); + return child ?? const SizedBox.shrink(); + }, + darkTheme: themeProvider.darkTheme, + themeMode: themeProvider.themeMode, + locale: localeProvider.locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + ); + }), + ); + } } class Mutex { diff --git a/lib/status/status_screen.dart b/lib/status/status_screen.dart index 0549fbd..e5c2b84 100644 --- a/lib/status/status_screen.dart +++ b/lib/status/status_screen.dart @@ -21,6 +21,7 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'dart:typed_data'; +import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; import 'package:orion/files/grid_files_screen.dart'; import 'package:orion/glasser/glasser.dart'; @@ -85,6 +86,15 @@ class StatusScreenState extends State { } } + bool _bytesEqual(Uint8List a, Uint8List b) { + if (identical(a, b)) return true; + if (a.lengthInBytes != b.lengthInBytes) return false; + for (int i = 0; i < a.lengthInBytes; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + @override Widget build(BuildContext context) { return Consumer( @@ -116,22 +126,34 @@ class StatusScreenState extends State { // exposes a typed StatusModel. The screen now focuses solely on presentation. // Show global loading while provider indicates loading, we have no status yet, - // or (for new prints) until the provider signals the print is ready - // (we have active job+file metadata+thumbnail). However, if the - // backend reports the job has already finished (idle with layer data) - // or is canceled we should not remain in a spinner indefinitely — - // render the final status instead. + // or while the thumbnail is still being prepared. For new prints we + // continue to wait until the provider signals the print is ready + // (we have active job+file metadata+thumbnail). For auto-open (newPrint + // == false) we also show a spinner while the provider is still fetching + // the thumbnail so the UI doesn't immediately render a stale/placeholder + // preview. However, if the backend reports the job has already finished + // (idle with layer data) or is canceled we should not remain in a + // spinner indefinitely — render the final status instead. final bool finishedSnapshot = status?.isIdle == true && status?.layer != null; final bool canceledSnapshot = status?.isCanceled == true; + final bool thumbnailLoadingForAutoOpen = + !widget.newPrint && // only apply to auto-open path + status != null && + (status.isPrinting || status.isPaused) && + !provider.thumbnailReady && + !finishedSnapshot && + !canceledSnapshot; + if (_suppressOldStatus || provider.isLoading || status == null || + thumbnailLoadingForAutoOpen || // If this screen was opened as a new print, wait until the // provider reports the job is ready to display. But allow // finished/canceled snapshots through so the UI doesn't lock up. - ((widget.newPrint || awaiting) && + ((widget.newPrint && awaiting) && !newPrintReady && !finishedSnapshot && !canceledSnapshot)) { @@ -413,7 +435,31 @@ class StatusScreenState extends State { Widget _buildThumbnailView( BuildContext context, StatusProvider provider, StatusModel? status) { - final thumbnail = provider.thumbnailBytes; + // Prefer provider's thumbnail bytes. If none yet, consider the + // initialThumbnailBytes passed from the Details screen — but do not + // show a generated placeholder as the initial preview while the + // provider is still probing for a real preview. In that case show the + // spinner until provider provides a non-placeholder or finishes. + Uint8List? thumbnail; + if (provider.thumbnailBytes != null) { + thumbnail = provider.thumbnailBytes; + } else if (widget.initialThumbnailBytes != null) { + // Detect whether the provided initial bytes are the NanoDLP generated + // placeholder. If so and provider isn't ready yet, prefer spinner. + final placeholder = NanoDlpThumbnailGenerator.generatePlaceholder( + NanoDlpThumbnailGenerator.largeWidth, + NanoDlpThumbnailGenerator.largeHeight); + bool isPlaceholder = + widget.initialThumbnailBytes!.length == placeholder.length && + _bytesEqual(widget.initialThumbnailBytes!, placeholder); + if (isPlaceholder && !provider.thumbnailReady) { + thumbnail = null; + } else { + thumbnail = widget.initialThumbnailBytes; + } + } else { + thumbnail = null; + } final themeProvider = Provider.of(context); final progress = provider.progress; final statusColor = provider.statusColor(context); From 42cff4e26dfbaa47593772bc8d6ff0e44c478109 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Wed, 8 Oct 2025 01:54:34 +0200 Subject: [PATCH 26/47] feat(backend_service): NanoStatus: add stateCode field for granular machine state values --- .../nanodlp/models/nano_status.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/backend_service/nanodlp/models/nano_status.dart b/lib/backend_service/nanodlp/models/nano_status.dart index 35a06be..b3acbe5 100644 --- a/lib/backend_service/nanodlp/models/nano_status.dart +++ b/lib/backend_service/nanodlp/models/nano_status.dart @@ -33,6 +33,7 @@ class NanoStatus { // Existing convenience fields final String state; // 'printing' | 'paused' | 'idle' + final int? stateCode; // raw numeric 'State' field from NanoDLP when present final double? progress; // 0.0 - 1.0 final NanoFile? file; // not always present in NanoDLP status final double? z; // z position (converted if needed) @@ -50,6 +51,7 @@ class NanoStatus { this.mcuTemp, this.rawJsonStatus, required this.state, + this.stateCode, this.progress, this.file, this.z, @@ -141,6 +143,17 @@ class NanoStatus { state = 'idle'; } + // Try to parse a numeric State field when present. NanoDLP may include + // a 'State' integer that provides more granular machine state values. + int? stateCode; + try { + final sc = json['State'] ?? json['state'] ?? json['STATE']; + if (sc is int) stateCode = sc; + if (sc is String) stateCode = int.tryParse(sc); + } catch (_) { + stateCode = null; + } + double? progress; if (layerId != null && layersCount != null && layersCount > 0) { progress = (layerId / layersCount).clamp(0.0, 1.0).toDouble(); @@ -167,6 +180,7 @@ class NanoStatus { mcuTemp: mcuTemp, rawJsonStatus: json['Status']?.toString() ?? json.toString(), state: state, + stateCode: stateCode, progress: progress, file: nf, z: z, @@ -185,6 +199,7 @@ class NanoStatus { 'temp': temp, 'mcuTemp': mcuTemp, 'state': state, + 'stateCode': stateCode, 'progress': progress, 'file': file?.toJson(), 'z': z, From 3f1240ffe3b278aab263f1505e3c7d315e4a6ca1 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Wed, 8 Oct 2025 02:03:59 +0200 Subject: [PATCH 27/47] feat(backend_service): add cancelLatched and finished fields to StatusModel --- .../odyssey/models/status_models.dart | 23 ++++++++++++++++--- .../odyssey/models/status_models.g.dart | 4 ++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/backend_service/odyssey/models/status_models.dart b/lib/backend_service/odyssey/models/status_models.dart index f1a40ee..c122a28 100644 --- a/lib/backend_service/odyssey/models/status_models.dart +++ b/lib/backend_service/odyssey/models/status_models.dart @@ -1,7 +1,19 @@ /* - * Orion - Generated Status Models (json_serializable) - * Strong typing for /status endpoint with convenience getters. - */ +* Orion - Odyssey Status Models +* Copyright (C) 2025 Open Resin Alliance +* +* 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/material.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -19,6 +31,9 @@ class StatusModel { final String status; final bool? paused; final int? layer; + @JsonKey(name: 'cancel_latched') + final bool? cancelLatched; + final bool? finished; @JsonKey(name: 'print_data') final PrintData? printData; @JsonKey(name: 'physical_state') @@ -28,6 +43,8 @@ class StatusModel { required this.status, required this.paused, required this.layer, + this.cancelLatched, + this.finished, required this.printData, required this.physicalState, }); diff --git a/lib/backend_service/odyssey/models/status_models.g.dart b/lib/backend_service/odyssey/models/status_models.g.dart index 5687113..a31e6b6 100644 --- a/lib/backend_service/odyssey/models/status_models.g.dart +++ b/lib/backend_service/odyssey/models/status_models.g.dart @@ -10,6 +10,8 @@ StatusModel _$StatusModelFromJson(Map json) => StatusModel( status: json['status'] as String, paused: json['paused'] as bool?, layer: (json['layer'] as num?)?.toInt(), + cancelLatched: json['cancel_latched'] as bool?, + finished: json['finished'] as bool?, printData: json['print_data'] == null ? null : PrintData.fromJson(json['print_data'] as Map), @@ -22,6 +24,8 @@ Map _$StatusModelToJson(StatusModel instance) => 'status': instance.status, 'paused': instance.paused, 'layer': instance.layer, + 'cancel_latched': instance.cancelLatched, + 'finished': instance.finished, 'print_data': instance.printData?.toJson(), 'physical_state': instance.physicalState.toJson(), }; From 4fbd1c2ba28f692ca3ab6aa834bfcd5252792e29 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Wed, 8 Oct 2025 02:04:24 +0200 Subject: [PATCH 28/47] feat(backend_service): implement NanoDlpStateHandler for managing transient states --- .../nanodlp/nanodlp_state_handler.dart | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 lib/backend_service/nanodlp/nanodlp_state_handler.dart diff --git a/lib/backend_service/nanodlp/nanodlp_state_handler.dart b/lib/backend_service/nanodlp/nanodlp_state_handler.dart new file mode 100644 index 0000000..12824ea --- /dev/null +++ b/lib/backend_service/nanodlp/nanodlp_state_handler.dart @@ -0,0 +1,256 @@ +/* +* Orion - NanoDLP State Handler +* Copyright (C) 2025 Open Resin Alliance +* +* 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:logging/logging.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; + +/// NanoDLP state handler with simple latching for transient states. +/// +/// NanoDLP `State` codes (observed): +/// 0 = Idle +/// 1 = Starting/ending a print (transient) +/// 2 = Pause-request (transient) +/// 3 = Paused (stable) +/// 4 = Cancel-request (transient but must be latched) +/// 5 = Active print (stable) +/// +/// This handler keeps a small in-memory latch for an active cancel-request +/// so that once State==4 is observed the client will remember that a +/// cancellation is in-flight until the device returns to State==0 (idle), +/// at which point the latch clears. The latch also clears when a new print +/// begins (State moves from 0->1) — i.e., a successful new-print unlatches. +class NanoDlpStateHandler { + // Singleton-like instance is fine for the provider-level lifecycle. + NanoDlpStateHandler(); + + bool _cancelLatched = false; + int _prevStateCode = -1; + int _lastReportedStateCode = -999; + String? _lastReportedStatus; + bool? _lastReportedCancelLatched; + bool? _lastReportedPauseLatched; + bool? _lastReportedFinished; + final Logger _log = Logger('NanoDlpStateHandler'); + + /// Reset internal latches (useful for new-session or explicit reset). + void reset() { + _cancelLatched = false; + } + + /// Update the handler with the latest observed NanoStatus and return a + /// canonical mapping describing whether the device should be considered + /// printing, paused, or in a cancel-request. + /// + /// The returned map contains: + /// - 'status': String one of 'Printing'|'Paused'|'Idle'|'Canceling' + /// - 'paused': bool, authoritative paused flag + /// - 'cancel_latched': bool, whether a cancel request is currently latched + Map canonicalize(NanoStatus ns) { + // Prefer numeric state code when present. If missing, infer a numeric + // code from other convenience fields so we can reason about transitions + // deterministically (avoids logging -1/unobserved states). + final sc = ns.stateCode; + int stateCode; + if (sc != null) { + stateCode = sc; + } else { + // Map textual state or booleans to approximate numeric codes: + // 'printing' -> 5, 'paused' -> 3, 'idle' -> 0. + final text = ns.state.toLowerCase(); + if (text == 'printing' || ns.printing == true) { + stateCode = 5; + } else if (text == 'paused' || ns.paused == true) { + stateCode = 3; + } else if (text == 'idle') { + stateCode = 0; + } else { + stateCode = -1; + } + } + + // Update latches based on observed state code/value + // Latching rules: + // - When we observe state==4, latch cancel until we see state==0 (idle) + // which indicates the cancel completed. The latch should survive + // transient transitions (e.g., 4 -> 1). + // - If we observe a new print starting from idle (prev==0 && state==1), + // this is a fresh job and we should clear the cancel latch. + if (stateCode == 4) { + _cancelLatched = true; + } + + if (_prevStateCode == 0 && stateCode == 1) { + // New print started from idle — clear any previous cancel latch. + _cancelLatched = false; + } + + // If device returns to idle and we had a cancel latched, report Idle with + // the latch indicated. Do NOT clear the latch here — we only clear the + // cancel latch when a new print starts (0 -> 1). Keeping the latch + // through repeated idle snapshots allows callers to reliably detect + // that a cancel completed until a fresh job begins. + // If we return to idle and a cancel is latched, report Idle with the + // latch indicated. However, transitions from transient 1->0 (start->idle) + // can mean a normal print finish; only consider the snapshot canceled + // (latched) if we previously observed an explicit cancel request (4). + if (stateCode == 0 && _cancelLatched) { + final result = { + 'status': 'Idle', + 'paused': false, + 'cancel_latched': true, + 'finished': false, + }; + // Log only when something changed + _reportIfChanged( + stateCode, result['status'] as String, true, false, false); + _prevStateCode = stateCode; + return result; + } + + // Now map to canonical status. Rules: + // - If cancel latch is set -> status 'Canceling' and paused=false + // - State 3 -> Paused + // - State 1 or 5 -> Printing + // - State 2 -> treat as Printing but not paused (it's a request) + // - Fallback: use ns.paused or ns.printing + if (_cancelLatched) { + final result = { + 'status': 'Canceling', + 'paused': false, + 'cancel_latched': true, + 'pause_latched': false, + 'finished': false, + }; + _reportIfChanged( + stateCode, result['status'] as String, true, false, false); + _prevStateCode = stateCode; + return result; + } + + // Update previous state for next call. + _prevStateCode = stateCode; + + if (stateCode == 3) { + final result = { + 'status': 'Paused', + 'paused': true, + 'cancel_latched': false, + 'finished': false, + }; + _reportIfChanged( + stateCode, result['status'] as String, false, false, false); + return result; + } + + if (stateCode == 1 || stateCode == 5) { + final result = { + 'status': 'Printing', + 'paused': false, + 'cancel_latched': false, + 'finished': false, + }; + _reportIfChanged( + stateCode, result['status'] as String, false, false, false); + return result; + } + + if (stateCode == 2) { + // Pause requested — report as pausing (transitional). We expose a + // pause_latched flag so callers can show a pausing spinner. + final result = { + 'status': 'Pausing', + 'paused': false, + 'cancel_latched': false, + 'pause_latched': true, + 'finished': false, + }; + _reportIfChanged( + stateCode, result['status'] as String, false, true, false); + return result; + } + + // Fallback: use existing flags + if (ns.paused == true) { + final result = { + 'status': 'Paused', + 'paused': true, + 'cancel_latched': false, + 'finished': false, + }; + _reportIfChanged( + stateCode, result['status'] as String, false, false, false); + return result; + } + + if (ns.printing == true) { + final result = { + 'status': 'Printing', + 'paused': false, + 'cancel_latched': false, + 'finished': false, + }; + _reportIfChanged( + stateCode, result['status'] as String, false, false, false); + return result; + } + + // Fallback: treat as Idle. We may be able to infer 'finished' if the + // snapshot contains information suggesting a completed job (layer info + // or layersCount or file metadata). This hint helps the mapper decide + // whether to present a green finished state or a canceled appearance. + final bool inferFinished = + ns.layerId != null || ns.layersCount != null || ns.file != null; + final result = { + 'status': 'Idle', + 'paused': false, + 'cancel_latched': false, + 'pause_latched': false, + 'finished': inferFinished, + }; + _reportIfChanged( + stateCode, result['status'] as String, false, false, inferFinished); + return result; + } + + void _reportIfChanged(int stateCode, String status, bool cancelLatched, + bool pauseLatched, bool finished) { + final changed = stateCode != _lastReportedStateCode || + status != _lastReportedStatus || + cancelLatched != _lastReportedCancelLatched || + pauseLatched != _lastReportedPauseLatched || + finished != _lastReportedFinished; + if (!changed) return; + + // Human-friendly single-line log. Use info level so it's visible by + // default but configurable by the app's logging setup. + final prevState = _lastReportedStateCode == -999 + ? 'unknown' + : _lastReportedStateCode.toString(); + final curState = stateCode < 0 ? 'unknown' : stateCode.toString(); + _log.info( + 'state $prevState -> $curState | status ${_lastReportedStatus ?? 'unknown'} -> $status | cancel_latched: $cancelLatched | pause_latched: $pauseLatched | finished: $finished'); + + _lastReportedStateCode = stateCode; + _lastReportedStatus = status; + _lastReportedCancelLatched = cancelLatched; + _lastReportedPauseLatched = pauseLatched; + _lastReportedFinished = finished; + } +} + +// Provide a package-level shared handler for simple use by mappers/providers. +final NanoDlpStateHandler nanoDlpStateHandler = NanoDlpStateHandler(); From 5a5bdb4db86d778e5cfd07a12c0f8c288072a3b1 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Wed, 8 Oct 2025 02:04:49 +0200 Subject: [PATCH 29/47] feat(backend_service): enhance nanoStatusToOdysseyMap with canonical state handling and cancel latching --- .../nanodlp/nanodlp_http_client.dart | 210 ++++++++++-------- .../nanodlp/nanodlp_mappers.dart | 91 ++++++-- 2 files changed, 180 insertions(+), 121 deletions(-) diff --git a/lib/backend_service/nanodlp/nanodlp_http_client.dart b/lib/backend_service/nanodlp/nanodlp_http_client.dart index fdab578..bee0f29 100644 --- a/lib/backend_service/nanodlp/nanodlp_http_client.dart +++ b/lib/backend_service/nanodlp/nanodlp_http_client.dart @@ -59,7 +59,7 @@ class NanoDlpHttpClient implements OdysseyClient { NanoDlpHttpClient( {http.Client Function()? clientFactory, Duration? requestTimeout}) : _clientFactory = clientFactory ?? http.Client.new, - _requestTimeout = requestTimeout ?? const Duration(seconds: 2) { + _requestTimeout = requestTimeout ?? const Duration(seconds: 5) { _createdAt = DateTime.now(); try { final cfg = OrionConfig(); @@ -95,115 +95,129 @@ class NanoDlpHttpClient implements OdysseyClient { Future> getStatus() async { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/status'); - final client = _createClient(); - try { - final resp = await client.get(uri); - if (resp.statusCode != 200) { - throw Exception('NanoDLP status call failed: ${resp.statusCode}'); - } - final decoded = json.decode(resp.body) as Map; - // Remove very large fields we don't need (e.g. FillAreas) to avoid - // costly parsing and memory churn when polling /status frequently. - if (decoded.containsKey('FillAreas')) { - try { - decoded.remove('FillAreas'); - } catch (_) { - // ignore any removal errors; parsing will continue without it + // Attempt the status request with one retry on transient failure. + int attempt = 0; + while (true) { + attempt += 1; + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + throw Exception('NanoDLP status call failed: ${resp.statusCode}'); } - } - var nano = NanoStatus.fromJson(decoded); - - // If the status payload doesn't include file metadata but does include - // a PlateID, try to resolve the plate from the plates list so we can - // populate file metadata & enable thumbnail lookup on the UI. - if (nano.file == null) { - int? plateId; - try { - final candidate = decoded['PlateID'] ?? - decoded['plate_id'] ?? - decoded['Plateid'] ?? - decoded['plateId']; - if (candidate is int) plateId = candidate; - if (candidate is String) plateId = int.tryParse(candidate); - } catch (_) { - plateId = null; + final decoded = json.decode(resp.body) as Map; + // Remove very large fields we don't need (e.g. FillAreas) to avoid + // costly parsing and memory churn when polling /status frequently. + if (decoded.containsKey('FillAreas')) { + try { + decoded.remove('FillAreas'); + } catch (_) { + // ignore any removal errors; parsing will continue without it + } } - if (plateId != null) { + var nano = NanoStatus.fromJson(decoded); + + // If the status payload doesn't include file metadata but does include + // a PlateID, try to resolve the plate from the plates list so we can + // populate file metadata & enable thumbnail lookup on the UI. + if (nano.file == null) { + int? plateId; try { - // If we previously resolved this plate and the cache is fresh, - // reuse it. - if (_resolvedPlateId == plateId && - _resolvedPlateFile != null && - _resolvedPlateTime != null && - DateTime.now().difference(_resolvedPlateTime!) < - _platesCacheTtl) { - final found = _resolvedPlateFile!; - nano = NanoStatus( - printing: nano.printing, - paused: nano.paused, - statusMessage: nano.statusMessage, - currentHeight: nano.currentHeight, - layerId: nano.layerId, - layersCount: nano.layersCount, - resinLevel: nano.resinLevel, - temp: nano.temp, - mcuTemp: nano.mcuTemp, - rawJsonStatus: nano.rawJsonStatus, - state: nano.state, - progress: nano.progress, - file: found, - z: nano.z, - curing: nano.curing, - ); - } else { - // Only attempt to resolve plate metadata when a job is active - // (printing). Avoid fetching the plates list while the device - // is idle to minimize network traffic at startup. - if (!nano.printing) { - // Skipping PlateID $plateId resolve because printer is not printing + final candidate = decoded['PlateID'] ?? + decoded['plate_id'] ?? + decoded['Plateid'] ?? + decoded['plateId']; + if (candidate is int) plateId = candidate; + if (candidate is String) plateId = int.tryParse(candidate); + } catch (_) { + plateId = null; + } + if (plateId != null) { + try { + // If we previously resolved this plate and the cache is fresh, + // reuse it. + if (_resolvedPlateId == plateId && + _resolvedPlateFile != null && + _resolvedPlateTime != null && + DateTime.now().difference(_resolvedPlateTime!) < + _platesCacheTtl) { + final found = _resolvedPlateFile!; + nano = NanoStatus( + printing: nano.printing, + paused: nano.paused, + statusMessage: nano.statusMessage, + currentHeight: nano.currentHeight, + layerId: nano.layerId, + layersCount: nano.layersCount, + resinLevel: nano.resinLevel, + temp: nano.temp, + mcuTemp: nano.mcuTemp, + rawJsonStatus: nano.rawJsonStatus, + state: nano.state, + stateCode: nano.stateCode, + progress: nano.progress, + file: found, + z: nano.z, + curing: nano.curing, + ); } else { - // Don't block status fetch on plates list resolution. Schedule - // a background task to refresh plates and populate the - // resolved-plate cache for subsequent polls and thumbnail - // lookups. However, avoid doing this immediately at startup - // (many installs poll status right away) — only schedule the - // async resolve if this client was created more than 2s ago. - final age = DateTime.now().difference(_createdAt); - if (age < const Duration(seconds: 2)) { - _log.fine( - 'Skipping PlateID $plateId resolve during startup (age=${age.inMilliseconds}ms)'); + // Only attempt to resolve plate metadata when a job is active + // (printing). Avoid fetching the plates list while the device + // is idle to minimize network traffic at startup. + if (!nano.printing) { + // Skipping PlateID $plateId resolve because printer is not printing } else { - _log.fine('Scheduling async resolve for PlateID $plateId'); - Future(() async { - try { - final plates = await _fetchPlates(); - final found = plates.firstWhere( - (p) => p.plateId != null && p.plateId == plateId, - orElse: () => const NanoFile()); - if (found.plateId != null) { - // Cache resolved plate for future polls - _log.fine('Resolved PlateID $plateId -> ${found.name}'); - _resolvedPlateId = plateId; - _resolvedPlateFile = found; - _resolvedPlateTime = DateTime.now(); + // Don't block status fetch on plates list resolution. Schedule + // a background task to refresh plates and populate the + // resolved-plate cache for subsequent polls and thumbnail + // lookups. However, avoid doing this immediately at startup + // (many installs poll status right away) — only schedule the + // async resolve if this client was created more than 2s ago. + final age = DateTime.now().difference(_createdAt); + if (age < const Duration(seconds: 2)) { + _log.fine( + 'Skipping PlateID $plateId resolve during startup (age=${age.inMilliseconds}ms)'); + } else { + _log.fine('Scheduling async resolve for PlateID $plateId'); + Future(() async { + try { + final plates = await _fetchPlates(); + final found = plates.firstWhere( + (p) => p.plateId != null && p.plateId == plateId, + orElse: () => const NanoFile()); + if (found.plateId != null) { + // Cache resolved plate for future polls + _log.fine( + 'Resolved PlateID $plateId -> ${found.name}'); + _resolvedPlateId = plateId; + _resolvedPlateFile = found; + _resolvedPlateTime = DateTime.now(); + } + } catch (e, st) { + _log.fine('Async PlateID resolve failed', e, st); } - } catch (e, st) { - _log.fine('Async PlateID resolve failed', e, st); - } - }); + }); + } } } + } catch (e, st) { + _log.fine('Failed to resolve PlateID $plateId to plate metadata', + e, st); } - } catch (e, st) { - _log.fine( - 'Failed to resolve PlateID $plateId to plate metadata', e, st); } } - } - return nanoStatusToOdysseyMap(nano); - } finally { - client.close(); + return nanoStatusToOdysseyMap(nano); + } catch (e, st) { + _log.fine('NanoDLP getStatus attempt #$attempt failed', e, st); + // Close client and, if we haven't retried yet, retry once. + client.close(); + if (attempt >= 2) rethrow; + _log.info('Retrying NanoDLP /status (attempt ${attempt + 1})'); + // Small backoff before retry + await Future.delayed(const Duration(milliseconds: 200)); + continue; + } } } diff --git a/lib/backend_service/nanodlp/nanodlp_mappers.dart b/lib/backend_service/nanodlp/nanodlp_mappers.dart index 3785cf4..2ca0355 100644 --- a/lib/backend_service/nanodlp/nanodlp_mappers.dart +++ b/lib/backend_service/nanodlp/nanodlp_mappers.dart @@ -1,24 +1,33 @@ +/* +* Orion - NanoDLP Status Mapper +* Copyright (C) 2025 Open Resin Alliance +* +* 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:orion/backend_service/nanodlp/models/nano_status.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_state_handler.dart'; /// Map NanoDLP DTOs into Odyssey-shaped maps expected by StatusModel.fromJson Map nanoStatusToOdysseyMap(NanoStatus ns) { - // Map NanoDLP-like state strings to Odyssey status strings. - // If the device reports `paused` explicitly prefer the paused label so - // the UI can reflect a paused state even when printing flag is present. - String status; - if (ns.paused == true) { - status = 'Paused'; - } else { - switch (ns.state.toLowerCase()) { - case 'printing': - case 'print': - status = 'Printing'; - break; - case 'idle': - default: - status = 'Idle'; - } - } + // Use the canonical NanoDLP state handler to determine authoritative + // status and paused flags. This handles latching of cancel requests and + // transient states described by the device's numeric `State` field. + final canonical = nanoDlpStateHandler.canonicalize(ns); + final String status = canonical['status'] as String? ?? 'Idle'; + final bool paused = canonical['paused'] as bool? ?? (ns.paused == true); + final bool cancelLatched = canonical['cancel_latched'] as bool? ?? false; + final bool finished = canonical['finished'] as bool? ?? false; final file = ns.file; Map? printData; @@ -50,17 +59,53 @@ Map nanoStatusToOdysseyMap(NanoStatus ns) { }; } - return { + // Special handling for cancel latching: + // - When canonical status is 'Canceling' we should preserve the layer + // information (if any) so the UI shows the indeterminate spinner with + // the stop icon. + // - When the handler reports that cancel completed (status == 'Idle' and + // cancel_latched == true) represent the snapshot as canceled by setting + // layer=null. This lets StatusModel.isCanceled be true and the UI show + // the finished/canceled appearance (full red circle + stop icon). + int? mappedLayer = ns.layerId; + if (status == 'Idle') { + if (cancelLatched) { + // indicate canceled snapshot + mappedLayer = null; + } else if (finished) { + // For a canonical 'finished' snapshot, infer a final layer so the + // UI renders a green finished state. Prefer file metadata, then + // reported layersCount. + if (mappedLayer == null) { + if (file != null) { + mappedLayer = file.layerCount ?? ns.layersCount; + } else if (ns.layersCount != null) { + mappedLayer = ns.layersCount; + } + } + } else { + // Not finished and not cancel-latched: keep mappedLayer as-is + // (may be null if device didn't report it yet). + } + } + + final result = { 'status': status, - // Preserve the explicit paused boolean from the NanoStatus so the - // StatusModel can determine paused vs printing correctly. - 'paused': ns.paused == true, - 'layer': ns.layerId, + // Use handler-provided paused if available, otherwise fall back to + // the explicit NanoStatus.paused field. + 'paused': paused, + 'layer': mappedLayer, 'print_data': printData, // Include the raw device 'Status' message (if present) so UI layers // can surface device-provided status text as an override for titles // or dialogs when appropriate. 'device_status_message': ns.statusMessage, - 'physical_state': {'z': ns.z ?? 0.0, 'curing': ns.curing} + 'physical_state': {'z': ns.z ?? 0.0, 'curing': ns.curing}, + // Expose whether the state handler has an active cancel latch so callers + // (e.g., StatusProvider) can decide UI transitional behavior. + 'cancel_latched': cancelLatched, + 'finished': finished, }; + + return result; } From e956e1f8b3d52f2e64008940c06a7fc973926aa0 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Wed, 8 Oct 2025 02:06:03 +0200 Subject: [PATCH 30/47] refactor(backend_service): StatusProvider: improve documentation and enhance canceling logic with model-level hints --- .../providers/status_provider.dart | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/lib/backend_service/providers/status_provider.dart b/lib/backend_service/providers/status_provider.dart index 28728cf..9a05045 100644 --- a/lib/backend_service/providers/status_provider.dart +++ b/lib/backend_service/providers/status_provider.dart @@ -30,14 +30,13 @@ import 'package:orion/util/thumbnail_cache.dart'; import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; -/// Polls the backend printer `/status` endpoint and exposes a typed [StatusModel]. +/// Polls the backend `/status` endpoint and exposes a typed [StatusModel]. /// -/// Handles: -/// * 1 Hz polling with simple re-entrancy guard -/// * Transitional UI flags (pause / cancel) to avoid flicker between user action -/// and backend state acknowledgment -/// * Lazy thumbnail extraction for current print file -/// * Derived convenience accessors used by UI widgets +/// Responsibilities: +/// * Periodic polling (and optional SSE) with backoff +/// * Transitional UI flags for pause/cancel +/// * Lazy thumbnail fetching and caching +/// * Convenience accessors for the UI class StatusProvider extends ChangeNotifier { final OdysseyClient _client; final _log = Logger('StatusProvider'); @@ -183,10 +182,8 @@ class StatusProvider extends ChangeNotifier { }); } - /// Attempt to connect to the SSE status stream at `/status/stream`. - /// If successful we listen for events and update state from incoming - /// JSON payloads. On failure we fall back to polling and schedule a - /// reconnection attempt after [_maxPollIntervalSeconds]. + /// Try to subscribe to SSE (status stream). On failure we fall back to + /// polling and schedule reconnect attempts with backoff. Future _tryStartSse() async { if (_sseConnected || _disposed) return; // If we've determined SSE is unsupported, don't retry it. @@ -300,18 +297,22 @@ class StatusProvider extends ChangeNotifier { _awaitingSince = null; } - // Clear transitional flags when backend reflects the requested - // change. We clear regardless of previous snapshot so cases where - // the pre-action status was null/stale still resolve the UI. + // Use model-level hints when available. The mapper/state-handler + // may populate `cancel_latched` for NanoDLP and `finished` to + // indicate an Idle snapshot that represents a completed job. + // Prefer these hints over string-matching to avoid duplicating + // backend-specific logic here. + final cancelLatched = parsed.cancelLatched == true; + final finishedHint = parsed.finished == true; + if (cancelLatched || parsed.status == 'Canceling') { + _isCanceling = true; + } else if (_isCanceling && (parsed.isCanceled || finishedHint)) { + _isCanceling = false; + } + if (_isPausing && parsed.isPaused) { _isPausing = false; } - if (_isCanceling && - (parsed.isCanceled || - !parsed.isPrinting || - (parsed.isIdle && parsed.layer != null))) { - _isCanceling = false; - } } catch (e, st) { _log.warning('Failed to handle SSE payload', e, st); } @@ -415,7 +416,12 @@ class StatusProvider extends ChangeNotifier { // Event buffer removed; nothing to clear here. } - /// Fetch latest status from backend. Re-entrancy guarded with [_fetchInFlight] + /// Fetch the latest status snapshot from the backend. This method is + /// re-entrancy guarded and performs the following at a high level: + /// - parses the payload into `StatusModel` + /// - updates thumbnails lazily + /// - adjusts polling interval and backoff on success/failure + /// - updates transitional flags (pause/cancel) Future refresh() async { if (_fetchInFlight) return; // simple re-entrancy guard _fetchInFlight = true; @@ -560,20 +566,20 @@ class StatusProvider extends ChangeNotifier { } // Clear transitional flags when the backend's paused/canceled state - // changes compared to our previous snapshot. This handles both - // pause->resume and resume->pause transitions and avoids leaving the - // UI stuck in a spinner. - // Clear transitional flags when backend reflects the requested change - // (e.g., resume -> paused=false or cancel -> layer==null). + // changes compared to our previous snapshot. Prefer model-level hints + // `cancelLatched` and `finished` provided by the mapper/state-handler + // so adapter-specific semantics stay in the adapter layer. + final cancelLatched = parsed.cancelLatched == true; + final finishedHint = parsed.finished == true; + if (cancelLatched || parsed.status == 'Canceling') { + _isCanceling = true; + } else if (_isCanceling && (parsed.isCanceled || finishedHint)) { + _isCanceling = false; + } + if (_isPausing && parsed.isPaused) { _isPausing = false; } - if (_isCanceling && - (parsed.isCanceled || - !parsed.isPrinting || - (parsed.isIdle && parsed.layer != null))) { - _isCanceling = false; - } } catch (e, st) { _log.severe('Status refresh failed', e, st); _error = e; From 4b400008766ec129970d93049dbbbf1f59e0896c Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Wed, 8 Oct 2025 02:06:33 +0200 Subject: [PATCH 31/47] feat(util, status): update StatusCard and StatusScreen to utilize canonical 'finished' state for improved status handling --- lib/status/status_screen.dart | 6 ++++-- lib/util/status_card.dart | 39 ++++++++--------------------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/lib/status/status_screen.dart b/lib/status/status_screen.dart index e5c2b84..988d437 100644 --- a/lib/status/status_screen.dart +++ b/lib/status/status_screen.dart @@ -408,7 +408,8 @@ class StatusScreenState extends State { final statusModel = provider.status; final finishedSnapshot = statusModel?.isIdle == true && statusModel?.layer != null; - final effectivelyFinished = provider.progress >= 0.999; + // Prefer canonical 'finished' hint from the mapper. + final effectivelyFinished = statusModel?.finished == true; final color = (finishedSnapshot && !effectivelyFinished) ? Theme.of(context).colorScheme.error : provider.statusColor(context); @@ -466,7 +467,8 @@ class StatusScreenState extends State { final statusModel = provider.status; final finishedSnapshot = statusModel?.isIdle == true && statusModel?.layer != null; - final effectivelyFinished = progress >= 0.999; + // Prefer canonical 'finished' hint from the parsed model. + final effectivelyFinished = statusModel?.finished == true; final effectiveStatusColor = (finishedSnapshot && !effectivelyFinished) ? Theme.of(context).colorScheme.error : statusColor; diff --git a/lib/util/status_card.dart b/lib/util/status_card.dart index afb2169..d0aa592 100644 --- a/lib/util/status_card.dart +++ b/lib/util/status_card.dart @@ -46,21 +46,9 @@ class StatusCardState extends State { @override Widget build(BuildContext context) { final s = widget.status; - // Determine whether a finished snapshot actually indicates a successful - // completion or a cancellation. Some backends (NanoDLP) do not provide - // an explicit 'finished' flag; when a snapshot shows idle with a layer - // but the progress is not 100% we should treat this as a cancel. - final bool finishedSnapshot = s != null && s.isIdle && s.layer != null; - final bool effectivelyFinished = - finishedSnapshot && (widget.progress >= 0.999); - - if (effectivelyFinished) { + if (s != null && s.isIdle && s.layer != null) { cardIcon = const Icon(Icons.check); - } else if (widget.isCanceling || - (s?.layer == null) || - (finishedSnapshot && !effectivelyFinished)) { - // show stop when actively canceling, when there's no layer (canceled), - // or when a finished-looking snapshot has progress < 100% (treat as canceled) + } else if (widget.isCanceling || (s?.layer == null)) { cardIcon = const Icon(Icons.stop); } else if (widget.isPausing || s?.isPaused == true) { cardIcon = const Icon(Icons.pause); @@ -85,23 +73,14 @@ class StatusCardState extends State { showSpinner = true; // Actively canceling from printing (not from paused) } - // Show full circle when canceled or canceling from paused. Also treat - // a finished-looking snapshot with progress < 100% as canceled (full). - if (s?.layer == null) { + // Show full circle when canceled or canceling from paused + if (s?.layer == null || s?.finished == true) { showFullCircle = true; // Already canceled } else if (widget.isCanceling && widget.isPausing) { - showFullCircle = true; // Canceling from paused state - } else if (finishedSnapshot && !effectivelyFinished) { - showFullCircle = true; // Treated as canceled because progress < 100% + showFullCircle = + true; // Canceling from paused state (isPausing is still true) } - // If we determined a finished-looking snapshot is actually a cancel - // (progress < 100%), render using the error/canceled color so the - // progress ring and icon match the stop semantics. - final effectiveStatusColor = (finishedSnapshot && !effectivelyFinished) - ? Theme.of(context).colorScheme.error - : widget.statusColor; - final circleProgress = showSpinner ? null : showFullCircle @@ -159,9 +138,9 @@ class StatusCardState extends State { value: circleProgress, strokeWidth: 6, valueColor: AlwaysStoppedAnimation( - effectiveStatusColor), + widget.statusColor), backgroundColor: - effectiveStatusColor.withValues(alpha: 0.5), + widget.statusColor.withValues(alpha: 0.5), ), ), ), @@ -169,7 +148,7 @@ class StatusCardState extends State { padding: const EdgeInsets.all(25), child: Icon( cardIcon.icon, - color: effectiveStatusColor, + color: widget.statusColor, size: 70, ), ) From 494691f9ebf721f709ef9e2597521c0c37b1f8e8 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Wed, 8 Oct 2025 02:06:45 +0200 Subject: [PATCH 32/47] feat(tests): add tests for NanoDlpStateHandler and finished flow handling --- test/backend/nanodlp_state_handler_test.dart | 85 ++++++++++++++++++++ test/nanodlp/finished_flow_test.dart | 66 +++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 test/backend/nanodlp_state_handler_test.dart create mode 100644 test/nanodlp/finished_flow_test.dart diff --git a/test/backend/nanodlp_state_handler_test.dart b/test/backend/nanodlp_state_handler_test.dart new file mode 100644 index 0000000..f3547b8 --- /dev/null +++ b/test/backend/nanodlp_state_handler_test.dart @@ -0,0 +1,85 @@ +/* +* Orion - NanoDLP State Handler Test +* Copyright (C) 2025 Open Resin Alliance +* +* 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:orion/backend_service/nanodlp/nanodlp_state_handler.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; + +void main() { + group('NanoDlpStateHandler', () { + test('latches cancel and clears only on idle', () { + final h = NanoDlpStateHandler(); + h.reset(); + + // Observing state==4 should latch cancel + var ns = NanoStatus( + printing: false, paused: false, state: 'idle', stateCode: 4); + var out = h.canonicalize(ns); + expect(out['cancel_latched'], true); + expect(out['status'], 'Canceling'); + + // Transition to state 1 (starting) should keep latch + ns = NanoStatus( + printing: true, paused: false, state: 'printing', stateCode: 1); + out = h.canonicalize(ns); + expect(out['cancel_latched'], true); + expect(out['status'], 'Canceling'); + + // Now idle -> should report idle with cancel_latched true (remain latched) + ns = NanoStatus( + printing: false, paused: false, state: 'idle', stateCode: 0); + out = h.canonicalize(ns); + expect(out['status'], 'Idle'); + expect(out['cancel_latched'], true); + + // Subsequent idle should still show latch until a new print starts + out = h.canonicalize(ns); + expect(out['status'], 'Idle'); + expect(out['cancel_latched'], true); + + // New print (0 -> 1) should clear the latch + ns = NanoStatus( + printing: true, paused: false, state: 'printing', stateCode: 1); + out = h.canonicalize(ns); + expect(out['cancel_latched'], false); + }); + + test('new print clears previous cancel latch when coming from idle', () { + final h = NanoDlpStateHandler(); + h.reset(); + + // Simulate a previous cancel that completed (we'll set latch manually) + // To simulate we first latch and then go to idle which clears it. + var ns = NanoStatus( + printing: false, paused: false, state: 'idle', stateCode: 4); + var out = h.canonicalize(ns); + expect(out['cancel_latched'], true); + + ns = NanoStatus( + printing: false, paused: false, state: 'idle', stateCode: 0); + out = h.canonicalize(ns); + expect(out['cancel_latched'], true); + + // New print start (0 -> 1) should clear latch + ns = NanoStatus( + printing: true, paused: false, state: 'printing', stateCode: 1); + out = h.canonicalize(ns); + expect(out['cancel_latched'], false); + expect(out['status'], 'Printing'); + }); + }); +} diff --git a/test/nanodlp/finished_flow_test.dart b/test/nanodlp/finished_flow_test.dart new file mode 100644 index 0000000..77f967e --- /dev/null +++ b/test/nanodlp/finished_flow_test.dart @@ -0,0 +1,66 @@ +/* +* Orion - NanoDLP Finished Flow Test +* Copyright (C) 2025 Open Resin Alliance +* +* 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:orion/backend_service/nanodlp/models/nano_status.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_state_handler.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; +import 'package:orion/backend_service/odyssey/models/status_models.dart'; + +void main() { + test('1->0 finish produces finished=true and mapped layer for green state', + () { + final h = NanoDlpStateHandler(); + h.reset(); + + // Simulate transient printing state (1) + var ns = NanoStatus( + printing: true, + paused: false, + state: 'printing', + stateCode: 1, + layerId: 99, + layersCount: 100, + ); + var canonical = h.canonicalize(ns); + expect(canonical['status'], equals('Printing')); + + // Now simulate finish transition 1 -> 0 with layersCount present + ns = NanoStatus( + printing: false, + paused: false, + state: 'idle', + stateCode: 0, + layerId: null, + layersCount: 100, + ); + canonical = h.canonicalize(ns); + expect(canonical['status'], equals('Idle')); + expect(canonical['finished'], true); + + final mapped = nanoStatusToOdysseyMap(ns); + // Mapper should set finished and map a sensible layer + expect(mapped['finished'], true); + expect(mapped['layer'], isNotNull); + + final statusModel = StatusModel.fromJson(Map.from(mapped)); + // Model should show Idle with a layer -> not canceled + expect(statusModel.isIdle, true); + expect(statusModel.layer, isNotNull); + expect(statusModel.isCanceled, false); + }); +} From bebc4d29567cac508f85755be742f75e6586f199 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Fri, 10 Oct 2025 10:08:59 +0200 Subject: [PATCH 33/47] feat(backend_service): add pauseLatched field to StatusModel for enhanced state tracking --- lib/backend_service/nanodlp/nanodlp_mappers.dart | 2 ++ lib/backend_service/odyssey/models/status_models.dart | 3 +++ lib/backend_service/odyssey/models/status_models.g.dart | 2 ++ 3 files changed, 7 insertions(+) diff --git a/lib/backend_service/nanodlp/nanodlp_mappers.dart b/lib/backend_service/nanodlp/nanodlp_mappers.dart index 2ca0355..574b4ca 100644 --- a/lib/backend_service/nanodlp/nanodlp_mappers.dart +++ b/lib/backend_service/nanodlp/nanodlp_mappers.dart @@ -27,6 +27,7 @@ Map nanoStatusToOdysseyMap(NanoStatus ns) { final String status = canonical['status'] as String? ?? 'Idle'; final bool paused = canonical['paused'] as bool? ?? (ns.paused == true); final bool cancelLatched = canonical['cancel_latched'] as bool? ?? false; + final bool pauseLatched = canonical['pause_latched'] as bool? ?? false; final bool finished = canonical['finished'] as bool? ?? false; final file = ns.file; @@ -104,6 +105,7 @@ Map nanoStatusToOdysseyMap(NanoStatus ns) { // Expose whether the state handler has an active cancel latch so callers // (e.g., StatusProvider) can decide UI transitional behavior. 'cancel_latched': cancelLatched, + 'pause_latched': pauseLatched, 'finished': finished, }; diff --git a/lib/backend_service/odyssey/models/status_models.dart b/lib/backend_service/odyssey/models/status_models.dart index c122a28..6f2018b 100644 --- a/lib/backend_service/odyssey/models/status_models.dart +++ b/lib/backend_service/odyssey/models/status_models.dart @@ -33,6 +33,8 @@ class StatusModel { final int? layer; @JsonKey(name: 'cancel_latched') final bool? cancelLatched; + @JsonKey(name: 'pause_latched') + final bool? pauseLatched; final bool? finished; @JsonKey(name: 'print_data') final PrintData? printData; @@ -44,6 +46,7 @@ class StatusModel { required this.paused, required this.layer, this.cancelLatched, + this.pauseLatched, this.finished, required this.printData, required this.physicalState, diff --git a/lib/backend_service/odyssey/models/status_models.g.dart b/lib/backend_service/odyssey/models/status_models.g.dart index a31e6b6..e6e3b29 100644 --- a/lib/backend_service/odyssey/models/status_models.g.dart +++ b/lib/backend_service/odyssey/models/status_models.g.dart @@ -11,6 +11,7 @@ StatusModel _$StatusModelFromJson(Map json) => StatusModel( paused: json['paused'] as bool?, layer: (json['layer'] as num?)?.toInt(), cancelLatched: json['cancel_latched'] as bool?, + pauseLatched: json['pause_latched'] as bool?, finished: json['finished'] as bool?, printData: json['print_data'] == null ? null @@ -25,6 +26,7 @@ Map _$StatusModelToJson(StatusModel instance) => 'paused': instance.paused, 'layer': instance.layer, 'cancel_latched': instance.cancelLatched, + 'pause_latched': instance.pauseLatched, 'finished': instance.finished, 'print_data': instance.printData?.toJson(), 'physical_state': instance.physicalState.toJson(), From 1b2ea4b0a94aea607fb519cada8ed5de32f1c13d Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Fri, 10 Oct 2025 10:09:23 +0200 Subject: [PATCH 34/47] feat(backend_service): enhance StatusProvider with pause and minimum spinner logic for improved UI responsiveness --- .../providers/status_provider.dart | 69 ++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/lib/backend_service/providers/status_provider.dart b/lib/backend_service/providers/status_provider.dart index 9a05045..72e5b01 100644 --- a/lib/backend_service/providers/status_provider.dart +++ b/lib/backend_service/providers/status_provider.dart @@ -72,6 +72,11 @@ class StatusProvider extends ChangeNotifier { DateTime? _awaitingSince; // timestamp we began waiting for coherent data bool get awaitingNewPrintData => _awaitingNewPrintData; static const Duration _awaitingTimeout = Duration(seconds: 12); + // Minimum spinner gating: when starting a new print we may want to ensure + // the UI shows a small loading animation so the transition feels natural. + DateTime? _minSpinnerUntil; + bool get minSpinnerActive => + _minSpinnerUntil != null && DateTime.now().isBefore(_minSpinnerUntil!); // We deliberately removed expected file name gating; re-printing the same // file should still show a clean loading phase. @@ -304,13 +309,23 @@ class StatusProvider extends ChangeNotifier { // backend-specific logic here. final cancelLatched = parsed.cancelLatched == true; final finishedHint = parsed.finished == true; - if (cancelLatched || parsed.status == 'Canceling') { + final pauseLatched = parsed.pauseLatched == true; + // Only mark transitional cancel when the canonical status or + // the handler indicates an in-flight cancel (i.e. not an Idle + // snapshot). If we observe an Idle snapshot that appears + // canceled (cancel_latched + Idle), prefer the final canceled + // state and clear the transitional flag so UI buttons enable. + if (parsed.status == 'Canceling' || + (cancelLatched && parsed.status != 'Idle')) { _isCanceling = true; - } else if (_isCanceling && (parsed.isCanceled || finishedHint)) { + } else if (_isCanceling && + (parsed.isCanceled || finishedHint || parsed.status == 'Idle')) { _isCanceling = false; } - if (_isPausing && parsed.isPaused) { + if (pauseLatched || parsed.status == 'Pausing') { + _isPausing = true; + } else if (_isPausing && parsed.isPaused) { _isPausing = false; } } catch (e, st) { @@ -571,13 +586,23 @@ class StatusProvider extends ChangeNotifier { // so adapter-specific semantics stay in the adapter layer. final cancelLatched = parsed.cancelLatched == true; final finishedHint = parsed.finished == true; - if (cancelLatched || parsed.status == 'Canceling') { + final pauseLatched = parsed.pauseLatched == true; + // Only mark transitional cancel when the canonical status or + // the handler indicates an in-flight cancel (i.e. not an Idle + // snapshot). If we observe an Idle snapshot that appears + // canceled (cancel_latched + Idle), prefer the final canceled + // state and clear the transitional flag so UI buttons enable. + if (parsed.status == 'Canceling' || + (cancelLatched && parsed.status != 'Idle')) { _isCanceling = true; - } else if (_isCanceling && (parsed.isCanceled || finishedHint)) { + } else if (_isCanceling && + (parsed.isCanceled || finishedHint || parsed.status == 'Idle')) { _isCanceling = false; } - if (_isPausing && parsed.isPaused) { + if (pauseLatched || parsed.status == 'Pausing') { + _isPausing = true; + } else if (_isPausing && parsed.isPaused) { _isPausing = false; } } catch (e, st) { @@ -757,22 +782,46 @@ class StatusProvider extends ChangeNotifier { String? initialFilePath, int? initialPlateId, }) { + _log.fine('resetStatus called — purging stale status and thumbnails'); + // Purge cached status and transient state immediately so UI shows a + // clean spinner instead of stale values while we fetch fresh status. _status = null; _thumbnailBytes = initialThumbnailBytes; _thumbnailReady = initialThumbnailBytes != null; _error = null; _loading = true; // so consumer screens can show a spinner if they mount + // Clear transitional flags so UI isn't stuck in a paused/canceling state + // when starting a fresh session. _isCanceling = false; _isPausing = false; _awaitingNewPrintData = true; // begin awaiting active print _awaitingSince = DateTime.now(); // Clear thumbnail retry counters for fresh session _thumbnailRetries.clear(); - // If an initial file path or plate id is provided we may use it to - // resolve thumbnails faster in NanoDLP adapters; store on status - // model is not needed here but provider consumers can access - // _thumbnailBytes immediately. + // Ensure the UI shows a minimum spinner duration so the user perceives + // a loading phase even if the backend responds very quickly. + _minSpinnerUntil = DateTime.now().add(const Duration(seconds: 2)); notifyListeners(); + + // Schedule a notify when the minimum spinner window expires so UI can + // re-evaluate its conditions (refresh() may complete earlier and + // already clear loading). The delayed callback is guarded by the + // provider's disposed flag. + Future.delayed(const Duration(seconds: 2), () { + if (_disposed) return; + _minSpinnerUntil = null; + // Notify listeners so consumers can dismiss the forced spinner. + notifyListeners(); + }); + + // Kick off an immediate refresh to populate fresh status. If a fetch + // is already in flight, refresh() will no-op via its guard. + try { + // Fire-and-forget; refresh handles its own errors and notifications. + Future.microtask(() => refresh()); + } catch (_) { + // ignore — refresh has internal error handling + } } Future _fetchAndHandleThumbnail({ From 35b0a9f930d21906f659367cc63b0fa70cf5bba0 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Fri, 10 Oct 2025 10:09:39 +0200 Subject: [PATCH 35/47] feat(status): enhance new print handling by ensuring readiness before dismissing spinner --- lib/status/status_screen.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/status/status_screen.dart b/lib/status/status_screen.dart index 988d437..dd8ad32 100644 --- a/lib/status/status_screen.dart +++ b/lib/status/status_screen.dart @@ -148,12 +148,22 @@ class StatusScreenState extends State { if (_suppressOldStatus || provider.isLoading || + provider.minSpinnerActive || status == null || thumbnailLoadingForAutoOpen || // If this screen was opened as a new print, wait until the // provider reports the job is ready to display. But allow // finished/canceled snapshots through so the UI doesn't lock up. - ((widget.newPrint && awaiting) && + // If opened as a new print, wait until provider signals readiness + // (active job + file metadata + thumbnail). Additionally, ensure + // we have at least the file name (or have frozen it) before + // dismissing the global spinner. Some backends report the job + // active before file metadata arrives; keep showing the spinner + // until the UI can display a stable filename. + ((widget.newPrint && + (awaiting || + ((status.printData?.fileData?.name == null) && + _frozenFileName == null))) && !newPrintReady && !finishedSnapshot && !canceledSnapshot)) { From 6d27134841331855caf1fde986f882ca15193428 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:20:12 +0200 Subject: [PATCH 36/47] refactor(settings): GeneralCfgScreen: update label for custom URL option to 'Use Custom Backend URL' - we can use alternative backends now --- lib/settings/general_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/settings/general_screen.dart b/lib/settings/general_screen.dart index 45cc07f..c60029a 100644 --- a/lib/settings/general_screen.dart +++ b/lib/settings/general_screen.dart @@ -402,7 +402,7 @@ class GeneralCfgScreenState extends State { ), const SizedBox(height: 20.0), OrionListTile( - title: 'Use Custom Odyssey URL', + title: 'Use Custom Backend URL', icon: PhosphorIcons.network, value: useCustomUrl, onChanged: (bool value) { From 7b2a8081de83d06fa52772d308fce920ac73636b Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:31:00 +0200 Subject: [PATCH 37/47] fix(script): install_orion_athena2: revert to old revert_orion --- scripts/install_orion_athena2.sh | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/scripts/install_orion_athena2.sh b/scripts/install_orion_athena2.sh index b942126..795fae8 100644 --- a/scripts/install_orion_athena2.sh +++ b/scripts/install_orion_athena2.sh @@ -209,7 +209,7 @@ main() { }, "developer": { "releaseOverride": true, - "overrideRelease": "BRANCH_nanodlp_basic_support", + "overrideRelease": "BRANCH_api_refactor", "overrideUpdateCheck": false }, "topsecret": { @@ -287,21 +287,12 @@ if [[ $EUID -ne 0 ]]; then exit 1 fi fi -# Ensure NanoDLP service is enabled and started first so the system has a -# functioning UI/service to use once Orion is removed. -systemctl enable nanodlp-dsi.service || true -systemctl start nanodlp-dsi.service || true -# Reload systemd configuration in case units changed. -systemctl daemon-reload || true - -# Schedule disabling and stopping of the Orion service asynchronously. -# We run this in a detached background process (via nohup) so that if -# this script was invoked from the running Orion service, stopping Orion -# won't kill this helper before it can finish and return output. -nohup bash -c 'sleep 2; systemctl disable --now orion.service >/dev/null 2>&1 || true' >/dev/null 2>&1 & - -echo "NanoDLP enabled and started. Orion will be disabled/stopped shortly." +systemctl stop orion.service 2>/dev/null || true +systemctl disable orion.service 2>/dev/null || true +systemctl daemon-reload +systemctl enable nanodlp-dsi.service +systemctl start nanodlp-dsi.service EOF chmod 0755 "$REVERT_PATH" @@ -311,4 +302,4 @@ EOF systemctl status orion.service --no-pager } -main "$@" +main "$@" \ No newline at end of file From 9092ab7cc1d4fb6b35093c1ffeb7f9091ce33932 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:31:45 +0200 Subject: [PATCH 38/47] feat(backend_service): add missing methods for backend version and emergency stop functionality --- lib/backend_service/backend_service.dart | 12 ++ .../nanodlp/nanodlp_http_client.dart | 118 ++++++++++++++++++ .../odyssey/odyssey_client.dart | 5 + .../odyssey/odyssey_http_client.dart | 19 ++- 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/lib/backend_service/backend_service.dart b/lib/backend_service/backend_service.dart index 1f7be52..e6dd2a3 100644 --- a/lib/backend_service/backend_service.dart +++ b/lib/backend_service/backend_service.dart @@ -49,6 +49,9 @@ class BackendService implements OdysseyClient { @override Future> getConfig() => _delegate.getConfig(); + @override + Future getBackendVersion() => _delegate.getBackendVersion(); + @override Future getFileThumbnail( String location, String filePath, String size) => @@ -90,6 +93,12 @@ class BackendService implements OdysseyClient { @override Future> moveToTop() => _delegate.moveToTop(); + @override + Future canMoveToFloor() => _delegate.canMoveToFloor(); + + @override + Future> moveToFloor() => _delegate.moveToFloor(); + @override Future> manualCure(bool cure) => _delegate.manualCure(cure); @@ -101,6 +110,9 @@ class BackendService implements OdysseyClient { Future> manualCommand(String command) => _delegate.manualCommand(command); + @override + Future> emergencyStop() => _delegate.emergencyStop(); + @override Future displayTest(String test) => _delegate.displayTest(test); } diff --git a/lib/backend_service/nanodlp/nanodlp_http_client.dart b/lib/backend_service/nanodlp/nanodlp_http_client.dart index bee0f29..a54da0b 100644 --- a/lib/backend_service/nanodlp/nanodlp_http_client.dart +++ b/lib/backend_service/nanodlp/nanodlp_http_client.dart @@ -421,6 +421,49 @@ class NanoDlpHttpClient implements OdysseyClient { } }(); + @override + Future getBackendVersion() async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/static/image_version'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.fine('NanoDLP image_version returned ${resp.statusCode}'); + return 'NanoDLP'; + } + + final body = resp.body.trim(); + if (body.isEmpty) return 'NanoDLP'; + + // Try to capture a full semver and any following +metadata tokens, + // e.g. "Athena2-16K-CM5+0.9.9+2025-08-01" -> capture "0.9.9+2025-08-01". + final fullSemverWithMeta = + RegExp(r'\d+\.\d+\.\d+(?:\+[^\s]+)*').firstMatch(body); + if (fullSemverWithMeta != null) { + return 'NanoDLP ${fullSemverWithMeta.group(0)}'; + } + + // Fallback: capture X.Y or X.Y.Z and any +metadata following it. + final partialWithMeta = + RegExp(r'\d+\.\d+(?:\.\d+)?(?:\+[^\s]+)*').firstMatch(body); + if (partialWithMeta != null) { + return 'NanoDLP ${partialWithMeta.group(0)}'; + } + + // As a last resort, try to pick a token after "+" or return the raw body. + final plusParts = body.split('+'); + final candidate = plusParts.firstWhere((p) => RegExp(r'\d').hasMatch(p), + orElse: () => body); + return 'NanoDLP ${candidate.trim()}'; + } catch (e, st) { + _log.fine('Failed to fetch backend version', e, st); + return 'NanoDLP'; + } finally { + client.close(); + } + } + @override Future> listItems( String location, int pageSize, int pageIndex, String subdirectory) async { @@ -592,6 +635,53 @@ class NanoDlpHttpClient implements OdysseyClient { } } + @override + Future canMoveToFloor() async { + // Best-effort: check /status to see if device exposes z-axis controls + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/status'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) return false; + // If status contains physical_state or similar keys, assume support. + final decoded = json.decode(resp.body) as Map; + return decoded.containsKey('CurrentHeight') || + decoded.containsKey('physical_state'); + } finally { + client.close(); + } + } catch (_) { + return false; + } + } + + @override + Future> moveToFloor() async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/z-axis/bottom'); + _log.info('NanoDLP moveToFloor request: $uri'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP moveToFloor failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP moveToFloor failed: ${resp.statusCode}'); + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + } + @override Future> manualCommand(String command) async => throw UnimplementedError('NanoDLP manualCommand not implemented'); @@ -600,6 +690,34 @@ class NanoDlpHttpClient implements OdysseyClient { Future> manualCure(bool cure) async => throw UnimplementedError('NanoDLP manualCure not implemented'); + @override + Future> emergencyStop() async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/printer/force-stop'); + _log.info('NanoDLP emergencyStop commanded: $uri'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP emergencyStop failed as expected: ${resp.statusCode} ${resp.body}'); + // throw Exception('NanoDLP emergencyStop failed: ${resp.statusCode}'); + client.close(); + return NanoManualResult(ok: true) + .toMap(); // treat non-200 as success, emergency stop should have occurred. + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + } + @override Future> manualHome() async => () async { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); diff --git a/lib/backend_service/odyssey/odyssey_client.dart b/lib/backend_service/odyssey/odyssey_client.dart index da07e10..cad757b 100644 --- a/lib/backend_service/odyssey/odyssey_client.dart +++ b/lib/backend_service/odyssey/odyssey_client.dart @@ -15,6 +15,8 @@ abstract class OdysseyClient { Future> getConfig(); + Future getBackendVersion(); + Future getFileThumbnail( String location, String filePath, String size); @@ -45,11 +47,14 @@ abstract class OdysseyClient { /// Whether the client supports a direct "move to top limit" command. Future canMoveToTop(); + Future canMoveToFloor(); /// Move the Z axis directly to the device's top limit if supported. Future> moveToTop(); + Future> moveToFloor(); Future> manualCure(bool cure); Future> manualHome(); Future> manualCommand(String command); + Future> emergencyStop(); Future displayTest(String test); } diff --git a/lib/backend_service/odyssey/odyssey_http_client.dart b/lib/backend_service/odyssey/odyssey_http_client.dart index fbb6ea0..f5fffa0 100644 --- a/lib/backend_service/odyssey/odyssey_http_client.dart +++ b/lib/backend_service/odyssey/odyssey_http_client.dart @@ -91,6 +91,11 @@ class OdysseyHttpClient implements OdysseyClient { return json.decode(resp.body) as Map; } + @override + Future getBackendVersion() async { + return 'Odyssey ?.?.?'; //(await _odysseyGet('/version', {})).body; + } + @override Future> getStatus() async { final resp = await _odysseyGet('/status', {}); @@ -156,12 +161,19 @@ class OdysseyHttpClient implements OdysseyClient { } @override - Future canMoveToTop() async => false; + Future canMoveToTop() async => true; + + @override + Future canMoveToFloor() async => true; @override Future> moveToTop() async => throw UnimplementedError('moveToTop not supported by Odyssey backend'); + @override + Future> moveToFloor() async => + throw UnimplementedError('moveToFloor not supported by Odyssey backend'); + @override Future> manualCure(bool cure) async { final resp = await _odysseyPost('/manual', {'cure': cure.toString()}); @@ -189,6 +201,11 @@ class OdysseyHttpClient implements OdysseyClient { await _odysseyPost('/manual/display_test', {'test': test}); } + @override + Future> emergencyStop() async { + return await manualCommand('M112'); + } + @override Future getFileThumbnail( String location, String filePath, String size) async { From 05a719a5820dbe92fddd204f39133a7fb61355df Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:32:11 +0200 Subject: [PATCH 39/47] feat(manual_provider): add moveToFloor and emergencyStop methods with error handling --- .../providers/manual_provider.dart | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/lib/backend_service/providers/manual_provider.dart b/lib/backend_service/providers/manual_provider.dart index a23c626..7bef35b 100644 --- a/lib/backend_service/providers/manual_provider.dart +++ b/lib/backend_service/providers/manual_provider.dart @@ -1,6 +1,22 @@ +/* +* Orion - Manual Hardware Control Provider +* Copyright (C) 2025 Open Resin Alliance +* +* 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/foundation.dart'; import 'package:logging/logging.dart'; - import 'package:orion/backend_service/odyssey/odyssey_client.dart'; import 'package:orion/backend_service/backend_service.dart'; @@ -129,6 +145,15 @@ class ManualProvider extends ChangeNotifier { } } + /// Whether the backend supports a direct move-to-floor operation. + Future canMoveToFloor() async { + try { + return await _client.canMoveToFloor(); + } catch (_) { + return false; + } + } + Future moveToTop() async { _log.info('moveToTop'); if (_busy) return false; @@ -149,6 +174,46 @@ class ManualProvider extends ChangeNotifier { } } + Future moveToFloor() async { + _log.info('moveToFloor'); + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await _client.moveToFloor(); + _busy = false; + notifyListeners(); + return true; + } catch (e, st) { + _log.severe('moveToFloor failed', e, st); + _error = e; + _busy = false; + notifyListeners(); + return false; + } + } + + Future emergencyStop() async { + _log.info('emergencyStop'); + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await _client.emergencyStop(); + _busy = false; + notifyListeners(); + return true; + } catch (e, st) { + _log.severe('emergencyStop failed', e, st); + _error = e; + _busy = false; + notifyListeners(); + return true; // Return true even on error to avoid blocking UI + } + } + Future displayTest(String test) async { _log.info('displayTest: $test'); if (_busy) return false; From 563486ef8fe76d2fd6d362bb5603b247cc6128fa Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:33:02 +0200 Subject: [PATCH 40/47] feat(about_screen): implement backend version fetching with error handling --- lib/settings/about_screen.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/settings/about_screen.dart b/lib/settings/about_screen.dart index 2c207ee..ff2784f 100644 --- a/lib/settings/about_screen.dart +++ b/lib/settings/about_screen.dart @@ -30,9 +30,11 @@ import 'package:orion/themes/themes.dart'; import 'package:orion/util/orion_config.dart'; import 'package:orion/util/orion_kb/orion_keyboard_expander.dart'; import 'package:orion/util/orion_kb/orion_textfield_spawn.dart'; +import 'package:orion/backend_service/backend_service.dart'; Logger _logger = Logger('AboutScreen'); OrionConfig config = OrionConfig(); +BackendService backend = BackendService(); Future executeCommand(String command, List arguments) async { final result = await Process.run(command, arguments); @@ -68,9 +70,14 @@ Future getDeviceModel() async { } } -// TODO: Implement Odyssey version fetching, awaiting API Future getVersionNumber() async { - return 'Orion ${Pubspec.version}' ' - Odyssey 1.0.0'; + try { + final backendVersion = await backend.getBackendVersion(); + return 'Orion ${Pubspec.version} - $backendVersion'; + } catch (e) { + _logger.warning('Failed to get backend version: $e'); + return 'Orion ${Pubspec.version} - N/A'; + } } class AboutScreen extends StatefulWidget { From 44da52d5e04892d11805f2e6991cf43d581806ee Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:33:46 +0200 Subject: [PATCH 41/47] feat(error_handling): ErrorDetails: add new error types for power, movement, and critical errors --- lib/util/error_handling/error_details.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/util/error_handling/error_details.dart b/lib/util/error_handling/error_details.dart index 844a677..0b4c51e 100644 --- a/lib/util/error_handling/error_details.dart +++ b/lib/util/error_handling/error_details.dart @@ -90,4 +90,22 @@ final Map errorLookupTable = { 'Please check the printer sensors.\n\n' 'Error Code: WHITE-WOLF', ), + 'GRAY-GOOSE': ErrorDetails( + 'Power Error', + 'There was a power issue with the printer.\n' + 'Please ensure the printer is properly connected.\n\n' + 'Error Code: GRAY-GOOSE', + ), + 'GOLDEN-APE': ErrorDetails( + 'Movement Error', + 'The movement command has failed.\n' + 'Please check the Z-axis and try again.\n\n' + 'Error Code: GOLDEN-APE', + ), + 'CRITICAL': ErrorDetails( + 'CRITICAL ERROR', + 'A critical error has occured!\n' + 'Please contact support immediately.\n\n' + 'Error Code: CRITICAL', + ) }; From 1d65924eb217b2ed9fd0e96478c38804a82de229 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:34:25 +0200 Subject: [PATCH 42/47] fix(script): install_orion_athena2: update release branch to BRANCH_nanodlp_basic_support --- scripts/install_orion_athena2.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install_orion_athena2.sh b/scripts/install_orion_athena2.sh index 795fae8..2268386 100644 --- a/scripts/install_orion_athena2.sh +++ b/scripts/install_orion_athena2.sh @@ -209,7 +209,7 @@ main() { }, "developer": { "releaseOverride": true, - "overrideRelease": "BRANCH_api_refactor", + "overrideRelease": "BRANCH_nanodlp_basic_support", "overrideUpdateCheck": false }, "topsecret": { From 20b86411586a2186782155233d4cd4259ef9e6e9 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:35:19 +0200 Subject: [PATCH 43/47] feat(tools): MoveZScreen: enhance error handling and update UI components with Phosphor icons --- lib/tools/move_z_screen.dart | 270 ++++++++++++++++++++--------------- 1 file changed, 157 insertions(+), 113 deletions(-) diff --git a/lib/tools/move_z_screen.dart b/lib/tools/move_z_screen.dart index 5ffd236..1919a5b 100644 --- a/lib/tools/move_z_screen.dart +++ b/lib/tools/move_z_screen.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:logging/logging.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:provider/provider.dart'; import 'package:orion/backend_service/providers/config_provider.dart'; @@ -26,6 +27,7 @@ import 'package:orion/backend_service/providers/manual_provider.dart'; import 'package:orion/backend_service/providers/status_provider.dart'; import 'package:orion/glasser/glasser.dart'; import 'package:orion/util/error_handling/error_dialog.dart'; +import 'package:orion/util/orion_config.dart'; class MoveZScreen extends StatefulWidget { const MoveZScreen({super.key}); @@ -45,28 +47,46 @@ class MoveZScreenState extends State { bool _apiErrorState = false; Map? status; + // Safe helper to show error dialogs without using a stale BuildContext. + // If a BuildContext is provided (usually from a builder), verify the + // Element is still mounted before showing the dialog. If omitted, use + // the State's context guarded by `mounted`. + void _safeShowError(String message, [BuildContext? maybeCtx]) { + if (maybeCtx != null) { + if (maybeCtx is Element) { + if (!maybeCtx.mounted) return; + } + showErrorDialog(maybeCtx, message); + } else { + if (!mounted) return; + showErrorDialog(context, message); + } + } + Future moveZ(double distance) async { try { _logger.info('Moving Z by $distance'); final statusProvider = Provider.of(context, listen: false); - final curZ = statusProvider.status?.physicalState.z ?? this.currentZ; + final curZ = statusProvider.status?.physicalState.z ?? currentZ; final newZ = (curZ + distance).clamp(0.0, maxZ).toDouble(); final manual = Provider.of(context, listen: false); final ok = await manual.move(newZ); if (!ok) { + if (!mounted) return; setState(() { _apiErrorState = true; }); - if (mounted) showErrorDialog(context, 'Failed to move Z'); + _safeShowError('Failed to move Z'); } } catch (e) { _logger.severe('Failed to move Z: $e'); + if (!mounted) return; setState(() { _apiErrorState = true; }); - if (mounted) showErrorDialog(context, 'Failed to move Z'); + _safeShowError('Failed to move Z'); } } @@ -80,6 +100,7 @@ class MoveZScreenState extends State { } else { try { await provider.refresh(); + if (!mounted) return; if (provider.config != null) { // Safe to update state here because we're already async and not // in the middle of a build. @@ -91,18 +112,20 @@ class MoveZScreenState extends State { // Provider rethrows on error; surface a dialog from the screen // instead of letting the provider call notifyListeners during // widget build. + if (!mounted) return; setState(() { _apiErrorState = true; }); - if (mounted) showErrorDialog(context, 'BLUE-BANANA'); + _safeShowError('BLUE-BANANA'); _logger.severe('Failed to refresh config: $e'); } } } catch (e) { + if (!mounted) return; setState(() { _apiErrorState = true; }); - if (mounted) showErrorDialog(context, 'BLUE-BANANA'); + _safeShowError('BLUE-BANANA'); _logger.severe('Failed to get max Z: $e'); } } @@ -219,38 +242,46 @@ class MoveZScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Expanded( - child: GlassButton( - onPressed: _apiErrorState - ? null - : () { - final manual = - Provider.of(context, listen: false); - manual.moveDelta(step); - }, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - minimumSize: const Size(double.infinity, double.infinity), - ), - child: const Icon(Icons.arrow_upward, size: 50), + child: Consumer( + builder: (context, manual, _) { + return GlassButton( + onPressed: _apiErrorState || manual.busy + ? null + : () { + final manual = + Provider.of(context, listen: false); + manual.moveDelta(step); + }, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + minimumSize: const Size(double.infinity, double.infinity), + ), + child: PhosphorIcon(PhosphorIcons.arrowUp(), size: 50), + ); + }, ), ), const SizedBox(height: 30), Expanded( - child: GlassButton( - onPressed: _apiErrorState - ? null - : () { - final manual = - Provider.of(context, listen: false); - manual.moveDelta(-step); - }, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - minimumSize: const Size(double.infinity, double.infinity), - ), - child: const Icon(Icons.arrow_downward, size: 50), + child: Consumer( + builder: (context, manual, _) { + return GlassButton( + onPressed: _apiErrorState || manual.busy + ? null + : () { + final manual = + Provider.of(context, listen: false); + manual.moveDelta(-step); + }, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + minimumSize: const Size(double.infinity, double.infinity), + ), + child: PhosphorIcon(PhosphorIcons.arrowDown(), size: 50), + ); + }, ), ), ], @@ -261,78 +292,6 @@ class MoveZScreenState extends State { return Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Expanded( - child: Consumer( - builder: (context, manual, _) { - return FutureBuilder( - future: manual.canMoveToTop(), - builder: (ctx, snap) { - final supportsTop = snap.data == true; - final enabled = !_apiErrorState && - !manual.busy && - (maxZ > 0.0 || supportsTop); - return GlassButton( - onPressed: !enabled - ? null - : () async { - try { - if (supportsTop) { - _logger.info( - 'Moving to device Top via moveToTop()'); - final ok = await manual.moveToTop(); - if (!ok && mounted) - showErrorDialog( - context, 'Failed to move to top'); - } else { - _logger.info('Moving to ZMAX (maxZ=$maxZ)'); - final ok = await manual.move(maxZ); - if (!ok && mounted) - showErrorDialog( - context, 'Failed to move to top'); - } - } catch (e) { - if (mounted) - showErrorDialog( - context, 'Failed to move to top'); - } - }, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - minimumSize: const Size(double.infinity, double.infinity), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 16), - const Icon(Icons.arrow_upward, size: 30), - const Expanded( - child: AutoSizeText( - 'Move to Top Limit', - style: TextStyle(fontSize: 24), - minFontSize: 20, - maxLines: 1, - overflowReplacement: Padding( - padding: EdgeInsets.only(right: 20.0), - child: Center( - child: Text( - 'Top', - style: TextStyle(fontSize: 24), - ), - ), - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ); - }, - ); - }, - ), - ), - const SizedBox(height: 25), Expanded( child: Consumer( builder: (context, manual, _) { @@ -340,10 +299,9 @@ class MoveZScreenState extends State { onPressed: _apiErrorState || manual.busy ? null : () async { - _logger.info('Moving to ZMIN'); + _logger.info('Moving to home position'); final ok = await manual.manualHome(); - if (!ok && mounted) - showErrorDialog(context, 'Failed to home'); + if (!ok) _safeShowError('GOLDEN-APE'); }, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( @@ -353,7 +311,7 @@ class MoveZScreenState extends State { child: Row( children: [ const SizedBox(width: 16), - const Icon(Icons.home, size: 30), + PhosphorIcon(PhosphorIconsFill.house, size: 30), const Expanded( child: AutoSizeText( 'Return to Home', @@ -379,6 +337,93 @@ class MoveZScreenState extends State { ), ), const SizedBox(height: 25), + Expanded( + child: Consumer( + builder: (context, manual, _) { + return FutureBuilder( + future: manual.canMoveToTop(), + builder: (ctx, snap) { + final supportsTop = snap.data == true; + final enabled = !_apiErrorState && + !manual.busy && + (maxZ > 0.0 || supportsTop); + return GlassButton( + onPressed: !enabled + ? null + : () async { + try { + final cfg = OrionConfig(); + if (cfg.isHomePositionUp()) { + _logger + .info('Moving to Floor via moveToFloor()'); + final ok = await manual.moveToFloor(); + if (!ok) _safeShowError('GOLDEN-APE'); + } else if (supportsTop) { + _logger.info( + 'Moving to device Top via moveToTop()'); + final ok = await manual.moveToTop(); + if (!ok) _safeShowError('GOLDEN-APE'); + } else { + _logger.info('Moving to ZMAX (maxZ=$maxZ)'); + final ok = await manual.move(maxZ); + if (!ok) _safeShowError('GOLDEN-APE'); + } + } catch (e) { + if (!mounted) return; + _safeShowError('GOLDEN-APE'); + } + }, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + minimumSize: const Size(double.infinity, double.infinity), + ), + child: Builder( + builder: (ctx) { + final cfg = OrionConfig(); + final topLabel = cfg.isHomePositionUp() + ? 'Move to Floor' + : 'Move to Top'; + final overflowReplacementLabel = + cfg.isHomePositionUp() ? 'Floor' : 'Top'; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 16), + cfg.isHomePositionUp() + ? PhosphorIcon(PhosphorIcons.arrowDown(), + size: 30) + : PhosphorIcon(PhosphorIcons.arrowUp(), + size: 30), + Expanded( + child: AutoSizeText( + topLabel, + style: const TextStyle(fontSize: 24), + minFontSize: 20, + maxLines: 1, + overflowReplacement: Padding( + padding: const EdgeInsets.only(right: 20.0), + child: Center( + child: Text( + overflowReplacementLabel, + style: const TextStyle(fontSize: 24), + ), + ), + ), + textAlign: TextAlign.center, + ), + ), + ], + ); + }, + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 25), Expanded( child: Consumer( builder: (context, manual, _) { @@ -387,9 +432,8 @@ class MoveZScreenState extends State { ? null : () async { _logger.severe('EMERGENCY STOP'); - final ok = await manual.manualCommand('M112'); - if (!ok && mounted) - showErrorDialog(context, 'Failed emergency stop'); + final ok = await manual.emergencyStop(); + if (!ok) _safeShowError('CRITICAL'); }, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( @@ -399,7 +443,7 @@ class MoveZScreenState extends State { child: Row( children: [ const SizedBox(width: 16), - Icon(Icons.stop, + PhosphorIcon(PhosphorIconsFill.stop, size: 30, color: _apiErrorState ? null From 4ee6bc92e73e3530377195a7ed596e7465c3688b Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:35:35 +0200 Subject: [PATCH 44/47] feat(fake_client): add methods for canMoveToFloor, moveToFloor, emergencyStop, and getBackendVersion --- test/fakes/fake_odyssey_client.dart | 23 +++++++++++++++++++ ...ake_odyssey_client_for_thumbnail_test.dart | 20 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/test/fakes/fake_odyssey_client.dart b/test/fakes/fake_odyssey_client.dart index 765fd1c..96be54f 100644 --- a/test/fakes/fake_odyssey_client.dart +++ b/test/fakes/fake_odyssey_client.dart @@ -108,4 +108,27 @@ class FakeOdysseyClient implements OdysseyClient { // No-op fake implementation return {}; } + + @override + Future canMoveToFloor() async { + // Default fake: not supported + return false; + } + + @override + Future> moveToFloor() async { + // No-op fake implementation + return {}; + } + + @override + Future> emergencyStop() async { + lastCommand = 'M112'; + return {}; + } + + @override + Future getBackendVersion() { + return Future.value('0.0.0'); + } } diff --git a/test/fakes/fake_odyssey_client_for_thumbnail_test.dart b/test/fakes/fake_odyssey_client_for_thumbnail_test.dart index 7b515c9..af973fb 100644 --- a/test/fakes/fake_odyssey_client_for_thumbnail_test.dart +++ b/test/fakes/fake_odyssey_client_for_thumbnail_test.dart @@ -110,5 +110,25 @@ class FakeOdysseyClientForThumbnailTest implements OdysseyClient { throw UnimplementedError(); } + @override + Future canMoveToFloor() { + return Future.value(false); + } + + @override + Future> moveToFloor() { + throw UnimplementedError(); + } + void main() {} + + @override + Future> emergencyStop() { + throw UnimplementedError(); + } + + @override + Future getBackendVersion() { + throw UnimplementedError(); + } } From 6f806d43b30e1de80318430b3badcc413fed5031 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 20:44:49 +0200 Subject: [PATCH 45/47] feat(orion_config): enhance vendor configuration management and add feature flag accessors - preparing for Athena-specific features --- lib/util/orion_config.dart | 125 +++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/lib/util/orion_config.dart b/lib/util/orion_config.dart index 324b2b1..da2be91 100644 --- a/lib/util/orion_config.dart +++ b/lib/util/orion_config.dart @@ -66,6 +66,9 @@ class OrionConfig { _logger.config('setString: $key to ${value == '' ? 'NULL' : value}'); } + // NOTE: we intentionally do not write other flags here. Use explicit + // configuration management to keep side-effects visible. + _writeConfig(config); } @@ -170,6 +173,110 @@ class OrionConfig { } } + /// Return the vendor-declared machine model name. + String getMachineModelName() { + var vendor = _getVendorConfig(); + return vendor['machineModelName'] ?? + vendor['vendor']?['machineModelName'] ?? + vendor['vendor']?['vendorMachineName'] ?? + '3D Printer'; + } + + /// Read the vendor `homePosition` setting. Expected values: 'up'|'down'. + /// Defaults to 'down' when absent or unrecognized. + String getHomePosition() { + final vendor = _getVendorConfig(); + final hp = vendor['homePosition'] ?? vendor['vendor']?['homePosition']; + if (hp is String) { + final v = hp.toLowerCase(); + if (v == 'up' || v == 'down') return v; + } + return 'down'; + } + + /// Convenience boolean: true when configured 'up' + bool isHomePositionUp() => getHomePosition() == 'up'; + + /// Query a boolean feature flag from the vendor `featureFlags` section. + /// Returns [defaultValue] when not present. + bool getFeatureFlag(String key, {bool defaultValue = false}) { + var vendor = _getVendorConfig(); + final flags = vendor['featureFlags']; + if (flags is Map && flags.containsKey(key)) { + return flags[key] == true; + } + return defaultValue; + } + + // --- Convenience accessors for known vendor feature flags --- + bool enableBetaFeatures() => getFeatureFlag('enableBetaFeatures'); + bool enableDeveloperSettings() => getFeatureFlag('enableDeveloperSettings'); + bool enableAdvancedSettings() => getFeatureFlag('enableAdvancedSettings'); + bool enableExperimentalFeatures() => + getFeatureFlag('enableExperimentalFeatures'); + bool enableResinProfiles() => getFeatureFlag('enableResinProfiles'); + bool enableCustomName() => getFeatureFlag('enableCustomName'); + + /// Return nested `hardwareFeatures` map (may be empty) + Map getHardwareFeatures() { + var vendor = _getVendorConfig(); + final flags = vendor['featureFlags']; + if (flags is Map && flags['hardwareFeatures'] is Map) { + return Map.from(flags['hardwareFeatures']); + } + return {}; + } + + /// Generic helper to read a hardware feature boolean from + /// `featureFlags.hardwareFeatures`. + bool getHardwareFeature(String key, {bool defaultValue = false}) { + final hw = getHardwareFeatures(); + if (hw.containsKey(key)) return hw[key] == true; + return defaultValue; + } + + // --- Convenience accessors for common hardware features --- + bool hasHeatedChamber() => getHardwareFeature('hasHeatedChamber'); + bool hasHeatedVat() => getHardwareFeature('hasHeatedVat'); + bool hasCamera() => getHardwareFeature('hasCamera'); + bool hasAirFilter() => getHardwareFeature('hasAirFilter'); + bool hasForceSensor() => getHardwareFeature('hasForceSensor'); + + /// Return the full featureFlags map (may be empty) + Map getFeatureFlags() { + var vendor = _getVendorConfig(); + final flags = vendor['featureFlags']; + if (flags is Map) return Map.from(flags); + return {}; + } + + /// Read the internalConfig section (vendor-specified internal config) + Map getInternalConfig() { + var vendor = _getVendorConfig(); + final internal = vendor['internalConfig']; + if (internal is Map) { + return Map.from(internal); + } + return {}; + } + + /// Convenience for string-backed internalConfig values. + String getInternalConfigString(String key, {String defaultValue = ''}) { + final internal = getInternalConfig(); + return internal[key]?.toString() ?? defaultValue; + } + + /// Convenience check for whether the app should operate in NanoDLP mode. + /// Determined solely from the merged 'advanced.backend' setting. + bool isNanoDlpMode() { + try { + final backend = getString('backend', category: 'advanced'); + return backend.toLowerCase() == 'nanodlp'; + } catch (_) { + return false; + } + } + Map _getConfig() { var fullPath = path.join(_configPath, 'orion.cfg'); var configFile = File(fullPath); @@ -209,6 +316,24 @@ class OrionConfig { void _writeConfig(Map config) { // Remove any vendor section before writing to orion.cfg var configToWrite = Map.from(config); + // If vendor provides internalConfig.backend or internalConfig.defaultLanguage, + // copy them into the 'advanced' section so they persist in orion.cfg. + final vendor = _getVendorConfig(); + final internal = vendor['internalConfig']; + if (internal is Map) { + configToWrite['advanced'] ??= {}; + if (internal.containsKey('backend') && + (configToWrite['advanced']['backend'] == null || + configToWrite['advanced']['backend'] == '')) { + configToWrite['advanced']['backend'] = internal['backend']; + } + if (internal.containsKey('defaultLanguage') && + (configToWrite['advanced']['defaultLanguage'] == null || + configToWrite['advanced']['defaultLanguage'] == '')) { + configToWrite['advanced']['defaultLanguage'] = + internal['defaultLanguage']; + } + } configToWrite.remove('vendor'); var fullPath = path.join(_configPath, 'orion.cfg'); From bb40aeedc028ab504a86dd41b008635fdb3fe573 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Sun, 12 Oct 2025 21:13:56 +0200 Subject: [PATCH 46/47] feat(script): enhance uninstall process and add vendor configuration support --- scripts/install_orion_athena2.sh | 77 +++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/scripts/install_orion_athena2.sh b/scripts/install_orion_athena2.sh index 2268386..873d3cc 100644 --- a/scripts/install_orion_athena2.sh +++ b/scripts/install_orion_athena2.sh @@ -48,10 +48,25 @@ uninstall_orion() { systemctl daemon-reload if (( enable_nano )); then + printf '\n[%s] Attempting to re-enable nanodlp-dsi.service...\n' "$SCRIPT_NAME" + # Reload systemd in case unit files changed + systemctl daemon-reload || true + + # Ensure service is not masked, then enable & start it. Prefer --now to + # enable+start atomically; fall back to start if enable fails. if systemctl list-unit-files | grep -q '^nanodlp-dsi.service'; then - systemctl enable nanodlp-dsi.service || true - systemctl start nanodlp-dsi.service || true + systemctl unmask nanodlp-dsi.service 2>/dev/null || true + if ! systemctl enable --now nanodlp-dsi.service 2>/dev/null; then + systemctl start nanodlp-dsi.service 2>/dev/null || true + fi + else + # If the unit is not registered as an installed unit, still try to + # start it in case the runtime unit exists. + systemctl start nanodlp-dsi.service 2>/dev/null || true fi + + # Print status for debugging in case the start failed + systemctl --no-pager status nanodlp-dsi.service || true fi rm -rf "$ORION_DIR" @@ -59,6 +74,9 @@ uninstall_orion() { if [[ -f "$CONFIG_PATH" ]]; then rm -f "$CONFIG_PATH" fi + if [[ -f "$VENDOR_CONFIG_PATH" ]]; then + rm -f "$VENDOR_CONFIG_PATH" + fi rm -f "$ACTIVATE_PATH" "$REVERT_PATH" @@ -112,6 +130,7 @@ main() { ORION_DIR="${TARGET_HOME}/orion" SERVICE_PATH="/etc/systemd/system/orion.service" CONFIG_PATH="${TARGET_HOME}/orion.cfg" + VENDOR_CONFIG_PATH="${TARGET_HOME}/vendor.cfg" BIN_DIR="/usr/local/bin" ACTIVATE_PATH="${BIN_DIR}/activate_orion" REVERT_PATH="${BIN_DIR}/revert_orion" @@ -189,7 +208,27 @@ main() { ts=$(date +%s) cp "$CONFIG_PATH" "${CONFIG_PATH}.bak.${ts}" fi + if [[ -f "$VENDOR_CONFIG_PATH" ]]; then + local vts + vts=$(date +%s) + cp "$VENDOR_CONFIG_PATH" "${VENDOR_CONFIG_PATH}.bak.${vts}" + fi HOSTNAME_VALUE=$(hostname) + # Attempt to read a hardware serial from the device tree. Some systems + # expose a serial at /sys/firmware/devicetree/base/serial-number. + # The file may contain a trailing NUL; strip it. If unavailable, fall + # back to a safe default to keep the config valid. + if [[ -r "/sys/firmware/devicetree/base/serial-number" ]]; then + MACHINE_SERIAL=$(tr -d '\0' /dev/null || true) + # Remove newlines/carriage returns and any remaining non-printables. + MACHINE_SERIAL=$(printf '%s' "$MACHINE_SERIAL" | tr -d '\r\n' | sed 's/[^[:print:]]//g') + # Escape any double quotes to keep the JSON valid. + MACHINE_SERIAL=$(printf '%s' "$MACHINE_SERIAL" | sed 's/"/\\\"/g') + MACHINE_SERIAL=${MACHINE_SERIAL:-ATHENA2-0001} + printf '[%s] Detected machine serial: %s\n' "$SCRIPT_NAME" "$MACHINE_SERIAL" + else + MACHINE_SERIAL="ATHENA2-0001" + fi printf '\n[%s] Writing default Orion configuration to %s...\n' "$SCRIPT_NAME" "$CONFIG_PATH" cat >"$CONFIG_PATH" <"$VENDOR_CONFIG_PATH" <<'EOF' +{ + "vendor": { + "vendorName": "Concepts 3D", + "vendorMachineName": "Athena 2", + "machineModelName": "Athena 2", + "homePosition": "up", + "vendorUrl": "https://concepts3d.ca" + }, + "featureFlags": { + "enableBetaFeatures": false, + "enableDeveloperSettings": false, + "enableAdvancedSettings": true, + "enableExperimentalFeatures": false, + "enableResinProfiles": true, + "enableCustomName": false, + "hardwareFeatures": { + "hasHeatedChamber": true, + "hasHeatedVat": true, + "hasCamera": true, + "hasAirFilter": true, + "hasForceSensor": true + } + }, + "advanced": { + "backend": "nanodlp", + "defaultLanguage": "en" + } +} +EOF + chown "$ORIGINAL_USER":"$ORIGINAL_USER" "$VENDOR_CONFIG_PATH" + printf '\n[%s] Writing systemd service to %s...\n' "$SCRIPT_NAME" "$SERVICE_PATH" cat >"$SERVICE_PATH" < Date: Sun, 12 Oct 2025 21:14:03 +0200 Subject: [PATCH 47/47] feat(about_screen): replace Text with AutoSizeText for better version display --- lib/settings/about_screen.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/settings/about_screen.dart b/lib/settings/about_screen.dart index ff2784f..179a70e 100644 --- a/lib/settings/about_screen.dart +++ b/lib/settings/about_screen.dart @@ -17,6 +17,7 @@ import 'dart:io'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -181,7 +182,11 @@ class AboutScreenState extends State { subtitle: FutureBuilder( future: getVersionNumber(), builder: (BuildContext context, AsyncSnapshot snapshot) { - return Text(snapshot.data ?? 'N/A'); + return AutoSizeText( + snapshot.data ?? 'N/A', + maxLines: 1, + minFontSize: 12, + ); }, ), ),