From 06bcad46d9cbabe4628e4ef6ebf1f54c166d9144 Mon Sep 17 00:00:00 2001 From: Igor Demyanov Date: Fri, 23 Oct 2026 18:47:10 +0300 Subject: [PATCH] support dpms v2 --- packages/dpms/.gitignore | 7 ++ packages/dpms/CHANGELOG.md | 7 ++ packages/dpms/LICENSE | 21 +++++ packages/dpms/README.md | 81 ++++++++++++++++++ packages/dpms/analysis_options.yaml | 1 + packages/dpms/example/main.dart | 17 ++++ packages/dpms/lib/dpms.dart | 5 ++ packages/dpms/lib/src/dpms.dart | 39 +++++++++ packages/dpms/lib/src/dpms_stub.dart | 3 + packages/dpms/lib/src/platform/flutterpi.dart | 59 +++++++++++++ packages/dpms/lib/src/power_mode.dart | 8 ++ packages/dpms/pubspec.yaml | 11 +++ packages/dpms/test/dpms_test.dart | 40 +++++++++ packages/dpms/test/ffi_test.dart | 82 +++++++++++++++++++ packages/dpms/test/fixtures/dpms.c | 30 +++++++ packages/dpms/test/web_test.dart | 11 +++ 16 files changed, 422 insertions(+) create mode 100644 packages/dpms/.gitignore create mode 100644 packages/dpms/CHANGELOG.md create mode 100644 packages/dpms/LICENSE create mode 100644 packages/dpms/README.md create mode 100644 packages/dpms/analysis_options.yaml create mode 100644 packages/dpms/example/main.dart create mode 100644 packages/dpms/lib/dpms.dart create mode 100644 packages/dpms/lib/src/dpms.dart create mode 100644 packages/dpms/lib/src/dpms_stub.dart create mode 100644 packages/dpms/lib/src/platform/flutterpi.dart create mode 100644 packages/dpms/lib/src/power_mode.dart create mode 100644 packages/dpms/pubspec.yaml create mode 100644 packages/dpms/test/dpms_test.dart create mode 100644 packages/dpms/test/ffi_test.dart create mode 100644 packages/dpms/test/fixtures/dpms.c create mode 100644 packages/dpms/test/web_test.dart diff --git a/packages/dpms/.gitignore b/packages/dpms/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/dpms/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/dpms/CHANGELOG.md b/packages/dpms/CHANGELOG.md new file mode 100644 index 0000000..a077abf --- /dev/null +++ b/packages/dpms/CHANGELOG.md @@ -0,0 +1,7 @@ +## Unreleased + +- Use nullable `Dpms.instance` and a common base class with a flutter-pi backend. +- Bind the signed `flutterpi_dpms_*` ABI and report native errors as `DpmsException`. +- Correctly return `PowerMode.off` from `getMode()`. +- Return no implementation on unsupported platforms, including JavaScript/Wasm. +- Add usage/migration documentation, tests and an example. diff --git a/packages/dpms/LICENSE b/packages/dpms/LICENSE new file mode 100644 index 0000000..c4b6a63 --- /dev/null +++ b/packages/dpms/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Igor Demyanov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dpms/README.md b/packages/dpms/README.md new file mode 100644 index 0000000..3578c6c --- /dev/null +++ b/packages/dpms/README.md @@ -0,0 +1,81 @@ +# dpms + +Display power management for supported Linux embedders. Currently supports the +KMS output selected by **flutter-pi with the DPMS FFI bridge**. A desktop Flutter +Linux runner, an older flutter-pi build, a dummy display or an unsupported +platform returns `null` from `Dpms.instance`. + +```dart +import 'package:dpms/dpms.dart'; + +Future sleepDisplay() async { + final dpms = Dpms.instance; + if (dpms == null) return; + + try { + dpms.setMode(PowerMode.off); + await Future.delayed(const Duration(seconds: 5)); + } finally { + dpms.setMode(PowerMode.on); + } +} +``` + +Only `PowerMode.on` and `PowerMode.off` are exposed. flutter-pi retains the +latest composition while off and presents it when the display is turned on. +`getMode()` returns the last successfully requested mode, initially `on`, +not the state of a monitor's own power button or changes by other processes. + +`setMode` is synchronous and may wait for a display modeset. Operations can +throw `DpmsException`, whose `errorCode` is a positive Linux errno; for example, +a temporarily unavailable DRM master can make a request fail. Availability is +resolved lazily once per Dart isolate. A non-null instance indicates support, +not that every subsequent operation will succeed. + +The package does not require Flutter itself. Other embedders can implement the +`Dpms` base class, with their own mechanism for obtaining an instance. On web, +including JavaScript and WebAssembly, the default implementation is absent and +`Dpms.instance` is `null`; it does not pretend to switch display power. + +## Migration from the previous API + +Replace constructing `Dpms()` and calling `isAvailable()` with a nullable +`Dpms.instance` check. The mode names remain `PowerMode.on/off`: + +```dart +final dpms = Dpms.instance; +if (dpms == null) { + // This embedder/output has no DPMS implementation. + return; +} + +try { + dpms.setMode(PowerMode.off); +} on DpmsException catch (error) { + print('Could not switch display power: ${error.errorCode}'); +} +``` + +Update flutter-pi and this package together. The new ABI uses +`flutterpi_dpms_is_available`, `flutterpi_dpms_get`, and `flutterpi_dpms_set`; +old `dpms_isAvailable`/`dpms_getProperty`/`dpms_setProperty` symbols are not used. +Its native get result is signed (`0` off, `1` on, negative errno); the set result +is zero on success or a positive errno. The set argument is C `bool`. + +See flutter-pi's `docs/DPMS.md` for native implementation details and a +Raspberry Pi 4/5 hardware verification checklist. + +## Development + +```sh +dart pub get +dart analyze --fatal-infos +dart test +dart compile js example/main.dart -o /tmp/dpms-example.js +dart compile wasm example/main.dart -o /tmp/dpms-example.wasm +``` + +Linux FFI tests compile a small C fixture with `cc` to check the actual native +calling convention and error values. The optional browser test can be run with +`dart test --platform chrome test/web_test.dart` when Chrome is installed. +These checks do not replace testing screen power on actual Raspberry Pi boards. diff --git a/packages/dpms/analysis_options.yaml b/packages/dpms/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/packages/dpms/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/dpms/example/main.dart b/packages/dpms/example/main.dart new file mode 100644 index 0000000..f93462c --- /dev/null +++ b/packages/dpms/example/main.dart @@ -0,0 +1,17 @@ +import 'package:dpms/dpms.dart'; + +Future main() async { + final dpms = Dpms.instance; + if (dpms == null) { + print('DPMS is unavailable in this embedder.'); + return; + } + + try { + print('Current mode: ${dpms.getMode()}'); + dpms.setMode(PowerMode.off); + await Future.delayed(const Duration(seconds: 5)); + } finally { + dpms.setMode(PowerMode.on); + } +} diff --git a/packages/dpms/lib/dpms.dart b/packages/dpms/lib/dpms.dart new file mode 100644 index 0000000..dc4ba24 --- /dev/null +++ b/packages/dpms/lib/dpms.dart @@ -0,0 +1,5 @@ +/// Display power management for supported embedders. +library; + +export 'src/dpms.dart'; +export 'src/power_mode.dart'; diff --git a/packages/dpms/lib/src/dpms.dart b/packages/dpms/lib/src/dpms.dart new file mode 100644 index 0000000..9165ef4 --- /dev/null +++ b/packages/dpms/lib/src/dpms.dart @@ -0,0 +1,39 @@ +import 'dpms_stub.dart' if (dart.library.ffi) 'platform/flutterpi.dart' as platform; +import 'power_mode.dart'; + +/// Controls the power of the display selected by the embedder. +/// +/// Currently implemented by flutter-pi for its selected KMS output. +abstract base class Dpms { + /// The platform implementation, or `null` if DPMS is unavailable. + /// + /// Resolved lazily once per isolate. No support is assumed merely because + /// the program runs on Linux: the embedder must provide the native ABI. + static final Dpms? instance = platform.createInstance(); + + /// Sets the display power mode. + /// + /// The operation is synchronous and may wait for a display modeset. + /// Throws [DpmsException] when the embedder cannot apply the change. + void setMode(PowerMode mode); + + /// Returns the last mode successfully requested through the embedder. + /// + /// This does not measure the monitor's own power button or external changes. + /// Throws [DpmsException] if the embedder cannot report the mode. + PowerMode getMode(); +} + +/// An error returned by the native DPMS implementation. +final class DpmsException implements Exception { + const DpmsException(this.operation, this.errorCode); + + /// The failed operation, e.g. `setMode` or `getMode`. + final String operation; + + /// The positive Linux errno value returned by the embedder. + final int errorCode; + + @override + String toString() => 'DpmsException: $operation failed (errno $errorCode)'; +} diff --git a/packages/dpms/lib/src/dpms_stub.dart b/packages/dpms/lib/src/dpms_stub.dart new file mode 100644 index 0000000..f8383c1 --- /dev/null +++ b/packages/dpms/lib/src/dpms_stub.dart @@ -0,0 +1,3 @@ +import 'dpms.dart'; + +Dpms? createInstance() => null; diff --git a/packages/dpms/lib/src/platform/flutterpi.dart b/packages/dpms/lib/src/platform/flutterpi.dart new file mode 100644 index 0000000..3708bdf --- /dev/null +++ b/packages/dpms/lib/src/platform/flutterpi.dart @@ -0,0 +1,59 @@ +import 'dart:ffi' as ffi; +import 'dart:io'; + +import '../dpms.dart'; +import '../power_mode.dart'; + +typedef _NativeGet = ffi.Int32 Function(); +typedef _NativeSet = ffi.Int32 Function(ffi.Bool); + +Dpms? createInstance() { + if (!Platform.isLinux) return null; + try { + return FlutterpiDpms.fromLocalProcess(); + } on UnsupportedError { + return null; + } +} + +/// flutter-pi's signed, errno-based FFI implementation. +final class FlutterpiDpms extends Dpms { + FlutterpiDpms(this._getMode, this._setMode); + + factory FlutterpiDpms.fromLocalProcess() => FlutterpiDpms.fromLibrary(ffi.DynamicLibrary.process()); + + /// Binds the same ABI from a library, also useful for native integration tests. + factory FlutterpiDpms.fromLibrary(ffi.DynamicLibrary library) { + try { + final isAvailable = library.lookupFunction<_NativeGet, int Function()>('flutterpi_dpms_is_available'); + final getMode = library.lookupFunction<_NativeGet, int Function()>('flutterpi_dpms_get'); + final setMode = library.lookupFunction<_NativeSet, int Function(bool)>('flutterpi_dpms_set'); + if (isAvailable() != 1) { + throw UnsupportedError('The selected flutter-pi output does not support DPMS.'); + } + return FlutterpiDpms(getMode, setMode); + } on ArgumentError { + throw UnsupportedError('The embedder does not provide the flutter-pi DPMS ABI.'); + } + } + + final int Function() _getMode; + final int Function(bool) _setMode; + + @override + void setMode(PowerMode mode) { + final result = _setMode(mode == PowerMode.on); + if (result != 0) throw DpmsException('setMode', result.abs()); + } + + @override + PowerMode getMode() { + final result = _getMode(); + if (result < 0) throw DpmsException('getMode', -result); + return switch (result) { + 0 => PowerMode.off, + 1 => PowerMode.on, + _ => throw StateError('Invalid flutter-pi DPMS mode: $result'), + }; + } +} diff --git a/packages/dpms/lib/src/power_mode.dart b/packages/dpms/lib/src/power_mode.dart new file mode 100644 index 0000000..1e0284d --- /dev/null +++ b/packages/dpms/lib/src/power_mode.dart @@ -0,0 +1,8 @@ +/// Power modes supported by atomic KMS drivers. +enum PowerMode { + /// Display enabled. + on, + + /// Display powered down, retaining its mode and the latest composition. + off, +} diff --git a/packages/dpms/pubspec.yaml b/packages/dpms/pubspec.yaml new file mode 100644 index 0000000..fb9486e --- /dev/null +++ b/packages/dpms/pubspec.yaml @@ -0,0 +1,11 @@ +name: dpms +description: Display power management for supported Linux embedders, including flutter-pi. +version: 1.0.0+1 +repository: https://github.com/ardera/flutter_packages + +environment: + sdk: '>=3.0.0 <4.0.0' + +dev_dependencies: + lints: ^3.0.0 + test: ^1.21.0 diff --git a/packages/dpms/test/dpms_test.dart b/packages/dpms/test/dpms_test.dart new file mode 100644 index 0000000..a229187 --- /dev/null +++ b/packages/dpms/test/dpms_test.dart @@ -0,0 +1,40 @@ +@TestOn('vm') +library; + +import 'package:dpms/dpms.dart'; +import 'package:dpms/src/platform/flutterpi.dart'; +import 'package:test/test.dart'; + +void main() { + test('a regular Dart process has no DPMS implementation', () { + expect(Dpms.instance, isNull); + }); + + test('both power modes round trip, including off', () { + var nativeMode = 1; + final dpms = FlutterpiDpms(() => nativeMode, (enabled) { + nativeMode = enabled ? 1 : 0; + return 0; + }); + expect(dpms.getMode(), PowerMode.on); + dpms.setMode(PowerMode.off); + expect(dpms.getMode(), PowerMode.off); + dpms.setMode(PowerMode.on); + expect(dpms.getMode(), PowerMode.on); + }); + + test('read errors remain errors instead of being interpreted as on', () { + final dpms = FlutterpiDpms(() => -19, (_) => 0); + expect(dpms.getMode, throwsA(isA().having((e) => e.errorCode, 'errno', 19))); + }); + + test('failed mode changes propagate the native errno', () { + final dpms = FlutterpiDpms(() => 1, (_) => 16); + expect(() => dpms.setMode(PowerMode.off), throwsA(isA().having((e) => e.errorCode, 'errno', 16))); + }); + + test('unexpected native mode is rejected', () { + final dpms = FlutterpiDpms(() => 3, (_) => 0); + expect(dpms.getMode, throwsStateError); + }); +} diff --git a/packages/dpms/test/ffi_test.dart b/packages/dpms/test/ffi_test.dart new file mode 100644 index 0000000..ccb1adb --- /dev/null +++ b/packages/dpms/test/ffi_test.dart @@ -0,0 +1,82 @@ +@TestOn('vm') +library; + +import 'dart:ffi' as ffi; +import 'dart:io'; + +import 'package:dpms/dpms.dart'; +import 'package:dpms/src/platform/flutterpi.dart'; +import 'package:test/test.dart'; + +typedef _ConfigureNative = ffi.Void Function(ffi.Int32, ffi.Int32, ffi.Int32); + +void main() { + group('flutter-pi FFI ABI', () { + late Directory temporary; + late ffi.DynamicLibrary library; + late ffi.DynamicLibrary missingSymbols; + late void Function(int, int, int) configure; + + setUpAll(() async { + temporary = await Directory.systemTemp.createTemp('dpms-ffi-test-'); + for (final missing in [false, true]) { + final output = '${temporary.path}/${missing ? 'missing' : 'dpms'}.so'; + final result = await Process.run('cc', [ + '-shared', + '-fPIC', + if (missing) '-DDPMS_TEST_MISSING_SYMBOLS', + 'test/fixtures/dpms.c', + '-o', + output, + ]); + if (result.exitCode != 0) { + fail('Could not build native DPMS fixture: ${result.stderr}'); + } + final loaded = ffi.DynamicLibrary.open(output); + if (missing) { + missingSymbols = loaded; + } else { + library = loaded; + } + } + configure = library.lookupFunction<_ConfigureNative, void Function(int, int, int)>('dpms_test_configure'); + }); + + tearDownAll(() async { + await temporary.delete(recursive: true); + }); + + setUp(() => configure(1, 0, 0)); + + test('C bool and signed int32 bindings round trip both modes', () { + final dpms = FlutterpiDpms.fromLibrary(library); + dpms.setMode(PowerMode.off); + expect(dpms.getMode(), PowerMode.off); + dpms.setMode(PowerMode.on); + expect(dpms.getMode(), PowerMode.on); + }); + + test('negative native read errno crosses FFI intact', () { + final dpms = FlutterpiDpms.fromLibrary(library); + configure(1, 19, 0); + expect(dpms.getMode, throwsA(isA().having((e) => e.errorCode, 'errno', 19))); + }); + + test('failed native write crosses FFI intact', () { + final dpms = FlutterpiDpms.fromLibrary(library); + dpms.setMode(PowerMode.on); + configure(1, 0, 16); + expect(() => dpms.setMode(PowerMode.off), throwsA(isA().having((e) => e.errorCode, 'errno', 16))); + expect(dpms.getMode(), PowerMode.on); + }); + + test('symbols alone do not imply support for the selected output', () { + configure(0, 0, 0); + expect(() => FlutterpiDpms.fromLibrary(library), throwsUnsupportedError); + }); + + test('an embedder without the ABI is unsupported', () { + expect(() => FlutterpiDpms.fromLibrary(missingSymbols), throwsUnsupportedError); + }); + }, skip: !Platform.isLinux ? 'Native fixture targets Linux.' : false); +} diff --git a/packages/dpms/test/fixtures/dpms.c b/packages/dpms/test/fixtures/dpms.c new file mode 100644 index 0000000..9531ed6 --- /dev/null +++ b/packages/dpms/test/fixtures/dpms.c @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +#include +#include + +static int32_t available = 1; +static int32_t mode = 1; +static int32_t get_error; +static int32_t set_error; + +void dpms_test_configure(int32_t supported, int32_t read_error, int32_t write_error) { + available = supported; + get_error = read_error; + set_error = write_error; +} + +#ifndef DPMS_TEST_MISSING_SYMBOLS +int32_t flutterpi_dpms_is_available(void) { + return available; +} + +int32_t flutterpi_dpms_get(void) { + return get_error == 0 ? mode : -get_error; +} + +int32_t flutterpi_dpms_set(bool enabled) { + if (set_error != 0) return set_error; + mode = enabled ? 1 : 0; + return 0; +} +#endif diff --git a/packages/dpms/test/web_test.dart b/packages/dpms/test/web_test.dart new file mode 100644 index 0000000..280b7b9 --- /dev/null +++ b/packages/dpms/test/web_test.dart @@ -0,0 +1,11 @@ +@TestOn('browser') +library; + +import 'package:dpms/dpms.dart'; +import 'package:test/test.dart'; + +void main() { + test('DPMS is unavailable on web', () { + expect(Dpms.instance, isNull); + }); +}