512 lines
25 KiB
Python
512 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""Run actual Cardputer controls, drawing and shared display policy on the host."""
|
|
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_cardputer_ui import function
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
VARIANT = ROOT / "variants/m5stack_cardputer_adv"
|
|
|
|
|
|
class CardputerLifecycleTests(unittest.TestCase):
|
|
def test_actual_controls_notifications_power_and_night_drawing(self):
|
|
task = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text(encoding="ascii")
|
|
display = (VARIANT / "hw/CardputerDisplay.cpp").read_text(encoding="ascii")
|
|
abstract = (ROOT / "examples/companion_radio/AbstractUITask.h").read_text(encoding="ascii")
|
|
code = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
#include "helpers/ui/ColorTheme.h"
|
|
#include "helpers/CompanionNotificationPolicy.h"
|
|
#include "helpers/ui/BluetoothPairingUiPolicy.h"
|
|
uint32_t fake_now=0;
|
|
unsigned long millis() { return fake_now; }
|
|
bool night=false;
|
|
namespace mesh { namespace ui { bool displayNightActive() { return night; } } }
|
|
constexpr uint16_t TFT_BLACK=0,TFT_WHITE=0xFFFF,TFT_DARKGRAY=0x7BEF;
|
|
struct LCDMock {
|
|
std::vector<uint16_t> colors;
|
|
std::vector<std::string> text;
|
|
uint16_t current=0;
|
|
unsigned brightness=0,text_level=0,prints=0;
|
|
void setBaseColor(uint16_t c) { colors.push_back(c); }
|
|
bool begin() { return true; }
|
|
void setRotation(int) {}
|
|
void setColorDepth(int) {}
|
|
void setBrightness(uint8_t b) { brightness=b; }
|
|
void setTextColor(uint16_t c) { colors.push_back(c); }
|
|
void setTextColor(uint16_t c,uint16_t b) { colors.push_back(c); colors.push_back(b); }
|
|
template<class F> void setEmojiCallback(F) {}
|
|
void setColor(uint16_t c) { current=c; colors.push_back(c); }
|
|
void clear(uint16_t c) { colors.push_back(c); }
|
|
void startWrite() {}
|
|
void endWrite() {}
|
|
void wakeup() {}
|
|
void sleep() {}
|
|
int textWidth(const char* s) { return strlen(s)*8; }
|
|
int fontHeight() { return 16; }
|
|
void drawRect(int,int,int,int) { colors.push_back(current); }
|
|
void drawRect(int,int,int,int,uint16_t c) { colors.push_back(c); }
|
|
void fillRect(int,int,int,int) { colors.push_back(current); }
|
|
void drawBitmap(int,int,const uint8_t*,int,int,uint16_t c) { colors.push_back(c); }
|
|
void setCursor(int,int) {}
|
|
void print(const char* value) { ++prints; colors.push_back(current); text.emplace_back(value); }
|
|
};
|
|
namespace lgfx { using LGFXBase=LCDMock; }
|
|
struct CardputerDisplay: DisplayDriver {
|
|
enum { TEXT_LEVEL_COUNT=3 };
|
|
enum PaletteColor:uint16_t { P_BLACK=0,P_WHITE=0xFFFF,P_RED=0xF800,P_GREEN=0x07E0,
|
|
P_BLUE=0x001F,P_ORANGE=0xFD20,P_YELLOW=0xFFE0,P_GRAY=0x8410 };
|
|
bool _isOn=false;
|
|
uint16_t _lastColor=0;
|
|
uint8_t _textLevel=1;
|
|
LCDMock LCD;
|
|
CardputerDisplay():DisplayDriver(240,135) {}
|
|
bool begin();
|
|
bool isOn() override { return _isOn; }
|
|
void turnOn() override;
|
|
void turnOff() override;
|
|
void clear() override;
|
|
void startFrame(ColorVal bkg=UIColor::window_bkg) override;
|
|
void endFrame() override;
|
|
void setTextSize(int) override {}
|
|
void setColor(ColorVal) override;
|
|
void setCursor(int,int) override;
|
|
void print(const char*) override;
|
|
void fillRect(int,int,int,int) override;
|
|
void drawRect(int,int,int,int) override;
|
|
void drawXbm(int,int,const uint8_t*,int,int) override;
|
|
uint16_t getTextWidth(const char*) override;
|
|
void setTextLevel(uint8_t level) { _textLevel=level; LCD.text_level=level; }
|
|
uint8_t getTextLevel() const { return _textLevel; }
|
|
void setBrightness(uint8_t level) { LCD.setBrightness(level); }
|
|
};
|
|
'''
|
|
color_start = display.index("ColorVal UIColor::window_bkg")
|
|
color_end = display.index("\n\nint32_t emoji_draw_callback", color_start)
|
|
code += display[color_start:color_end] + "\n"
|
|
code += function(display, "int32_t emoji_draw_callback(") + "\n"
|
|
for signature in (
|
|
"bool CardputerDisplay::begin(", "void CardputerDisplay::turnOn(",
|
|
"void CardputerDisplay::turnOff(", "void CardputerDisplay::clear(",
|
|
"void CardputerDisplay::startFrame(", "void CardputerDisplay::endFrame(",
|
|
"void CardputerDisplay::setColor(", "void CardputerDisplay::setCursor(",
|
|
"void CardputerDisplay::print(", "void CardputerDisplay::fillRect(",
|
|
"void CardputerDisplay::drawRect(", "void CardputerDisplay::drawXbm(",
|
|
"uint16_t CardputerDisplay::getTextWidth(",
|
|
):
|
|
code += function(display, signature) + "\n"
|
|
code += r'''
|
|
struct Speaker {
|
|
unsigned tones=0,sleeps=0;
|
|
void queueTone(int,int,float=0.5f) { ++tones; }
|
|
bool isBusy() const { return false; }
|
|
void sleep() { ++sleeps; }
|
|
};
|
|
struct Board {
|
|
bool usb=false;
|
|
Speaker speaker;
|
|
bool isExternalPowered() { return usb; }
|
|
bool isUsbHostConnected() { return usb; }
|
|
Speaker* getSpeaker() { return &speaker; }
|
|
};
|
|
struct Interfaces {
|
|
bool pending=false,enabled=true,ble=false;
|
|
bool takePairingRequest() { bool result=pending; pending=false; return result; }
|
|
};
|
|
namespace Keyboard {
|
|
enum Key { KEY_ESC,KEY_RETURN,KEY_A };
|
|
struct Event { Key key; };
|
|
}
|
|
struct Prefs { uint8_t gps_enabled=1,buzzer_quiet=0,powersaving_enabled=0; };
|
|
struct CustomPrefs { uint8_t text_level=1,brightness=96; };
|
|
struct NotificationSink: mesh::notify::Sink {
|
|
CardputerDisplay* display;
|
|
explicit NotificationSink(CardputerDisplay* d):display(d) {}
|
|
uint8_t capabilities() const override { return mesh::notify::Screen; }
|
|
bool gpioAvailable(uint8_t) const override { return false; }
|
|
void pulse(mesh::notify::Output,bool,int8_t) override {}
|
|
void melody(const char*) override {}
|
|
void screen(int8_t mode) override { display->notificationPower(mode); }
|
|
};
|
|
struct NotificationStore: mesh::notify::Store {
|
|
bool save(const mesh::notify::Settings&) override { return true; }
|
|
};
|
|
struct Mesh {
|
|
mesh::notify::Controller* notifications=nullptr;
|
|
unsigned button_calls=0;
|
|
bool notificationButton() { ++button_calls; return notifications && notifications->button(); }
|
|
Prefs* prefs=nullptr;
|
|
CustomPrefs* custom=nullptr;
|
|
CustomPrefs committed;
|
|
bool fail=false;
|
|
unsigned saves=0;
|
|
uint32_t getBLEPin() const { return 246810; }
|
|
bool savePrefs() { if(fail) return false; ++saves; return true; }
|
|
bool saveCustomPrefs() {
|
|
if(fail) { *custom=committed; return false; }
|
|
committed=*custom; ++saves; return true;
|
|
}
|
|
bool setGpsEnabled(bool on) {
|
|
if(fail) return false;
|
|
prefs->gps_enabled=on; ++saves; return true;
|
|
}
|
|
bool handleLocalCommand(const char* command,char* reply) {
|
|
if(fail) { strcpy(reply,"Error: save failed"); return true; }
|
|
assert(!strcmp(command,"powersaving on") || !strcmp(command,"powersaving off"));
|
|
prefs->powersaving_enabled=!strcmp(command,"powersaving on"); ++saves;
|
|
strcpy(reply,"OK - powersaving"); return true;
|
|
}
|
|
} the_mesh_cp;
|
|
struct CardputerScreen {};
|
|
struct MainScreen: CardputerScreen {
|
|
enum { FIRST,CHAT };
|
|
int page=FIRST;
|
|
int getCurrentPage() { return page; }
|
|
};
|
|
struct NewMessageScreen: CardputerScreen {
|
|
unsigned messages=0;
|
|
void newMessage(const char*,const char*) { ++messages; }
|
|
};
|
|
enum class UIEventType { none,channelMessage,contactMessage,ack };
|
|
enum class SoundType { Keyboard,NewMessage,DiscoveryResult,MessageAck };
|
|
struct CardputerUITask {
|
|
CardputerDisplay* _display=nullptr;
|
|
Board* _board=nullptr;
|
|
Interfaces* _interfaceManager=nullptr;
|
|
Prefs* _node_prefs=nullptr;
|
|
CustomPrefs* _custom_prefs=nullptr;
|
|
CardputerScreen *main_screen=nullptr,*new_message_screen=nullptr,*current_screen=nullptr;
|
|
bool connected=false,_message_notification_override=false,_message_notification_screen_override=false;
|
|
uint8_t _notification_outputs=31;
|
|
int unsynced_msg_count=0;
|
|
char alert_text[80]={};
|
|
unsigned long alert_expiry=0,next_refresh=0;
|
|
uint32_t pairing_prompt_until=0;
|
|
bool hasConnection() const { return connected || _interfaceManager->ble; }
|
|
bool hasBluetoothConnection() const { return _interfaceManager->ble; }
|
|
bool isBluetoothEnabled() const { return _interfaceManager->enabled; }
|
|
void scheduleRefresh(unsigned ms=0) { next_refresh=millis()+ms; }
|
|
void setCurrScreen(CardputerScreen* screen) { current_screen=screen; scheduleRefresh(); }
|
|
bool shouldWakeDisplayForMessage() const;
|
|
bool isAlertActive() const;
|
|
bool isChatVisible() const;
|
|
bool handleNotificationInput();
|
|
void showAlert(const char*,int);
|
|
void serviceDisplayPower();
|
|
void servicePairingState();
|
|
bool isPairingPromptActive() const;
|
|
void renderPairingPrompt();
|
|
bool handlePairingInput(const Keyboard::Event&);
|
|
bool turnDisplayOn();
|
|
bool getGPSState();
|
|
bool toggleGPS();
|
|
bool toggleBuzzer();
|
|
bool togglePowerSave();
|
|
void cycleBrightness();
|
|
void cycleTextSize();
|
|
void playSound(SoundType);
|
|
void notify(UIEventType);
|
|
uint8_t notificationCapabilities() const;
|
|
void notificationScreen(int8_t);
|
|
void newMsg(uint8_t,const char*,const char*,int,int=-1,const char*=nullptr,int=-1);
|
|
};
|
|
'''.replace("const char*=", "const char* =")
|
|
code += "bool CardputerUITask::" + function(abstract, "bool shouldWakeDisplayForMessage() const").removeprefix("bool ") + "\n"
|
|
for signature in (
|
|
"void CardputerUITask::showAlert(", "void CardputerUITask::serviceDisplayPower(",
|
|
"bool CardputerUITask::isAlertActive(", "bool CardputerUITask::isChatVisible(",
|
|
"bool CardputerUITask::handleNotificationInput(",
|
|
"void CardputerUITask::servicePairingState(", "bool CardputerUITask::isPairingPromptActive(",
|
|
"void CardputerUITask::renderPairingPrompt(", "bool CardputerUITask::handlePairingInput(",
|
|
"bool CardputerUITask::turnDisplayOn(", "bool CardputerUITask::getGPSState(",
|
|
"bool CardputerUITask::toggleGPS(", "bool CardputerUITask::toggleBuzzer(",
|
|
"bool CardputerUITask::togglePowerSave(", "void CardputerUITask::cycleBrightness(",
|
|
"void CardputerUITask::cycleTextSize(", "void CardputerUITask::playSound(",
|
|
"void CardputerUITask::notify(", "uint8_t CardputerUITask::notificationCapabilities(",
|
|
"void CardputerUITask::notificationScreen(", "void CardputerUITask::newMsg(",
|
|
):
|
|
code += function(task, signature) + "\n"
|
|
code += r'''
|
|
int main() {
|
|
CardputerDisplay display;
|
|
// Every drawing boundary, including implicit glyph and emoji colors, follows night mode.
|
|
night=true; assert(display.begin());
|
|
display.startFrame(0x1234);
|
|
const uint8_t bitmap[]={0xFF};
|
|
for(uint16_t color:{uint16_t(0),uint16_t(0xFFFF),uint16_t(0x07E0),uint16_t(0x1234)}) {
|
|
display.setColor(color); display.print("text"); display.fillRect(0,0,1,1);
|
|
display.drawRect(0,0,1,1); display.drawXbm(0,0,bitmap,1,1);
|
|
}
|
|
emoji_draw_callback(&display.LCD,0,0,0,16); display.clear(); display.endFrame();
|
|
for(auto color:display.LCD.colors) assert(color==0 || color==0xF800);
|
|
night=false; display.setColor(CardputerDisplay::P_GREEN);
|
|
assert(display._lastColor==CardputerDisplay::P_GREEN);
|
|
display.setColor(CardputerDisplay::P_WHITE); assert(display._lastColor==0xFFFF);
|
|
|
|
Board board; Interfaces interfaces; Prefs prefs; CustomPrefs custom;
|
|
MainScreen main; NewMessageScreen messages;
|
|
CardputerUITask ui;
|
|
ui._display=&display; ui._board=&board; ui._interfaceManager=&interfaces;
|
|
ui._node_prefs=&prefs; ui._custom_prefs=&custom;
|
|
ui.main_screen=&main; ui.current_screen=&main; ui.new_message_screen=&messages;
|
|
the_mesh_cp.prefs=&prefs; the_mesh_cp.custom=&custom;
|
|
auto& power=mesh::ui::displayPowerPrefs();
|
|
power.battery.mode=mesh::ui::DisplayMode::Automatic; power.battery.seconds=27;
|
|
power.usb.mode=mesh::ui::DisplayMode::Off;
|
|
ui.serviceDisplayPower(); assert(display.isOn());
|
|
fake_now=5000; ui.serviceDisplayPower(); assert(!display.isOn());
|
|
ui.newMsg(0,"peer","message",1); assert(display.isOn() && messages.messages==1);
|
|
fake_now=31999; ui.serviceDisplayPower(); assert(display.isOn());
|
|
fake_now=32000; ui.serviceDisplayPower(); assert(!display.isOn());
|
|
// The first waking key is consumed; subsequent input extends the saved timeout.
|
|
assert(ui.turnDisplayOn()); assert(display.isOn()); assert(!ui.turnDisplayOn());
|
|
fake_now=59000; ui.serviceDisplayPower(); assert(!display.isOn());
|
|
ui.connected=true; ui.current_screen=&main;
|
|
ui.newMsg(0,"peer","online",2); assert(!display.isOn() && messages.messages==1 && ui.current_screen==&main);
|
|
ui.connected=false;
|
|
ui._notification_outputs=mesh::notify::Sound;
|
|
ui.newMsg(0,"peer","screen muted",3); assert(!display.isOn() && messages.messages==1);
|
|
ui._notification_outputs=31; ui._message_notification_screen_override=true;
|
|
ui.newMsg(0,"peer","managed screen",4); assert(!display.isOn() && messages.messages==1);
|
|
ui._message_notification_screen_override=false;
|
|
power.battery.mode=mesh::ui::DisplayMode::Button;
|
|
ui.serviceDisplayPower(); ui.newMsg(0,"peer","button mode",5); assert(!display.isOn());
|
|
assert(ui.turnDisplayOn() && display.isOn());
|
|
board.usb=true; ui.serviceDisplayPower(); assert(!display.isOn());
|
|
ui.newMsg(0,"peer","USB off",6); assert(!display.isOn());
|
|
ui.notificationScreen(1); assert(display.isOn());
|
|
ui.notificationScreen(-1); ui.serviceDisplayPower(); assert(!display.isOn());
|
|
assert(ui.notificationCapabilities()==mesh::notify::Screen);
|
|
unsigned tones=board.speaker.tones;
|
|
ui._notification_outputs=0; ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones);
|
|
ui._notification_outputs=mesh::notify::Sound; ui._message_notification_override=true;
|
|
ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones);
|
|
ui._message_notification_override=false; prefs.buzzer_quiet=1;
|
|
ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones);
|
|
prefs.buzzer_quiet=0; ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones+1);
|
|
|
|
the_mesh_cp.fail=true;
|
|
assert(!ui.toggleGPS() && prefs.gps_enabled==1);
|
|
assert(!ui.toggleBuzzer() && prefs.buzzer_quiet==0);
|
|
assert(!ui.togglePowerSave() && prefs.powersaving_enabled==0);
|
|
display.setTextLevel(1); display.setBrightness(96);
|
|
ui.cycleTextSize(); assert(custom.text_level==1 && display.getTextLevel()==1);
|
|
ui.cycleBrightness(); assert(custom.brightness==96 && display.LCD.brightness==96);
|
|
the_mesh_cp.fail=false;
|
|
assert(ui.toggleGPS() && prefs.gps_enabled==0);
|
|
assert(ui.toggleBuzzer() && prefs.buzzer_quiet==1);
|
|
assert(ui.togglePowerSave() && prefs.powersaving_enabled==1);
|
|
ui.cycleTextSize(); assert(custom.text_level==2 && display.getTextLevel()==2);
|
|
ui.cycleBrightness(); assert(custom.brightness==160 && display.LCD.brightness==160);
|
|
// Real pairing events wake pairing-only policy, without replacing the chat/draft owner.
|
|
power.usb.mode=mesh::ui::DisplayMode::Pairing;
|
|
ui.current_screen=&main;
|
|
ui.servicePairingState(); assert(!ui.isPairingPromptActive() && !display.isOn());
|
|
interfaces.pending=true; ui.servicePairingState();
|
|
assert(!interfaces.pending && ui.isPairingPromptActive() && display.isOn());
|
|
assert(ui.current_screen==&main && ui.pairing_prompt_until==fake_now+120000);
|
|
display.LCD.text.clear(); ui.renderPairingPrompt();
|
|
unsigned pin_count=0;
|
|
for(const auto& text:display.LCD.text) if(text=="246810") ++pin_count;
|
|
assert(pin_count==1);
|
|
Keyboard::Event enter{Keyboard::KEY_RETURN};
|
|
assert(ui.handlePairingInput(enter) && ui.current_screen==&main && ui.isPairingPromptActive());
|
|
ui.newMsg(0,"peer","during pairing",8); assert(ui.current_screen==&main);
|
|
fake_now=ui.pairing_prompt_until-1; ui.servicePairingState(); assert(ui.isPairingPromptActive());
|
|
++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive() && !display.isOn());
|
|
assert(ui.current_screen==&main);
|
|
|
|
// A saved Off profile stays dark, even when the phone requests a PIN.
|
|
power.usb.mode=mesh::ui::DisplayMode::Off;
|
|
interfaces.pending=true; ui.servicePairingState();
|
|
assert(ui.isPairingPromptActive() && !display.isOn());
|
|
// BLE completion, unlike an unrelated USB client, ends the prompt immediately.
|
|
interfaces.ble=true; ui.servicePairingState();
|
|
assert(!ui.isPairingPromptActive() && ui.pairing_prompt_until==0);
|
|
interfaces.ble=false; power.usb.mode=mesh::ui::DisplayMode::ButtonPairing;
|
|
interfaces.pending=true; ui.servicePairingState(); assert(display.isOn());
|
|
interfaces.enabled=false; ui.servicePairingState();
|
|
assert(!ui.isPairingPromptActive() && !display.isOn());
|
|
interfaces.enabled=true;
|
|
// ESC dismisses the overlay and wakes the retained screen under button-pairing policy.
|
|
interfaces.pending=true; ui.servicePairingState();
|
|
Keyboard::Event escape{Keyboard::KEY_ESC};
|
|
assert(ui.handlePairingInput(escape) && !ui.isPairingPromptActive());
|
|
assert(ui.current_screen==&main && display.isOn());
|
|
assert(!ui.handlePairingInput(enter));
|
|
// Expiry remains bounded across the 32-bit millis rollover.
|
|
fake_now=0xFFFF0000U; interfaces.pending=true; ui.servicePairingState();
|
|
assert(ui.isPairingPromptActive());
|
|
fake_now+=119999U; ui.servicePairingState(); assert(ui.isPairingPromptActive());
|
|
++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive());
|
|
// Visibility means the matching conversation is actually readable, including overlays.
|
|
ui.current_screen=&main; main.page=MainScreen::CHAT; ui.alert_expiry=0;
|
|
board.usb=false; power.battery.mode=mesh::ui::DisplayMode::On; ui.serviceDisplayPower();
|
|
assert(ui.isChatVisible());
|
|
ui.showAlert("overlay",1000); assert(!ui.isChatVisible());
|
|
ui.alert_expiry=0; ui.current_screen=&messages; assert(!ui.isChatVisible());
|
|
ui.current_screen=&main; display.turnOff(); assert(!ui.isChatVisible());
|
|
display.turnOn(); main.page=MainScreen::FIRST; assert(!ui.isChatVisible());
|
|
main.page=MainScreen::CHAT;
|
|
interfaces.pending=true; ui.servicePairingState(); assert(!ui.isChatVisible());
|
|
ui.handlePairingInput(escape); assert(ui.isChatVisible());
|
|
// Actual shared controller stops infinite screen overrides on a physical-input action.
|
|
NotificationSink sink(&display); NotificationStore store;
|
|
mesh::notify::Controller notifications(sink,store); the_mesh_cp.notifications=¬ifications;
|
|
auto& rules=notifications.settings(); rules.enabled=1;
|
|
auto& rule=rules.rules[0]; rule.used=1; rule.fields=mesh::notify::FScreen;
|
|
rule.screen=1; rule.repeat=0; rule.stop=mesh::notify::Button;
|
|
assert(notifications.message(nullptr,nullptr,false,fake_now));
|
|
assert(notifications.active());
|
|
unsigned button_calls=the_mesh_cp.button_calls;
|
|
assert(ui.handleNotificationInput());
|
|
assert(!notifications.active() && the_mesh_cp.button_calls==button_calls+1);
|
|
assert(!ui.handleNotificationInput());
|
|
// Dismissing a notification during a pairing prompt leaves that modal prompt intact.
|
|
assert(notifications.message(nullptr,nullptr,false,fake_now));
|
|
interfaces.pending=true; ui.servicePairingState();
|
|
assert(ui.handleNotificationInput() && ui.isPairingPromptActive());
|
|
ui.handlePairingInput(escape);
|
|
// Failed display initialization leaves every display control and notification safe.
|
|
ui._display=nullptr;
|
|
auto saved=the_mesh_cp.saves;
|
|
ui.cycleTextSize(); ui.cycleBrightness(); ui.serviceDisplayPower(); ui.notificationScreen(1);
|
|
assert(!ui.turnDisplayOn() && ui.notificationCapabilities()==0 && the_mesh_cp.saves==saved);
|
|
interfaces.pending=true; ui.servicePairingState();
|
|
assert(!ui.isPairingPromptActive() && !interfaces.pending);
|
|
ui.newMsg(0,"peer","headless",7);
|
|
std::puts("PASS: Cardputer live controls, notifications, shared power and night drawing");
|
|
}
|
|
'''
|
|
self.assertIn("if (handleNotificationInput())", task)
|
|
self.assertIn("else if (!power_off_input && !handlePairingInput(event))", task)
|
|
self.assertIn("if (!handleNotificationInput() && !isPairingPromptActive()", task)
|
|
self.assertIn("tools->handlePowerOffInput(event)", task)
|
|
self.assertIn("current_screen != tools_screen) tools->poll()", task)
|
|
self.assertIn("renderPairingPrompt();\n _display->endFrame();", task)
|
|
with tempfile.TemporaryDirectory(prefix="cardputer-lifecycle-") as tmp:
|
|
work = Path(tmp)
|
|
(work / "Arduino.h").write_text("#pragma once\nunsigned long millis();\n", encoding="ascii")
|
|
source, binary = work / "test.cpp", work / "test"
|
|
source.write_text(code, encoding="ascii")
|
|
compiled = subprocess.run([
|
|
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
|
"-Wno-unused-parameter", "-Wno-misleading-indentation",
|
|
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie",
|
|
"-DCARDPUTER_ADV_UI=1", "-I", str(work), "-I", str(ROOT / "src"),
|
|
str(source), str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), "-o", str(binary),
|
|
], capture_output=True, text=True)
|
|
self.assertEqual(compiled.returncode, 0, compiled.stderr)
|
|
executed = subprocess.run([str(binary)], capture_output=True, text=True)
|
|
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
|
|
|
|
def test_actual_audio_loop_cleanup_waits_for_drain_and_keeps_visible_codec_warm(self):
|
|
task = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text(encoding="ascii")
|
|
speaker = (VARIANT / "hw/CardputerSpeaker.h").read_text(encoding="ascii")
|
|
audio_prefix = function(task, "void CardputerUITask::loop(").split(" CardputerKeyboard *kb", 1)[0]
|
|
code = r'''
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
struct Speaker {
|
|
bool tone_pending=false,draining=false,finish_drain=false,initialized=true;
|
|
unsigned polls=0,sleeps=0,shutdowns=0;
|
|
void processQueue() {
|
|
++polls;
|
|
if(finish_drain) { draining=false; finish_drain=false; }
|
|
}
|
|
void sleep() {
|
|
++sleeps;
|
|
if(initialized) { initialized=false; ++shutdowns; }
|
|
tone_pending=draining=false;
|
|
}
|
|
'''
|
|
code += function(speaker, "bool isBusy() const") + r'''
|
|
};
|
|
struct Board {
|
|
Speaker speaker;
|
|
Speaker* getSpeaker() { return &speaker; }
|
|
bool isExternalPowered() const { return false; }
|
|
bool isUsbHostConnected() const { return false; }
|
|
};
|
|
struct Display {
|
|
bool on=false,desired=false;
|
|
bool isOn() const { return on; }
|
|
bool servicePower(bool,bool,bool) { bool changed=on!=desired; on=desired; return changed; }
|
|
};
|
|
struct CardputerUITask {
|
|
Board* _board;
|
|
Display* _display;
|
|
unsigned refreshes=0;
|
|
bool hasConnection() const { return false; }
|
|
bool isPairingPromptActive() const { return false; }
|
|
void scheduleRefresh() { ++refreshes; }
|
|
void serviceDisplayPower();
|
|
void servicePairingState() { serviceDisplayPower(); }
|
|
void loop();
|
|
};
|
|
'''
|
|
code += function(task, "void CardputerUITask::serviceDisplayPower(") + "\n"
|
|
code += audio_prefix + "}\n"
|
|
code += r'''
|
|
int main() {
|
|
Board board; Display display;
|
|
CardputerUITask ui{&board,&display};
|
|
auto& audio=board.speaker;
|
|
// A sound-only notification plays while the screen is already dark.
|
|
audio.tone_pending=true;
|
|
ui.loop(); assert(audio.polls==1 && audio.sleeps==0 && audio.initialized); // warmup
|
|
ui.loop(); assert(audio.polls==2 && audio.sleeps==0 && audio.initialized); // transmitting
|
|
audio.tone_pending=false; audio.draining=true;
|
|
ui.loop(); assert(audio.polls==3 && audio.sleeps==0 && audio.initialized); // DMA still draining
|
|
audio.finish_drain=true;
|
|
ui.loop(); assert(audio.polls==4 && audio.sleeps==1 && !audio.initialized && audio.shutdowns==1);
|
|
ui.loop(); assert(audio.shutdowns==1); // repeated idle cleanup performs no further hardware shutdown
|
|
|
|
// A display timeout must not cancel audio before the post-processQueue idle check.
|
|
display.on=true; display.desired=false;
|
|
audio.initialized=true; audio.tone_pending=true;
|
|
unsigned sleeps=audio.sleeps;
|
|
ui.loop(); assert(!display.on && audio.tone_pending && audio.sleeps==sleeps && audio.initialized);
|
|
audio.tone_pending=false; audio.draining=true;
|
|
ui.loop(); assert(audio.draining && audio.sleeps==sleeps);
|
|
audio.finish_drain=true;
|
|
ui.loop(); assert(!audio.initialized && audio.sleeps==sleeps+1);
|
|
|
|
// Headless sound uses the same complete-playback-then-cleanup lifecycle.
|
|
ui._display=nullptr; audio.initialized=true; audio.tone_pending=true;
|
|
sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.tone_pending);
|
|
audio.tone_pending=false; audio.draining=true;
|
|
ui.loop(); assert(audio.sleeps==sleeps && audio.draining);
|
|
audio.finish_drain=true; ui.loop(); assert(audio.sleeps==sleeps+1 && !audio.initialized);
|
|
|
|
// Visible idle operation retains the warmed codec for keyboard tones.
|
|
ui._display=&display; display.on=display.desired=true; audio.initialized=true;
|
|
sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.initialized);
|
|
std::puts("PASS: actual UI audio loop waits for DMA drain, powers down dark/headless idle, and stays warm while visible");
|
|
}
|
|
'''
|
|
with tempfile.TemporaryDirectory(prefix="cardputer-audio-lifecycle-") as tmp:
|
|
source, binary = Path(tmp) / "test.cpp", Path(tmp) / "test"
|
|
source.write_text(code, encoding="ascii")
|
|
compiled = subprocess.run([
|
|
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
|
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie",
|
|
str(source), "-o", str(binary),
|
|
], capture_output=True, text=True)
|
|
self.assertEqual(compiled.returncode, 0, compiled.stderr)
|
|
executed = subprocess.run([str(binary)], capture_output=True, text=True)
|
|
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|