Skip to content
Merged
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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,7 @@ set(GUI_SOURCES
src/gui/HealthApplet.cpp
src/gui/PersistentDialog.cpp
src/gui/CanonWindow.cpp
src/gui/CanonMessageBox.cpp
src/gui/CanonSurface.cpp
src/gui/PanadapterHeader.cpp
src/gui/FramelessMessageBox.cpp
Expand Down
7 changes: 7 additions & 0 deletions docs/ctr2-usb-relay-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,13 @@ maker's firmware and its settings survive every update. SWITCH asks the
device to restart into that firmware; how a device selects its image is its
own business.

A device answers SWITCH and BOOTLOADER with STATUS before it leaves the bus.
AetherSDR also counts the request done if the device leaves while the answer
is outstanding, once the port has written the request, so a restart that beats
its answer, or an answer naming the wrong op, is not reported as a lost
device. A port that fails before writing it (a write error) still reports the
device lost.

**Host implementation.** `Ctr2FirmwareUpdater` runs the session and checks
the image before sending anything: an ESP32-S3 application image with an
application description (`readImage`), not a merged flash image or a
Expand Down
7 changes: 7 additions & 0 deletions docs/style/dialog-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ has moved to it (Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem
installs `FramelessResizer`, since `CanonWindow` itself only moves. Other tool
and workspace dialogs keep the persistent pattern above.

A question or notice from a canon window uses `CanonMessageBox`
(`src/gui/CanonMessageBox.{h,cpp}`) in place of `QMessageBox`: a canon dialog
with the title as a canon header, the text, optional further text, and the
buttons at the bottom right. `clickedButton()` names the button clicked and is
null when the box is closed any other way; the default button is outlined in
cyan and a destructive one is drawn in gold. The CTR2 window's questions use it.

## Common pitfalls

These are the issues that have hit real PRs in the project's history.
Expand Down
4 changes: 3 additions & 1 deletion docs/user/docs/ctr2-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,9 @@ date, including any saved with **Back Up…** in earlier versions of AetherSDR
it. Installing one restores it: the whole flash goes back exactly as it
was, both firmwares, the settings and the registration included, and anything
since is lost. A backup restores only onto the unit it came from (matched by
its MAC address), and only if the file is unchanged since it was made.
its MAC address), and only if the file is unchanged since it was made. The
question before a restore has **Cancel** as its default, so pressing Return
keeps what is on the unit; restoring takes a click on **Restore**.

### Cancelling

Expand Down
49 changes: 39 additions & 10 deletions src/core/Ctr2FirmwareUpdater.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,11 @@ bool Ctr2FirmwareUpdater::openPort(QString* error)
port->setParent(nullptr);
m_reassembler.reset();
m_reassembler.setExtensions(capabilityBit(MessageType::Firmware));
m_reportsQueued = 0;
m_reportsWritten = 0;
m_restartQueuedTo = 0;
connect(port, &Ctr2HidPort::extensionsNegotiated, this, &Ctr2FirmwareUpdater::onExtensions);
connect(port, &Ctr2HidPort::reportsSent, this, [this](int count) { m_reportsWritten += count; });
connect(port, &Ctr2HidPort::reportsReceived, this, &Ctr2FirmwareUpdater::onReports);
connect(port, &Ctr2HidPort::failed, this, &Ctr2FirmwareUpdater::onPortFailed);
return true;
Expand Down Expand Up @@ -270,15 +274,19 @@ void Ctr2FirmwareUpdater::sendHello()
std::vector<Report> out;
m_encoder.reset();
m_encoder.encodeControl(MessageType::Hello, &out);
m_reportsQueued += qint64(out.size());
m_port->send(out);
}

void Ctr2FirmwareUpdater::sendExtension(const QByteArray& payload)
bool Ctr2FirmwareUpdater::sendExtension(const QByteArray& payload)
{
std::vector<Report> out;
if (m_port && m_encoder.encodeExtension(MessageType::Firmware, payload, &out)) {
m_port->send(out);
if (!m_port || !m_encoder.encodeExtension(MessageType::Firmware, payload, &out)) {
return false;
}
m_reportsQueued += qint64(out.size());
m_port->send(out);
return true;
}

void Ctr2FirmwareUpdater::onReports(const QByteArray& reports)
Expand Down Expand Up @@ -316,7 +324,11 @@ void Ctr2FirmwareUpdater::onMessage(const Message& message)
const bool loader = *m_restart == Restart::Bootloader;
setPhase(Phase::Commanding, loader ? tr("Restarting into download mode")
: tr("Restarting into the other firmware"));
sendExtension(loader ? firmware::encodeBootloader() : firmware::encodeSwitch());
if (!sendExtension(loader ? firmware::encodeBootloader() : firmware::encodeSwitch())) {
fail(tr("The restart request could not be sent."));
return;
}
m_restartQueuedTo = m_reportsQueued;
armTimeout(m_timing.answerTimeoutMs);
} else if (m_phase == Phase::Calling) {
m_retry->stop();
Expand Down Expand Up @@ -358,13 +370,8 @@ void Ctr2FirmwareUpdater::onStatus(const firmware::Reply& reply)
: tr("The device has no other firmware to start."));
return;
}
m_timeout->stop();
closePort(); // it is leaving the bus
m_phase = Phase::Idle;
m_restart.reset();
emit finished(true, loader ? tr("The device is restarting into download mode.")
: tr("The device is restarting into its other firmware."),
QString());
restarted();
return;
}
if (reply.status != firmware::Status::Ok) {
Expand Down Expand Up @@ -462,12 +469,34 @@ void Ctr2FirmwareUpdater::onPortFailed(const QString& message)
fail(tr("The device left during the final check (%1). Reconnect it: it runs "
"either the new firmware or the old one.").arg(message));
break;
case Phase::Commanding:
// Leaving the bus is what the request asks for. A device that does it
// after the request was written, before its STATUS arrives or with a
// STATUS naming another op, has still restarted. A port that failed
// before writing it (a write error) has not.
if (m_reportsWritten >= m_restartQueuedTo) {
restarted();
break;
}
fail(tr("Lost the device: %1.").arg(message) + kKeepsFirmware);
break;
default:
fail(tr("Lost the device: %1.").arg(message) + kKeepsFirmware);
break;
}
}

void Ctr2FirmwareUpdater::restarted()
{
const bool loader = m_restart && *m_restart == Restart::Bootloader;
m_timeout->stop();
m_phase = Phase::Idle;
m_restart.reset();
emit finished(true, loader ? tr("The device is restarting into download mode.")
: tr("The device is restarting into its other firmware."),
QString());
}

void Ctr2FirmwareUpdater::onTimeout()
{
switch (m_phase) {
Expand Down
11 changes: 10 additions & 1 deletion src/core/Ctr2FirmwareUpdater.h
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,14 @@ class Ctr2FirmwareUpdater : public QObject {
void onPortFailed(const QString& message);
void onTimeout();
void sendHello();
void sendExtension(const QByteArray& payload);
// False when nothing could be queued (no port, or the payload would not encode).
bool sendExtension(const QByteArray& payload);
void sendChunks();
void lookForDevice();
void fail(const QString& message);
void succeed(const QString& version);
// A restart request done: the device is leaving the bus for its target.
void restarted();
void armTimeout(int ms);

DeviceSource m_devices;
Expand All @@ -130,6 +133,12 @@ class Ctr2FirmwareUpdater : public QObject {
qint64 m_sent{0}; // image bytes sent
qint64 m_stored{0}; // image bytes the device has acknowledged
int m_inFlight{0};
// Reports this port has queued and written, and the queued count once the
// restart request was queued: it has reached the device's endpoint when
// the written count gets there.
qint64 m_reportsQueued{0};
qint64 m_reportsWritten{0};
qint64 m_restartQueuedTo{0};
QString m_previousVersion;
};

Expand Down
7 changes: 7 additions & 0 deletions src/core/Ctr2HidThreadPort.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ class Ctr2HidIoWorker : public QObject {
}
const int n = writeReports(m_outbox, 0);
if (n < 0) {
// What this tick did write reached the device: count it
// before the failure, which arrives after it.
if (sent > 0) {
QMetaObject::invokeMethod(m_port, [port = m_port, sent, generation] {
port->deliverSent(sent, generation);
}, Qt::QueuedConnection);
}
fail(QStringLiteral("CTR2 USB write failed: %1").arg(errorText()));
return;
}
Expand Down
131 changes: 131 additions & 0 deletions src/gui/CanonMessageBox.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#include "CanonMessageBox.h"

#include "CanonIndicators.h"
#include "ModemChrome.h"

#include <QAccessible>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>

namespace AetherSDR {

namespace {

// The texts wrap at the narrower width; one long unbreakable token (a path in
// a command to copy) widens the box up to the wider one rather than clipping.
constexpr int kTextWidth = 420;
constexpr int kMaxTextWidth = 720;

} // namespace

CanonMessageBox::CanonMessageBox(const QString& title, const QString& text, QWidget* parent)
: CanonWindow(title, parent)
{
setObjectName(QStringLiteral("canonMessageBox"));
applyCanonSheet(bodyWidget(), [] {
return ModemChrome::styleSheet(ModemChrome::Scale::Dialog, ModemChrome::Look::Canon)
+ QStringLiteral(
"QLabel#canonMessageText { color: {{color.canon.ink}}; }"
"QLabel#canonMessageInfo { color: {{color.canon.inkSoft}}; }"
"QPushButton { padding: 6px 18px; }"
"QPushButton:default { color: {{color.canon.cyan}}; border-color: {{color.canon.cyan}}; }"
"QPushButton[role=\"destructive\"] { color: {{color.canon.sparkGold}}; }");
});

auto* root = new QVBoxLayout(bodyWidget());
root->setContentsMargins(18, 14, 18, 16);
root->setSpacing(12);
// Texts carry manifest and device strings (a product name, a version, a
// path): never render them as rich text, the title included.
QWidget* header = makeCanonHeader(title);
if (auto* heading = header->findChild<QLabel*>(QStringLiteral("canonHeaderTitle")))
heading->setTextFormat(Qt::PlainText);
root->addWidget(header);

// A screen reader announces a QMessageBox's text when it opens; this
// dialog is told the same through its description.
setAccessibleDescription(text);
m_text = new QLabel(text);
m_text->setObjectName(QStringLiteral("canonMessageText"));
m_text->setTextFormat(Qt::PlainText);
m_text->setWordWrap(true);
m_text->setMinimumWidth(kTextWidth);
m_text->setMaximumWidth(kMaxTextWidth);
root->addWidget(m_text);

m_info = new QLabel;
m_info->setObjectName(QStringLiteral("canonMessageInfo"));
m_info->setTextFormat(Qt::PlainText);
m_info->setWordWrap(true);
m_info->setMinimumWidth(kTextWidth);
m_info->setMaximumWidth(kMaxTextWidth);
m_info->hide();
root->addWidget(m_info);

root->addSpacing(4);
m_buttons = new QHBoxLayout;
m_buttons->setSpacing(8);
m_buttons->addStretch(1);
root->addLayout(m_buttons);
}

void CanonMessageBox::setInformativeText(const QString& text)
{
m_info->setText(text);
m_info->setVisible(!text.isEmpty());
setAccessibleDescription(text.isEmpty() ? m_text->text()
: m_text->text() + QLatin1Char('\n') + text);
}

void CanonMessageBox::setTextSelectable(bool on)
{
const Qt::TextInteractionFlags flags = on ? Qt::TextSelectableByMouse : Qt::NoTextInteraction;
m_text->setTextInteractionFlags(flags);
m_info->setTextInteractionFlags(flags);
}

QPushButton* CanonMessageBox::addButton(const QString& text, Role role)
{
// Auto-default, as in QMessageBox: a focused button takes Return itself,
// and the default button takes it only while no other button has focus.
auto* button = new QPushButton(text);
button->setAutoDefault(true);
if (role == Role::Destructive) {
button->setProperty("role", QStringLiteral("destructive"));
}
connect(button, &QPushButton::clicked, this, [this, button, role] {
m_clicked = button;
if (role == Role::Reject) {
reject();
} else {
accept();
}
});
m_buttons->addWidget(button);
m_added.append(button);
return button;
}

void CanonMessageBox::setDefaultButton(QPushButton* button)
{
for (QPushButton* b : std::as_const(m_added)) {
b->setDefault(b == button);
}
m_default = button;
}

void CanonMessageBox::showEvent(QShowEvent* event)
{
CanonWindow::showEvent(event);
m_clicked = nullptr; // this showing's answer, not a previous one's
if (m_default) {
m_default->setFocus();
}
// As QMessageBox does, so a screen reader reads the texts as it opens.
QAccessibleEvent alert(this, QAccessible::Alert);
QAccessible::updateAccessibility(&alert);
}

} // namespace AetherSDR
52 changes: 52 additions & 0 deletions src/gui/CanonMessageBox.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#pragma once

#include "CanonWindow.h"

#include <QList>

class QLabel;
class QHBoxLayout;
class QPushButton;

namespace AetherSDR {

// A question or a notice in the style guide's window, for windows built on
// CanonWindow in place of QMessageBox: the title as a canon header, the text in
// canon ink, optional further text below it in the softer ink, and the buttons
// in a row at the bottom right. Run it with exec() and read clickedButton(),
// which stays null when the operator closes it instead (the corner button,
// Escape, ⌘W / Ctrl+W).
class CanonMessageBox : public CanonWindow {
Q_OBJECT

public:
// Accept and Reject buttons close it as accepted or rejected; Destructive
// accepts too, and is drawn in the warning gold, for a step that replaces
// what is on the device.
enum class Role { Accept, Reject, Destructive };

CanonMessageBox(const QString& title, const QString& text, QWidget* parent = nullptr);

void setInformativeText(const QString& text);
// Lets the operator select and copy the texts (commands to paste).
void setTextSelectable(bool on);

QPushButton* addButton(const QString& text, Role role);
// The button Return clicks; also takes the focus when the box opens.
// Every box sets one; without it nothing decides where the focus starts.
void setDefaultButton(QPushButton* button);
QPushButton* clickedButton() const { return m_clicked; }

protected:
void showEvent(QShowEvent* event) override;

private:
QLabel* m_text{nullptr};
QLabel* m_info{nullptr};
QHBoxLayout* m_buttons{nullptr};
QList<QPushButton*> m_added;
QPushButton* m_default{nullptr};
QPushButton* m_clicked{nullptr};
};

} // namespace AetherSDR
Loading
Loading