Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/dpms/.gitignore
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions packages/dpms/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/dpms/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions packages/dpms/README.md
Original file line number Diff line number Diff line change
@@ -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<void> sleepDisplay() async {
final dpms = Dpms.instance;
if (dpms == null) return;

try {
dpms.setMode(PowerMode.off);
await Future<void>.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.
1 change: 1 addition & 0 deletions packages/dpms/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include: package:lints/recommended.yaml
17 changes: 17 additions & 0 deletions packages/dpms/example/main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:dpms/dpms.dart';

Future<void> 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<void>.delayed(const Duration(seconds: 5));
} finally {
dpms.setMode(PowerMode.on);
}
}
5 changes: 5 additions & 0 deletions packages/dpms/lib/dpms.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// Display power management for supported embedders.
library;

export 'src/dpms.dart';
export 'src/power_mode.dart';
39 changes: 39 additions & 0 deletions packages/dpms/lib/src/dpms.dart
Original file line number Diff line number Diff line change
@@ -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)';
}
3 changes: 3 additions & 0 deletions packages/dpms/lib/src/dpms_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import 'dpms.dart';

Dpms? createInstance() => null;
59 changes: 59 additions & 0 deletions packages/dpms/lib/src/platform/flutterpi.dart
Original file line number Diff line number Diff line change
@@ -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'),
};
}
}
8 changes: 8 additions & 0 deletions packages/dpms/lib/src/power_mode.dart
Original file line number Diff line number Diff line change
@@ -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,
}
11 changes: 11 additions & 0 deletions packages/dpms/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions packages/dpms/test/dpms_test.dart
Original file line number Diff line number Diff line change
@@ -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<DpmsException>().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<DpmsException>().having((e) => e.errorCode, 'errno', 16)));
});

test('unexpected native mode is rejected', () {
final dpms = FlutterpiDpms(() => 3, (_) => 0);
expect(dpms.getMode, throwsStateError);
});
}
82 changes: 82 additions & 0 deletions packages/dpms/test/ffi_test.dart
Original file line number Diff line number Diff line change
@@ -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<DpmsException>().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<DpmsException>().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);
}
Loading