frame_sdk 0.0.1 copy "frame_sdk: ^0.0.1" to clipboard
frame_sdk: ^0.0.1 copied to clipboard

The Python SDK for the Frame from Brilliant Labs

example/lib/main.dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';

import 'package:flutter/material.dart';
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:frame_sdk/camera.dart';
import 'package:frame_sdk/display.dart';
import 'package:frame_sdk/frame_sdk.dart';
import 'package:frame_sdk/bluetooth.dart';
import 'package:frame_sdk/motion.dart';

import 'package:logging/logging.dart';
import 'package:collection/collection.dart';
import 'package:path_provider/path_provider.dart';

void main() {
  // Request bluetooth permission
  BrilliantBluetooth.requestPermission();
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String _platformVersion = 'Unknown';
  final _frameSdkPlugin = Frame();
  final List<String> _logMessages = [];
  late final Frame frame;
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    initPlatformState();
    frame = Frame();

    runTests();
  }

  Future<void> runExample() async {
    var logger = Logger.root;
    logger.level = Level.WARNING;
    logger.onRecord.listen((record) {
      _addLogMessage(
          "Log ${record.loggerName} (${record.level.name}): ${record.message}");
    });

    while (!frame.isConnected) {
      _addLogMessage("Trying to connect...");
      final didConnect = await frame.connect();
      if (didConnect) {
        _addLogMessage("Connected to device");
      } else {
        _addLogMessage("Failed to connect to device, will try again...");
      }
    }

    // Check if connected
    _addLogMessage("Connected: ${frame.isConnected}");

    // Get battery level
    int batteryLevel = await frame.getBatteryLevel();
    _addLogMessage("Frame battery: $batteryLevel%");

    // Write file
    await frame.files.writeFile("greeting.txt", utf8.encode("Hello world"));

    // Read file
    String fileContent =
        utf8.decode(await frame.files.readFile("greeting.txt"));
    _addLogMessage(fileContent);

    // Display text
    await frame.runLua(
        "frame.display.text('Hello world', 50, 100);frame.display.show()");

    // Evaluate expression
    _addLogMessage(await frame.evaluate("1+2"));

    _addLogMessage("Tap the Frame to continue...");
    await frame.display.showText("Tap the Frame to take a photo",
        align: Alignment2D.middleCenter);
    await frame.motion.waitForTap();

    // Take and save photo
    await frame.display
        .showText("Taking photo...", align: Alignment2D.middleCenter);
    await frame.camera.savePhoto("frame-test-photo.jpg");
    await frame.display
        .showText("Photo saved!", align: Alignment2D.middleCenter);

    // Take photo with more control
    await frame.camera.savePhoto("frame-test-photo-2.jpg",
        autofocusSeconds: 3,
        quality: PhotoQuality.high,
        autofocusType: AutoFocusType.centerWeighted);

    // Get raw photo bytes
    Uint8List photoBytes = await frame.camera.takePhoto(autofocusSeconds: 1);
    _addLogMessage("Photo bytes: ${photoBytes.length}");

    _addLogMessage("About to record until you stop talking");
    await frame.display
        .showText("Say something...", align: Alignment2D.middleCenter);

    // Record audio to file
    double length = await frame.microphone.saveAudioFile("test-audio.wav");
    _addLogMessage(
        "Recorded ${length.toStringAsFixed(1)} seconds: \"./test-audio.wav\"");
    await frame.display.showText(
        "Recorded ${length.toStringAsFixed(1)} seconds",
        align: Alignment2D.middleCenter);
    await Future.delayed(const Duration(seconds: 3));

    // Record audio to memory
    await frame.display
        .showText("Say something else...", align: Alignment2D.middleCenter);
    Uint8List audioData =
        await frame.microphone.recordAudio(maxLength: const Duration(seconds: 10));
    await frame.display.showText(
        "Recorded ${(audioData.length / frame.microphone.sampleRate.toDouble()).toStringAsFixed(1)} seconds of audio",
        align: Alignment2D.middleCenter);

    _addLogMessage("Move around to track intensity of your motion");
    await frame.display.showText(
        "Move around to track intensity of your motion",
        align: Alignment2D.middleCenter);
    double intensityOfMotion = 0;
    Direction prevDirection = await frame.motion.getDirection();
    for (int i = 0; i < 10; i++) {
      await Future.delayed(const Duration(milliseconds: 100));
      Direction direction = await frame.motion.getDirection();
      intensityOfMotion =
          max(intensityOfMotion, (direction - prevDirection).amplitude());
      prevDirection = direction;
    }
    _addLogMessage(
        "Intensity of motion: ${intensityOfMotion.toStringAsFixed(2)}");
    await frame.display.showText(
        "Intensity of motion: ${intensityOfMotion.toStringAsFixed(2)}",
        align: Alignment2D.middleCenter);
    _addLogMessage("Tap the Frame to continue...");
    await frame.motion.waitForTap();

    // Show the full palette
    int width = 640 ~/ 4;
    int height = 400 ~/ 4;
    for (int color = 0; color < 16; color++) {
      int tileX = (color % 4);
      int tileY = (color ~/ 4);
      await frame.display.drawRect(tileX * width + 1, tileY * height + 1, width,
          height, PaletteColors.fromIndex(color));
      await frame.display.writeText("$color",
          x: tileX * width + width ~/ 2 + 1,
          y: tileY * height + height ~/ 2 + 1);
    }
    await frame.display.show();

    _addLogMessage("Tap the Frame to continue...");
    await frame.motion.waitForTap();

    // Scroll some long text
    await frame.display.scrollText(
        "Never gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you");

    // Display battery indicator and time as a home screen
    batteryLevel = await frame.getBatteryLevel();
    PaletteColors batteryFillColor = batteryLevel < 20
        ? PaletteColors.red
        : batteryLevel < 50
            ? PaletteColors.yellow
            : PaletteColors.green;
    int batteryWidth = 150;
    int batteryHeight = 75;
    await frame.display.drawRect(
        640 - 32, 40 + batteryHeight ~/ 2 - 8, 32, 16, PaletteColors.white);
    await frame.display.drawRectFilled(
        640 - 16 - batteryWidth,
        40 - 8,
        batteryWidth + 16,
        batteryHeight + 16,
        8,
        PaletteColors.white,
        PaletteColors.voidBlack);
    await frame.display.drawRect(
        640 - 8 - batteryWidth,
        40,
        (batteryWidth * 0.01 * batteryLevel).toInt(),
        batteryHeight,
        batteryFillColor);
    await frame.display.writeText("$batteryLevel%",
        x: 640 - 8 - batteryWidth,
        y: 40,
        maxWidth: batteryWidth,
        maxHeight: batteryHeight,
        align: Alignment2D.middleCenter);
    await frame.display
        .writeText(DateTime.now().toString(), align: Alignment2D.middleCenter);
    await frame.display.show();

    // Set a wake screen via script
    await frame.runOnWake(luaScript: """
      frame.display.text('Battery: ' .. frame.battery_level() ..  '%', 10, 10);
      if frame.time.utc() > 10000 then
        local time_now = frame.time.date();
        frame.display.text(time_now['hour'] .. ':' .. time_now['minute'], 300, 160);
        frame.display.text(time_now['month'] .. '/' .. time_now['day'] .. '/' .. time_now['year'], 300, 220) 
      end;
      frame.display.show();
      frame.sleep(10);
      frame.display.text(' ',1,1);
      frame.display.show();
      frame.sleep()
    """);

    // Tell frame to sleep after 10 seconds then clear the screen and go to sleep
    await frame.runLua(
        "frame.sleep(10);frame.display.text(' ',1,1);frame.display.show();frame.sleep()");
  }

  Future<void> runTests() async {
    var logger = Logger.root;
    logger.level = Level.INFO;
    logger.onRecord.listen((record) {
      print(
          "Log ${record.loggerName} (${record.level.name}): ${record.message}");
    });

    while (!frame.isConnected) {
      _addLogMessage("Trying to connect...");
      final didConnect = await frame.connect();
      if (didConnect) {
        _addLogMessage("Connected to device");
      } else {
        _addLogMessage("Failed to connect to device, will try again...");
      }
    }

    final Directory dir = await getApplicationDocumentsDirectory();
    if (!await dir.exists()) {
      await dir.create();
    }

    await frame.ensureConnected();

    assertTrue('Connected to device', frame.bluetooth.isConnected);

    _addLogMessage("Battery level: ${await frame.getBatteryLevel()}%");
    await frame.display.showText("Battery: ${await frame.getBatteryLevel()}%",
        align: Alignment2D.middleCenter);

    assertEqual("Evaluate 1", "1", await frame.evaluate("1"));
    assertEqual("Evaluate 2", "2", await frame.evaluate("2"));
    assertEqual("Evaluate 3", "3", await frame.evaluate("3"));

    await frame.display.clear();
    await frame.display.clear();
    await frame.display.clear();

    await frame.display.writeText("Hello world!",
        align: Alignment2D.topLeft, color: PaletteColors.skyBlue);
    await frame.display.writeText(
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
        align: Alignment2D.middleCenter,
        color: PaletteColors.white);
    await frame.display.writeText("Goodbye world!",
        align: Alignment2D.bottomRight, color: PaletteColors.pink);
    await frame.display.show();

    await Future.delayed(const Duration(seconds: 2));

    frame.display.charSpacing = 10;
    await frame.display.writeText("Hello world!",
        align: Alignment2D.topRight, color: PaletteColors.cloudBlue);
    await frame.display.writeText(
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
        align: Alignment2D.middleCenter,
        color: PaletteColors.yellow);
    await frame.display.writeText("Goodbye world!",
        align: Alignment2D.bottomLeft, color: PaletteColors.red);
    await frame.display.show();
    await Future.delayed(const Duration(seconds: 2));

    frame.display.charSpacing = 4;

    // Test Bluetooth
    assertEqual(
        "Send lua with response",
        "hi",
        await frame.bluetooth.sendString("print('hi')", awaitResponse: true) ??
            "");
    assertEqual("Send lua without response", null,
        await frame.bluetooth.sendString("tester = 1"));

    assertEqual(
        "Send complex lua with response",
        "c",
        await frame.bluetooth.sendString(
            "frame.bluetooth.receive_callback((function(d)frame.bluetooth.send(d)end));print('c')",
            awaitResponse: true));

    assertEqual(
        "send and receive data",
        "test",
        utf8.decode((await frame.bluetooth.sendData(
            Uint8List.fromList(utf8.encode("test")),
            awaitResponse: true))!));

    assertEqual(
        "send and receive data again",
        "still testing",
        utf8.decode((await frame.bluetooth.sendData(
            Uint8List.fromList(utf8.encode("still testing")),
            awaitResponse: true))!));

    assertEqual(
        "send data without response",
        null,
        await frame.bluetooth
            .sendData(Uint8List.fromList(utf8.encode("test"))));

    await frame.bluetooth.sendString("frame.bluetooth.receive_callback(nil)");

    String longToSend =
        "a = 0;${List.generate(32, (i) => "a = a + 1;").join(" ")}print(a)";
    var longResult = await frame.sendLongLua(longToSend, awaitPrint: true);

    // Test Frame
    assertEqual("Long send lua", "32", longResult);

    assertEqual("Long receive lua", "hi",
        await frame.runLua("prntLng('hi')", awaitPrint: true));

    var msg = "hello world! " * 32;
    await frame.runLua(
        "msg = \"hello world! \";${List.generate(5, (i) => "msg = msg .. msg;").join("")}",
        checked: true);
    assertEqual("Long receive lua message", msg, await frame.evaluate("msg"));

    var batteryLevel = await frame.getBatteryLevel();
    assertTrue(
        "Battery level is valid", batteryLevel > 0 && batteryLevel <= 100);

    // Test long send and receive
    int aCount = 2;
    String message = List.generate(aCount, (i) => "and $i, ").join();
    String script = "message = '';${List.generate(aCount, (i) => "message = message .. 'and $i, '; ")
            .join()}print(message)";
    assertEqual("Long send and receive lua (a=2)", message,
        await frame.runLua(script, awaitPrint: true));

    // Test longer send and receive
    aCount = 50;
    message = List.generate(aCount, (i) => "and $i, ").join();
    script = "message = '';${List.generate(aCount, (i) => "message = message .. 'and $i, '; ")
            .join()}print(message)";
    assertEqual("Longer send and receive lua (a=50)", message,
        await frame.runLua(script, awaitPrint: true));

    // Test battery level comparison
    var batteryLevelFromEvaluate =
        double.parse(await frame.evaluate("frame.battery_level()")).toInt();
    assertAlmostEqual(
        "Battery level from getBatteryLevel and evaluate are close",
        batteryLevel,
        batteryLevelFromEvaluate,
        15);

    // Test time synchronization
    var frameTime = double.parse(await frame.evaluate("frame.time.utc()"));
    var currentTime = DateTime.now().millisecondsSinceEpoch / 1000.0;
    assertAlmostEqual(
        "Frame time is close to current time", frameTime, currentTime, 3);

    // Test Files

    var content = "Testing:\n${"test1... " * 200}\nTesting 2:\n${"test2\n" * 100}";
    await frame.files
        .writeFile("test.txt", utf8.encode(content), checked: true);
    var actualContent = await frame.files.readFile("test.txt");
    assertEqual("Long file length matches", content.trim().length,
        utf8.decode(actualContent).trim().length);
    assertEqual("Long file content matches", content.trim(),
        utf8.decode(actualContent).trim());

    actualContent = await frame.files.readFile("test.txt");
    assertEqual("Long file length matches on second read",
        content.trim().length, utf8.decode(actualContent).trim().length);
    assertEqual("Long file content matches on second read", content.trim(),
        utf8.decode(actualContent).trim());

    await frame.files.deleteFile("test.txt");

    var rawContent = Uint8List.fromList(List.generate(254, (i) => i + 1));
    await frame.files.writeFile("test.dat", rawContent, checked: true);
    actualContent = await frame.files.readFile("test.dat");
    assertEqual("Raw file content matches", rawContent, actualContent);

    actualContent = await frame.files.readFile("test.dat");
    assertEqual(
        "Raw file content matches on second read", rawContent, actualContent);

    await frame.files.writeFile("test.dat", rawContent, checked: true);
    actualContent = await frame.files.readFile("test.dat");
    assertEqual(
        "Raw file content matches after rewrite", rawContent, actualContent);

    actualContent = await frame.files.readFile("test.dat");
    assertEqual("Raw file content matches on second read after rewrite",
        rawContent, actualContent);

    await frame.files.deleteFile("test.dat");

    // Test Camera

    var photo = await frame.camera.takePhoto();
    assertTrue("took photo content greater than 2kb", photo.length > 2000);

    // Test Camera with autofocus options

    var startTime = DateTime.now();
    var photoWithoutAutofocus =
        await frame.camera.takePhoto(autofocusSeconds: null);
    var endTime = DateTime.now();
    var timeWithoutAutofocus = endTime.difference(startTime);
    assertTrue("photo without autofocus content greater than 2kb",
        photoWithoutAutofocus.length > 2000);

    startTime = DateTime.now();
    var photoWithAutofocus1Sec = await frame.camera
        .takePhoto(autofocusSeconds: 1, autofocusType: AutoFocusType.spot);
    endTime = DateTime.now();
    var timeWithAutofocus1Sec = endTime.difference(startTime);
    assertTrue("photo with 1 sec autofocus content greater than 2kb",
        photoWithAutofocus1Sec.length > 2000);
    assertTrue("photo with 1 sec autofocus takes longer than without autofocus",
        timeWithAutofocus1Sec > timeWithoutAutofocus);

    startTime = DateTime.now();
    var photoWithAutofocus3Sec = await frame.camera.takePhoto(
        autofocusSeconds: 3, autofocusType: AutoFocusType.centerWeighted);
    endTime = DateTime.now();
    var timeWithAutofocus3Sec = endTime.difference(startTime);
    assertTrue("photo with 3 sec autofocus content greater than 2kb",
        photoWithAutofocus3Sec.length > 2000);
    assertTrue("photo with 3 sec autofocus takes longer than 1 sec autofocus",
        timeWithAutofocus3Sec > timeWithAutofocus1Sec);

    var file = File("${dir.path}/test.jpg");
    await frame.camera.savePhoto(file.path);
    assertGreaterThan(
        "saved photo size is reasonable", await file.length(), 1000);

    // Test Camera with quality options

    var lowQualityPhoto =
        await frame.camera.takePhoto(quality: PhotoQuality.low);
    assertTrue("low quality photo content greater than 2kb",
        lowQualityPhoto.length > 2000);

    var mediumQualityPhoto =
        await frame.camera.takePhoto(quality: PhotoQuality.medium);
    assertTrue("medium quality photo content greater than 2kb",
        mediumQualityPhoto.length > 2000);
    assertTrue("medium quality photo larger than low quality",
        mediumQualityPhoto.length > lowQualityPhoto.length);

    var highQualityPhoto =
        await frame.camera.takePhoto(quality: PhotoQuality.high);
    assertTrue("high quality photo content greater than 2kb",
        highQualityPhoto.length > 2000);
    assertTrue("high quality photo larger than medium quality",
        highQualityPhoto.length > mediumQualityPhoto.length);

    // Test Display
    await frame.display.showText(
        "In WHITE: Lorem ipsum dolor sit amet, consectetur adipiscing elit.");
    await Future.delayed(const Duration(seconds: 2));
    await frame.display.clear();

    await frame.display.showText(
        "In GEEEN: Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
        color: PaletteColors.green);
    await Future.delayed(const Duration(seconds: 2));

    frame.display.charSpacing = 4;
    int oldWidth = frame.display.getTextWidth("Lorem ipsum!");
    frame.display.charSpacing = 10;
    await frame.display
        .showText("Lorem ipsum dolor sit amet, consectetur adipiscing elit.");
    int newWidth = frame.display.getTextWidth("Lorem ipsum!");
    assertGreaterThan("Text width", newWidth, oldWidth);
    await Future.delayed(const Duration(seconds: 2));
    frame.display.charSpacing = 4;
    // Test Display - Draw rectangles

    await frame.display.drawRect(1, 1, 640, 400, PaletteColors.red);
    await frame.display.drawRect(300, 300, 10, 10, PaletteColors.green);
    await frame.display.drawRectFilled(
        50, 50, 300, 300, 8, PaletteColors.skyBlue, PaletteColors.darkBrown);
    await frame.display.show();
    await Future.delayed(const Duration(seconds: 5));
    await frame.display.clear();

    // Test Display - Word wrap
    String testText = "Hi bob! " * 100;
    String wrapped400 = frame.display.wrapText(testText, 400);
    String wrapped800 = frame.display.wrapText(testText, 800);
    assertEqual("Wrapped text exclamation count remains the same",
        wrapped400.split("!").length - 1, wrapped800.split("!").length - 1);
    assertAlmostEqual("Wrapped text newline count increases",
        wrapped400.split("\n").length, wrapped800.split("\n").length * 2, 3);
    assertAlmostEqual(
        "Wrapped text height",
        frame.display.getTextHeight(wrapped400),
        frame.display.getTextHeight(wrapped800) * 2,
        200);

    frame.display.charSpacing = 10;
    String wideWrapped400 = frame.display.wrapText(testText, 400);
    assertGreaterThan(
        "Wrapped text height increases when we increase char spacing",
        frame.display.getTextHeight(wideWrapped400),
        frame.display.getTextHeight(wrapped400) + 20);
    frame.display.charSpacing = 4;

    // Test Display - Line height
    int initialLineHeight = frame.display.lineHeight;
    assertEqual("Initial line height", initialLineHeight,
        frame.display.getTextHeight("hello world!  123Qgjp@"));
    int heightOfTwoLines = frame.display.getTextHeight("hello\nworldj");
    frame.display.lineHeight += 20;
    assertEqual("Increased line height", heightOfTwoLines + 40,
        frame.display.getTextHeight("hello p\nworld j"));
    frame.display.lineHeight = initialLineHeight; // Reset line height

    // Test Display - Scroll text

    testText =
        "Lorem \"ipsum\" [dolor] 'sit' amet,    consectetur adipiscing elit.\n"
        "Nulla nec nunc euismod, consectetur nunc eu, aliquam nunc.\n"
        "Nulla lorem nec nunc euismod, ipsum consectetur nunc eu, aliquam nunc.";

    Stopwatch stopwatch = Stopwatch()..start();
    await frame.display.scrollText(testText);
    stopwatch.stop();
    int elapsedTime1 = stopwatch.elapsedMilliseconds;

    assertTrue("Scroll text time within range",
        elapsedTime1 >= 5000 && elapsedTime1 < 20000);

    stopwatch.reset();
    stopwatch.start();
    await frame.display.scrollText(testText * 3);
    stopwatch.stop();
    int elapsedTime2 = stopwatch.elapsedMilliseconds;

    assertAlmostEqual(
        "Scroll text time proportional", elapsedTime1 * 3, elapsedTime2, 8000);

    // Test tap handler registration
    await frame.display.showText("Testing tap, tap the Frame!");

    // Test with Lua script
    await frame.motion.runOnTap(luaScript: "print('Tapped!')");

    // Test with Dart callback
    await frame.motion
        .runOnTap(callback: () => frame.display.showText("Tapped!"));

    // Test with both Lua script and Dart callback
    await frame.motion.runOnTap(
        luaScript: "print('tap1')",
        callback: () => frame.display.showText("Tapped again!"));

    // Test clearing tap handlers
    await frame.motion.runOnTap(luaScript: null, callback: null);

    // Test Microphone
    frame.microphone.sampleRate = 8000;
    frame.microphone.bitDepth = 16;
    await frame.display.showText("Testing microphone, please be silent!");
    var audioData = await frame.microphone
        .recordAudio(maxLength: const Duration(seconds: 5));
    assertTrue("Record audio", audioData.isNotEmpty);
    // Test Microphone - End on silence

    var silentAudio = await frame.microphone.recordAudio(
        maxLength: const Duration(seconds: 20),
        silenceCutoffLength: const Duration(seconds: 2));
    await frame.display.clear();
    assertGreaterThan(
        "End on silence recording shorter than max",
        5 * frame.microphone.sampleRate * frame.microphone.bitDepth ~/ 8,
        silentAudio.length);

    // Test Microphone - Save audio file
    file = File("${dir.path}/test.wav");
    var length = await frame.microphone.saveAudioFile(file.path,
        maxLength: const Duration(seconds: 5), silenceCutoffLength: null);
    await frame.display.clear();
    assertAlmostEqual("Saved audio file near 5 seconds", length, 5, 0.5);
    assertTrue("Audio file exists", await file.exists());
    assertTrue(
        "Audio file size greater than 500b", (await file.length()) > 500);
    await file.delete();

    // Test Microphone - Record and play audio
    for (var sampleRate in [8000, 16000]) {
      for (var bitDepth in [8, 16]) {
        if (sampleRate == 16000 && bitDepth == 16) continue;
        _addLogMessage("Testing microphone at ${sampleRate}Hz, ${bitDepth}bit");
        await frame.display.showText(
            "Recording 5 seconds at ${sampleRate}Hz, ${bitDepth}bit",
            align: Alignment2D.middleCenter);
        frame.microphone.sampleRate = sampleRate;
        frame.microphone.bitDepth = bitDepth;
        var data = await frame.microphone.recordAudio(
            maxLength: const Duration(seconds: 5), silenceCutoffLength: null);
        await frame.display
            .showText("Playing back audio", align: Alignment2D.middleCenter);

        var stopwatch = Stopwatch()..start();
        await frame.microphone.playAudio(data);
        stopwatch.stop();
        assertAlmostEqual(
            "Play audio duration (${sampleRate}Hz, ${bitDepth}bit)",
            5,
            stopwatch.elapsedMilliseconds / 1000,
            0.5);
        assertAlmostEqual(
            "Play audio matches data length (${sampleRate}Hz, ${bitDepth}bit)",
            data.length /
                frame.microphone.sampleRate /
                (frame.microphone.bitDepth ~/ 8),
            stopwatch.elapsedMilliseconds / 1000,
            0.4);

        stopwatch.reset();
        stopwatch.start();
        await frame.microphone.playAudio(data);
        stopwatch.stop();
        assertAlmostEqual(
            "Async play audio matches data length (${sampleRate}Hz, ${bitDepth}bit)",
            data.length /
                frame.microphone.sampleRate /
                (frame.microphone.bitDepth ~/ 8),
            stopwatch.elapsedMilliseconds / 1000,
            0.4);

        stopwatch.reset();
        stopwatch.start();
        frame.microphone.playAudio(data);
        stopwatch.stop();
        assertAlmostEqual(
            "Background play audio start time (${sampleRate}Hz, ${bitDepth}bit)",
            0,
            stopwatch.elapsedMilliseconds / 1000,
            0.1);

        await Future.delayed(const Duration(seconds: 5));

        await frame.display.showText(
            "Recording until silence at ${sampleRate}Hz, ${bitDepth}bit",
            align: Alignment2D.middleCenter);
        frame.microphone.sampleRate = sampleRate;
        frame.microphone.bitDepth = bitDepth;
        data = await frame.microphone.recordAudio();
        await frame.display
            .showText("Playing back audio", align: Alignment2D.middleCenter);

        stopwatch = Stopwatch()..start();
        await frame.microphone.playAudio(data);
        stopwatch.stop();
        assertAlmostEqual(
            "Play audio matches data length (${sampleRate}Hz, ${bitDepth}bit)",
            data.length /
                frame.microphone.sampleRate /
                (frame.microphone.bitDepth ~/ 8),
            stopwatch.elapsedMilliseconds / 1000,
            0.4);
      }
    }
    await frame.display.clear();

    // Test Motion
    var direction = await frame.motion.getDirection();
    assertTrue("Get direction pitch within range",
        direction.pitch >= -180 && direction.pitch <= 180);
    assertTrue("Get direction roll within range",
        direction.roll >= -180 && direction.roll <= 180);
    assertTrue("Get direction heading within range",
        direction.heading >= 0 && direction.heading <= 360);

    // Test motion consistency
    await frame.display.showText("Testing motion, don't move the Frame!");
    Direction direction1 = await frame.motion.getDirection();
    await Future.delayed(const Duration(seconds: 1));
    Direction direction2 = await frame.motion.getDirection();
    await frame.display.clear();

    Direction diff = direction2 - direction1;
    assertAlmostEqual("Motion difference amplitude", 0, diff.amplitude(), 10);
    assertAlmostEqual(
        "Pitch consistency", direction1.pitch, direction2.pitch, 5);
    assertAlmostEqual("Roll consistency", direction1.roll, direction2.roll, 5);
    assertAlmostEqual(
        "Heading consistency", direction1.heading, direction2.heading, 5);

    // Test sleep
    await frame.runLua("test_var = 55", checked: true);

    frameTime = double.parse(await frame.evaluate("frame.time.utc()"));
    currentTime = DateTime.now().millisecondsSinceEpoch / 1000.0;
    assertAlmostEqual("Frame time is close to current time before sleep",
        frameTime, currentTime, 3);

    await frame.sleep();

    assertEqual("Variable persists after sleep", "55",
        await frame.evaluate("test_var"));

    // Check that the camera is not awake after sleep
    assertTrue("Camera is not awake after sleep", !frame.camera.isAwake);

    // Take a photo and verify that the camera wakes up
    var result = await frame.camera
        .takePhoto(autofocusSeconds: 1, quality: PhotoQuality.low);
    assertGreaterThan("photo has data", result.lengthInBytes, 100);

    assertTrue("Camera is awake after taking photo", frame.camera.isAwake);

    frameTime = double.parse(await frame.evaluate("frame.time.utc()"));
    currentTime = DateTime.now().millisecondsSinceEpoch / 1000.0;
    assertAlmostEqual("Frame time is close to current time after sleep",
        frameTime, currentTime, 3);

    await frame.motion.runOnTap(callback: () => _addLogMessage("Tapped!"));

    _addLogMessage("All tests completed.");
  }

  Future<void> initPlatformState() async {
    String platformVersion;
    try {
      platformVersion = await _frameSdkPlugin.getPlatformVersion() ??
          'Unknown platform version';
    } on PlatformException {
      platformVersion = 'Failed to get platform version.';
      _addLogMessage(platformVersion);
    }

    if (!mounted) return;

    setState(() {
      _platformVersion = platformVersion;
      _addLogMessage('Platform version: $platformVersion');
    });
  }

  void assertTrue(String message, bool condition) async {
    if (condition) {
      _addLogMessage('✅ Test passed: $message');
    } else {
      _addLogMessage('❌ Test failed: $message');
    }
  }

  void assertEqual(String message, dynamic expected, dynamic actual) async {
    if (expected is List && actual is List) {
      assertEqualLists(message, expected, actual);
    } else if (expected == actual) {
      _addLogMessage('✅ Test passed: $message');
    } else {
      _addLogMessage('❌ Test failed $message: expected $expected, got $actual');
    }
  }

  void assertEqualLists(String message, List expected, List actual) {
    const listEquality = ListEquality();
    if (listEquality.equals(expected, actual)) {
      _addLogMessage('✅ Test passed: $message');
    } else {
      if (expected.length != actual.length) {
        if (expected.length > actual.length) {
          final differentItems =
              expected.where((element) => !actual.contains(element));
          _addLogMessage(
              '❌ Test failed $message: expected list length ${expected.length}, got ${actual.length}.  Expected items not received: $differentItems');
        } else {
          final differentItems =
              actual.where((element) => !expected.contains(element));
          _addLogMessage(
              '❌ Test failed $message: expected list length ${expected.length}, got ${actual.length}.  Actual items not expected: $differentItems');
        }
      } else {
        _addLogMessage(
            '❌ Test failed $message: expected $expected, got $actual');
      }
    }
  }

  void assertGreaterThan(
      String message, num expectedGreater, num expectedLower) {
    if (expectedGreater > expectedLower) {
      _addLogMessage(
          '✅ Test passed $message: expected ${expectedGreater.toStringAsFixed(2)} > ${expectedLower.toStringAsFixed(2)}.  Difference of ${(expectedGreater - expectedLower).abs().toStringAsFixed(2)}');
    } else {
      _addLogMessage(
          '❌ Test failed $message: expected ${expectedGreater.toStringAsFixed(2)} > ${expectedLower.toStringAsFixed(2)}.  Difference of ${(expectedGreater - expectedLower).abs().toStringAsFixed(2)}');
    }
  }

  void assertAlmostEqual(String message, num expected, num actual, num delta) {
    if ((expected - actual).abs() <= delta) {
      _addLogMessage(
          '✅ Test passed $message: expected ${expected.toStringAsFixed(2)}, got ${actual.toStringAsFixed(2)}. Actual difference: ${(expected - actual).abs().toStringAsFixed(2)}, max expected delta: ${delta.toStringAsFixed(2)}');
    } else {
      _addLogMessage(
          '❌ Test failed $message: expected ${expected.toStringAsFixed(2)}, got ${actual.toStringAsFixed(2)}.  Actual difference: ${(expected - actual).abs().toStringAsFixed(2)}, max expected delta: ${delta.toStringAsFixed(2)}');
    }
  }

  void _addLogMessage(String message) {
    print(message);
    setState(() {
      _logMessages.add(message);
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (_scrollController.hasClients) {
          _scrollController.animateTo(
            _scrollController.position.maxScrollExtent,
            duration: const Duration(milliseconds: 300),
            curve: Curves.easeOut,
          );
        }
      });
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    frame.disconnect();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Column(
          children: [
            Center(
              child: Text('Running on: $_platformVersion\n'),
            ),
            Expanded(
              child: SingleChildScrollView(
                controller: _scrollController,
                child: Column(
                  children:
                      _logMessages.map((message) => Text(message)).toList(),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}