Apart Concept 1 Serial Controller Program with modern UI
This commit is contained in:
commit
c4bde4c18c
103 changed files with 38385 additions and 0 deletions
397
src/core/Concept1Controller.cpp
Normal file
397
src/core/Concept1Controller.cpp
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
#include "Concept1Controller.h"
|
||||
#include "transport/ITransport.h"
|
||||
|
||||
using namespace Concept1;
|
||||
|
||||
Concept1Controller::Concept1Controller(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
Concept1Controller::~Concept1Controller() = default;
|
||||
|
||||
void Concept1Controller::setTransport(ITransport *transport)
|
||||
{
|
||||
if (m_transport == transport)
|
||||
return;
|
||||
if (m_transport) {
|
||||
m_transport->disconnect(this);
|
||||
if (m_transport->parent() == this)
|
||||
m_transport->deleteLater();
|
||||
}
|
||||
m_transport = transport;
|
||||
if (m_transport) {
|
||||
m_transport->setParent(this);
|
||||
connect(m_transport, &ITransport::bytesReceived,
|
||||
this, &Concept1Controller::onBytesReceived);
|
||||
connect(m_transport, &ITransport::openedChanged,
|
||||
this, &Concept1Controller::onTransportOpenedChanged);
|
||||
connect(m_transport, &ITransport::errorOccurred,
|
||||
this, &Concept1Controller::onTransportError);
|
||||
}
|
||||
}
|
||||
|
||||
bool Concept1Controller::connectDevice()
|
||||
{
|
||||
if (!m_transport)
|
||||
return false;
|
||||
m_rxBuffer.clear();
|
||||
m_pendingBareGets.clear();
|
||||
return m_transport->open();
|
||||
}
|
||||
|
||||
void Concept1Controller::disconnectDevice()
|
||||
{
|
||||
if (m_transport)
|
||||
m_transport->close();
|
||||
}
|
||||
|
||||
bool Concept1Controller::isConnected() const
|
||||
{
|
||||
return m_transport && m_transport->isOpen();
|
||||
}
|
||||
|
||||
void Concept1Controller::send(const QByteArray &command)
|
||||
{
|
||||
if (!isConnected())
|
||||
return;
|
||||
m_transport->write(command);
|
||||
QString shown = QString::fromLatin1(command);
|
||||
shown.replace(QLatin1Char('\r'), QString());
|
||||
shown.replace(QLatin1Char('\n'), QString());
|
||||
emit lineSent(shown);
|
||||
}
|
||||
|
||||
// --- High-level command API -------------------------------------------------
|
||||
|
||||
void Concept1Controller::setMultiZone(bool on)
|
||||
{ send(buildSetBool(Attribute::MultiZone, on)); }
|
||||
|
||||
void Concept1Controller::setZoneLink(bool on)
|
||||
{ send(buildSetBool(Attribute::ZoneLink, on)); }
|
||||
|
||||
void Concept1Controller::setMusicLevel(Zone zone, int level)
|
||||
{
|
||||
const Zone z = m_state.musicNeedsZone() ? zone : Zone::None;
|
||||
send(buildSetInt(Attribute::MusicLevel, clampLevel(level), z));
|
||||
}
|
||||
|
||||
void Concept1Controller::setMicLevel(Zone zone, int level)
|
||||
{
|
||||
const Zone z = m_state.micNeedsZone() ? zone : Zone::None;
|
||||
send(buildSetInt(Attribute::MicLevel, clampLevel(level), z));
|
||||
}
|
||||
|
||||
void Concept1Controller::setMaxMusicLevel(Zone zone, int level)
|
||||
{
|
||||
const Zone z = m_state.musicNeedsZone() ? zone : Zone::None;
|
||||
send(buildSetInt(Attribute::MaxMusicLevel, clampLevel(level), z));
|
||||
}
|
||||
|
||||
void Concept1Controller::setMaxMicLevel(Zone zone, int level)
|
||||
{
|
||||
const Zone z = m_state.micNeedsZone() ? zone : Zone::None;
|
||||
send(buildSetInt(Attribute::MaxMicLevel, clampLevel(level), z));
|
||||
}
|
||||
|
||||
void Concept1Controller::incMusicLevel(Zone zone, int step)
|
||||
{
|
||||
const Zone z = m_state.musicNeedsZone() ? zone : Zone::None;
|
||||
send(buildStep(Command::Inc, Attribute::MusicLevel, step, z));
|
||||
}
|
||||
|
||||
void Concept1Controller::decMusicLevel(Zone zone, int step)
|
||||
{
|
||||
const Zone z = m_state.musicNeedsZone() ? zone : Zone::None;
|
||||
send(buildStep(Command::Dec, Attribute::MusicLevel, step, z));
|
||||
}
|
||||
|
||||
void Concept1Controller::selectSource(Source source)
|
||||
{ send(buildSetString(Attribute::Select, sourceToken(source))); }
|
||||
|
||||
void Concept1Controller::setEqBass(int value)
|
||||
{ send(buildSetInt(Attribute::EqBass, normalizeEq(value))); }
|
||||
|
||||
void Concept1Controller::setEqTreble(int value)
|
||||
{ send(buildSetInt(Attribute::EqTreble, normalizeEq(value))); }
|
||||
|
||||
void Concept1Controller::setStandby(bool on)
|
||||
{ send(buildSetBool(Attribute::Standby, on)); }
|
||||
|
||||
void Concept1Controller::setAutoLoudness(bool on)
|
||||
{ send(buildSetBool(Attribute::AutoLoudness, on)); }
|
||||
|
||||
void Concept1Controller::setPagingActive(Zone zone, bool on)
|
||||
{
|
||||
const Zone z = m_state.micNeedsZone() ? zone : Zone::None;
|
||||
send(buildSetBool(Attribute::PagingActive, on, z));
|
||||
}
|
||||
|
||||
void Concept1Controller::setInputGain(Source source, int value)
|
||||
{ send(buildSetInt(Attribute::InputGain, normalizeGain(value), Zone::None, source)); }
|
||||
|
||||
void Concept1Controller::setSourceName(Source source, const QString &name)
|
||||
{ send(buildSetString(Attribute::SourceName, name, source)); }
|
||||
|
||||
void Concept1Controller::setEcho(bool on) { send(buildSetBool(Attribute::Echo, on)); }
|
||||
void Concept1Controller::setLineFeed(bool on) { send(buildSetBool(Attribute::LineFeed, on)); }
|
||||
void Concept1Controller::setBackSpace(bool on) { send(buildSetBool(Attribute::BackSpace, on)); }
|
||||
void Concept1Controller::setHeader(bool on) { send(buildSetBool(Attribute::Header, on)); }
|
||||
void Concept1Controller::setValueFeedback(bool on) { send(buildSetBool(Attribute::ValueFeedback, on)); }
|
||||
|
||||
void Concept1Controller::restoreFactory()
|
||||
{ send(buildSetBool(Attribute::Restore, true)); }
|
||||
|
||||
void Concept1Controller::refreshInfo()
|
||||
{ send(buildGet(Attribute::Info)); }
|
||||
|
||||
void Concept1Controller::refreshVersions()
|
||||
{
|
||||
m_pendingBareGets.enqueue({Attribute::Serial, std::nullopt});
|
||||
send(buildGet(Attribute::Serial));
|
||||
m_pendingBareGets.enqueue({Attribute::HwVersion, std::nullopt});
|
||||
send(buildGet(Attribute::HwVersion));
|
||||
m_pendingBareGets.enqueue({Attribute::SwVersion, std::nullopt});
|
||||
send(buildGet(Attribute::SwVersion));
|
||||
}
|
||||
|
||||
void Concept1Controller::refreshSourceNames()
|
||||
{
|
||||
for (Source s : {Source::A, Source::B, Source::C, Source::D}) {
|
||||
m_pendingBareGets.enqueue({Attribute::SourceName, s});
|
||||
send(buildGet(Attribute::SourceName, Zone::None, s));
|
||||
}
|
||||
}
|
||||
|
||||
void Concept1Controller::refreshInputGains()
|
||||
{
|
||||
for (Source s : {Source::A, Source::B, Source::C, Source::D})
|
||||
send(buildGet(Attribute::InputGain, Zone::None, s));
|
||||
}
|
||||
|
||||
void Concept1Controller::sendRaw(const QString &line)
|
||||
{
|
||||
if (!isConnected())
|
||||
return;
|
||||
QByteArray cmd = line.toLatin1();
|
||||
cmd.append('\r');
|
||||
send(cmd);
|
||||
}
|
||||
|
||||
// --- Transport plumbing -----------------------------------------------------
|
||||
|
||||
void Concept1Controller::onTransportOpenedChanged(bool open)
|
||||
{
|
||||
const QString endpoint = m_transport ? m_transport->endpointName() : QString();
|
||||
emit connectionChanged(open, endpoint);
|
||||
if (open)
|
||||
initialSync();
|
||||
}
|
||||
|
||||
void Concept1Controller::onTransportError(const QString &message)
|
||||
{
|
||||
emit errorReceived(message);
|
||||
}
|
||||
|
||||
void Concept1Controller::initialSync()
|
||||
{
|
||||
// Ensure value feedback is on, then pull a full snapshot.
|
||||
send(buildSetBool(Attribute::ValueFeedback, true));
|
||||
send(buildGet(Attribute::MultiZone));
|
||||
send(buildGet(Attribute::ZoneLink));
|
||||
refreshInfo();
|
||||
refreshInputGains();
|
||||
refreshSourceNames();
|
||||
refreshVersions();
|
||||
// RS-232 setting states
|
||||
send(buildGet(Attribute::Echo));
|
||||
send(buildGet(Attribute::LineFeed));
|
||||
send(buildGet(Attribute::BackSpace));
|
||||
send(buildGet(Attribute::Header));
|
||||
send(buildGet(Attribute::AutoLoudness));
|
||||
}
|
||||
|
||||
void Concept1Controller::onBytesReceived(const QByteArray &data)
|
||||
{
|
||||
m_rxBuffer.append(data);
|
||||
int idx;
|
||||
while ((idx = m_rxBuffer.indexOf('\r')) >= 0) {
|
||||
QByteArray lineBytes = m_rxBuffer.left(idx);
|
||||
m_rxBuffer.remove(0, idx + 1);
|
||||
QString line = QString::fromLatin1(lineBytes);
|
||||
line.remove(QLatin1Char('\n'));
|
||||
line = line.trimmed();
|
||||
if (!line.isEmpty())
|
||||
handleLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
void Concept1Controller::handleLine(const QString &line)
|
||||
{
|
||||
emit lineReceived(line);
|
||||
|
||||
ParsedReply r = parseReply(line);
|
||||
|
||||
// Echoed instructions are ignored for state purposes.
|
||||
if (r.kind == ReplyKind::Echo)
|
||||
return;
|
||||
|
||||
// A bare value reply (Unknown kind) that matches a pending GET for
|
||||
// SOURCENAME / SERIAL / HWVRSN / SWVRSN.
|
||||
if (r.kind == ReplyKind::Unknown && !m_pendingBareGets.isEmpty()) {
|
||||
const PendingGet pg = m_pendingBareGets.dequeue();
|
||||
switch (pg.attr) {
|
||||
case Attribute::SourceName:
|
||||
if (pg.source) {
|
||||
const int i = DeviceState::sourceIndex(*pg.source);
|
||||
m_state.sourceName[i] = line;
|
||||
emit sourceNameChanged(*pg.source, line);
|
||||
emit stateChanged();
|
||||
}
|
||||
return;
|
||||
case Attribute::Serial:
|
||||
m_state.serial = line; emit versionsChanged(); return;
|
||||
case Attribute::HwVersion:
|
||||
m_state.hwVersion = line; emit versionsChanged(); return;
|
||||
case Attribute::SwVersion:
|
||||
m_state.swVersion = line; emit versionsChanged(); return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// NotImplemented reply also consumes a pending bare GET.
|
||||
if (r.kind == ReplyKind::NotImplemented && !m_pendingBareGets.isEmpty()) {
|
||||
m_pendingBareGets.dequeue();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (r.kind) {
|
||||
case ReplyKind::Error:
|
||||
emit errorReceived(r.textValue);
|
||||
return;
|
||||
case ReplyKind::PleaseCycle:
|
||||
emit notice(QStringLiteral("Bitte Gerät aus- und wieder einschalten (Werksreset)."));
|
||||
return;
|
||||
case ReplyKind::PowerDown:
|
||||
emit notice(QStringLiteral("Gerät schaltet ab (POWER DOWN)."));
|
||||
return;
|
||||
case ReplyKind::Acknowledge:
|
||||
return; // SET IPGAIN / SOURCENAME ack; state already optimistic-free
|
||||
case ReplyKind::Paging: {
|
||||
const int zi = DeviceState::zoneIndex(r.zone);
|
||||
m_state.pagingState[zi] = r.boolValue;
|
||||
emit pagingStateChanged(r.zone == Zone::Zone2 ? Zone::Zone2 : Zone::Zone1,
|
||||
r.boolValue);
|
||||
return;
|
||||
}
|
||||
case ReplyKind::Value:
|
||||
case ReplyKind::Info:
|
||||
applyParsed(r);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void Concept1Controller::applyParsed(const ParsedReply &r)
|
||||
{
|
||||
const int zi = DeviceState::zoneIndex(r.zone);
|
||||
switch (r.attribute) {
|
||||
case Attribute::MultiZone:
|
||||
if (r.hasBool && m_state.multiZone != r.boolValue) {
|
||||
m_state.multiZone = r.boolValue;
|
||||
emit modeChanged();
|
||||
}
|
||||
break;
|
||||
case Attribute::ZoneLink:
|
||||
if (r.hasBool && m_state.zoneLink != r.boolValue) {
|
||||
m_state.zoneLink = r.boolValue;
|
||||
emit modeChanged();
|
||||
}
|
||||
break;
|
||||
case Attribute::MusicLevel:
|
||||
if (r.intValue) {
|
||||
m_state.musicLevel[zi] = *r.intValue;
|
||||
emit musicLevelChanged(r.zone == Zone::Zone2 ? Zone::Zone2 : Zone::Zone1,
|
||||
*r.intValue);
|
||||
}
|
||||
break;
|
||||
case Attribute::MicLevel:
|
||||
if (r.intValue) {
|
||||
m_state.micLevel[zi] = *r.intValue;
|
||||
emit micLevelChanged(r.zone == Zone::Zone2 ? Zone::Zone2 : Zone::Zone1,
|
||||
*r.intValue);
|
||||
}
|
||||
break;
|
||||
case Attribute::MaxMusicLevel:
|
||||
if (r.intValue) {
|
||||
m_state.maxMusicLevel[zi] = *r.intValue;
|
||||
emit maxMusicLevelChanged(r.zone == Zone::Zone2 ? Zone::Zone2 : Zone::Zone1,
|
||||
*r.intValue);
|
||||
}
|
||||
break;
|
||||
case Attribute::MaxMicLevel:
|
||||
if (r.intValue) {
|
||||
m_state.maxMicLevel[zi] = *r.intValue;
|
||||
emit maxMicLevelChanged(r.zone == Zone::Zone2 ? Zone::Zone2 : Zone::Zone1,
|
||||
*r.intValue);
|
||||
}
|
||||
break;
|
||||
case Attribute::Select:
|
||||
if (r.source) {
|
||||
m_state.select = *r.source;
|
||||
emit sourceChanged(*r.source);
|
||||
}
|
||||
break;
|
||||
case Attribute::EqBass:
|
||||
if (r.intValue) { m_state.eqBass = *r.intValue; emit eqChanged(); }
|
||||
break;
|
||||
case Attribute::EqTreble:
|
||||
if (r.intValue) { m_state.eqTreble = *r.intValue; emit eqChanged(); }
|
||||
break;
|
||||
case Attribute::Standby:
|
||||
if (r.hasBool) { m_state.standby = r.boolValue; emit standbyChanged(r.boolValue); }
|
||||
break;
|
||||
case Attribute::AutoLoudness:
|
||||
if (r.hasBool) { m_state.autoLoudness = r.boolValue; emit autoLoudnessChanged(r.boolValue); }
|
||||
break;
|
||||
case Attribute::PagingActive:
|
||||
if (r.hasBool) {
|
||||
m_state.pagingActive[zi] = r.boolValue;
|
||||
emit pagingActiveChanged(r.zone == Zone::Zone2 ? Zone::Zone2 : Zone::Zone1,
|
||||
r.boolValue);
|
||||
}
|
||||
break;
|
||||
case Attribute::InputGain:
|
||||
if (r.intValue && r.source) {
|
||||
m_state.inputGain[DeviceState::sourceIndex(*r.source)] = *r.intValue;
|
||||
emit inputGainChanged(*r.source, *r.intValue);
|
||||
}
|
||||
break;
|
||||
case Attribute::SourceName:
|
||||
if (r.source) {
|
||||
m_state.sourceName[DeviceState::sourceIndex(*r.source)] = r.textValue;
|
||||
emit sourceNameChanged(*r.source, r.textValue);
|
||||
}
|
||||
break;
|
||||
case Attribute::Echo:
|
||||
if (r.hasBool) { m_state.echo = r.boolValue; emit rs232SettingsChanged(); }
|
||||
break;
|
||||
case Attribute::LineFeed:
|
||||
if (r.hasBool) { m_state.lineFeed = r.boolValue; emit rs232SettingsChanged(); }
|
||||
break;
|
||||
case Attribute::BackSpace:
|
||||
if (r.hasBool) { m_state.backSpace = r.boolValue; emit rs232SettingsChanged(); }
|
||||
break;
|
||||
case Attribute::Header:
|
||||
if (r.hasBool) { m_state.header = r.boolValue; emit rs232SettingsChanged(); }
|
||||
break;
|
||||
case Attribute::ValueFeedback:
|
||||
if (r.hasBool) { m_state.valueFeedback = r.boolValue; emit rs232SettingsChanged(); }
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
emit stateChanged();
|
||||
}
|
||||
112
src/core/Concept1Controller.h
Normal file
112
src/core/Concept1Controller.h
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#pragma once
|
||||
|
||||
#include "protocol/Concept1Protocol.h"
|
||||
#include "protocol/Concept1State.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QByteArray>
|
||||
#include <QQueue>
|
||||
|
||||
class ITransport;
|
||||
|
||||
// High-level facade over a transport + protocol. Owns the device state,
|
||||
// translates UI intents into RS-232 instructions, parses incoming lines,
|
||||
// keeps the state in sync, and emits fine-grained change signals.
|
||||
class Concept1Controller : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit Concept1Controller(QObject *parent = nullptr);
|
||||
~Concept1Controller() override;
|
||||
|
||||
// Takes ownership of the transport. Pass nullptr to detach.
|
||||
void setTransport(ITransport *transport);
|
||||
ITransport *transport() const { return m_transport; }
|
||||
|
||||
bool connectDevice();
|
||||
void disconnectDevice();
|
||||
bool isConnected() const;
|
||||
|
||||
const Concept1::DeviceState &state() const { return m_state; }
|
||||
|
||||
// --- High-level commands (no-ops if not connected) ------------------
|
||||
void setMultiZone(bool on);
|
||||
void setZoneLink(bool on);
|
||||
void setMusicLevel(Concept1::Zone zone, int level);
|
||||
void setMicLevel(Concept1::Zone zone, int level);
|
||||
void setMaxMusicLevel(Concept1::Zone zone, int level);
|
||||
void setMaxMicLevel(Concept1::Zone zone, int level);
|
||||
void incMusicLevel(Concept1::Zone zone, int step = 1);
|
||||
void decMusicLevel(Concept1::Zone zone, int step = 1);
|
||||
void selectSource(Concept1::Source source);
|
||||
void setEqBass(int value);
|
||||
void setEqTreble(int value);
|
||||
void setStandby(bool on);
|
||||
void setAutoLoudness(bool on);
|
||||
void setPagingActive(Concept1::Zone zone, bool on);
|
||||
void setInputGain(Concept1::Source source, int value);
|
||||
void setSourceName(Concept1::Source source, const QString &name);
|
||||
void setEcho(bool on);
|
||||
void setLineFeed(bool on);
|
||||
void setBackSpace(bool on);
|
||||
void setHeader(bool on);
|
||||
void setValueFeedback(bool on);
|
||||
void restoreFactory();
|
||||
|
||||
void refreshInfo(); // GET INFO
|
||||
void refreshVersions(); // SERIAL / HWVRSN / SWVRSN
|
||||
void refreshSourceNames();
|
||||
void refreshInputGains();
|
||||
|
||||
// Send an arbitrary raw command line (terminal). <CR> is appended.
|
||||
void sendRaw(const QString &line);
|
||||
|
||||
signals:
|
||||
void connectionChanged(bool connected, const QString &endpoint);
|
||||
void stateChanged(); // any state field changed (coarse)
|
||||
void modeChanged(); // multizone/zonelink changed
|
||||
void musicLevelChanged(Concept1::Zone zone, int level);
|
||||
void micLevelChanged(Concept1::Zone zone, int level);
|
||||
void maxMusicLevelChanged(Concept1::Zone zone, int level);
|
||||
void maxMicLevelChanged(Concept1::Zone zone, int level);
|
||||
void sourceChanged(Concept1::Source source);
|
||||
void eqChanged();
|
||||
void standbyChanged(bool on);
|
||||
void autoLoudnessChanged(bool on);
|
||||
void pagingActiveChanged(Concept1::Zone zone, bool on);
|
||||
void pagingStateChanged(Concept1::Zone zone, bool active);
|
||||
void inputGainChanged(Concept1::Source source, int value);
|
||||
void sourceNameChanged(Concept1::Source source, const QString &name);
|
||||
void rs232SettingsChanged();
|
||||
void versionsChanged();
|
||||
|
||||
void errorReceived(const QString &message);
|
||||
void notice(const QString &message); // PleaseCycle / PowerDown etc.
|
||||
|
||||
// Raw I/O for the terminal view.
|
||||
void lineSent(const QString &line);
|
||||
void lineReceived(const QString &line);
|
||||
|
||||
private slots:
|
||||
void onBytesReceived(const QByteArray &data);
|
||||
void onTransportOpenedChanged(bool open);
|
||||
void onTransportError(const QString &message);
|
||||
|
||||
private:
|
||||
void send(const QByteArray &command);
|
||||
void handleLine(const QString &line);
|
||||
void applyParsed(const Concept1::ParsedReply &r);
|
||||
void initialSync();
|
||||
|
||||
ITransport *m_transport = nullptr;
|
||||
Concept1::DeviceState m_state;
|
||||
QByteArray m_rxBuffer;
|
||||
|
||||
// Queue of attributes whose GET reply is a bare value (no attribute
|
||||
// prefix): SOURCENAME, SERIAL, HWVRSN, SWVRSN. Paired with the source
|
||||
// for SOURCENAME.
|
||||
struct PendingGet {
|
||||
Concept1::Attribute attr;
|
||||
std::optional<Concept1::Source> source;
|
||||
};
|
||||
QQueue<PendingGet> m_pendingBareGets;
|
||||
};
|
||||
15
src/main.cpp
Normal file
15
src/main.cpp
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#include "ui/MainWindow.h"
|
||||
|
||||
#include <QApplication>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName(QStringLiteral("Apart Concept 1 Controller"));
|
||||
QApplication::setOrganizationName(QStringLiteral("ApartController"));
|
||||
QApplication::setApplicationVersion(QStringLiteral("1.0.0"));
|
||||
|
||||
MainWindow window;
|
||||
window.show();
|
||||
return app.exec();
|
||||
}
|
||||
388
src/protocol/Concept1Protocol.cpp
Normal file
388
src/protocol/Concept1Protocol.cpp
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
#include "Concept1Protocol.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <cmath>
|
||||
|
||||
namespace Concept1 {
|
||||
|
||||
QString attributeToken(Attribute attr)
|
||||
{
|
||||
switch (attr) {
|
||||
case Attribute::MultiZone: return QStringLiteral("MULTIZONE");
|
||||
case Attribute::ZoneLink: return QStringLiteral("ZONELINK");
|
||||
case Attribute::MusicLevel: return QStringLiteral("MSCLVL");
|
||||
case Attribute::MicLevel: return QStringLiteral("MICLVL");
|
||||
case Attribute::MaxMusicLevel: return QStringLiteral("MAXMSCLVL");
|
||||
case Attribute::MaxMicLevel: return QStringLiteral("MAXMICLVL");
|
||||
case Attribute::Select: return QStringLiteral("SELECT");
|
||||
case Attribute::EqBass: return QStringLiteral("EQBASS");
|
||||
case Attribute::EqTreble: return QStringLiteral("EQTREB");
|
||||
case Attribute::Standby: return QStringLiteral("STANDBY");
|
||||
case Attribute::AutoLoudness: return QStringLiteral("AUTOLD");
|
||||
case Attribute::PagingActive: return QStringLiteral("PAGACT");
|
||||
case Attribute::InputGain: return QStringLiteral("IPGAIN");
|
||||
case Attribute::Echo: return QStringLiteral("ECHO");
|
||||
case Attribute::LineFeed: return QStringLiteral("LF");
|
||||
case Attribute::BackSpace: return QStringLiteral("BS");
|
||||
case Attribute::Header: return QStringLiteral("HEADER");
|
||||
case Attribute::ValueFeedback: return QStringLiteral("VALFB");
|
||||
case Attribute::Info: return QStringLiteral("INFO");
|
||||
case Attribute::SourceName: return QStringLiteral("SOURCENAME");
|
||||
case Attribute::Serial: return QStringLiteral("SERIAL");
|
||||
case Attribute::HwVersion: return QStringLiteral("HWVRSN");
|
||||
case Attribute::SwVersion: return QStringLiteral("SWVRSN");
|
||||
case Attribute::Restore: return QStringLiteral("RESTORE");
|
||||
case Attribute::Unknown: break;
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
Attribute attributeFromToken(const QString &token)
|
||||
{
|
||||
const QString t = token.trimmed().toUpper();
|
||||
if (t == QLatin1String("MULTIZONE")) return Attribute::MultiZone;
|
||||
if (t == QLatin1String("ZONELINK")) return Attribute::ZoneLink;
|
||||
if (t == QLatin1String("MSCLVL")) return Attribute::MusicLevel;
|
||||
if (t == QLatin1String("MICLVL")) return Attribute::MicLevel;
|
||||
if (t == QLatin1String("MAXMSCLVL")) return Attribute::MaxMusicLevel;
|
||||
if (t == QLatin1String("MAXMICLVL")) return Attribute::MaxMicLevel;
|
||||
if (t == QLatin1String("SELECT")) return Attribute::Select;
|
||||
if (t == QLatin1String("EQBASS")) return Attribute::EqBass;
|
||||
if (t == QLatin1String("EQTREB")) return Attribute::EqTreble;
|
||||
if (t == QLatin1String("STANDBY")) return Attribute::Standby;
|
||||
if (t == QLatin1String("AUTOLD")) return Attribute::AutoLoudness;
|
||||
if (t == QLatin1String("PAGACT")) return Attribute::PagingActive;
|
||||
if (t == QLatin1String("IPGAIN")) return Attribute::InputGain;
|
||||
if (t == QLatin1String("ECHO")) return Attribute::Echo;
|
||||
if (t == QLatin1String("LF")) return Attribute::LineFeed;
|
||||
if (t == QLatin1String("BS")) return Attribute::BackSpace;
|
||||
if (t == QLatin1String("HEADER")) return Attribute::Header;
|
||||
if (t == QLatin1String("VALFB")) return Attribute::ValueFeedback;
|
||||
if (t == QLatin1String("INFO")) return Attribute::Info;
|
||||
if (t == QLatin1String("SOURCENAME")) return Attribute::SourceName;
|
||||
if (t == QLatin1String("SERIAL")) return Attribute::Serial;
|
||||
if (t == QLatin1String("HWVRSN")) return Attribute::HwVersion;
|
||||
if (t == QLatin1String("SWVRSN")) return Attribute::SwVersion;
|
||||
if (t == QLatin1String("RESTORE")) return Attribute::Restore;
|
||||
return Attribute::Unknown;
|
||||
}
|
||||
|
||||
QString commandToken(Command cmd)
|
||||
{
|
||||
switch (cmd) {
|
||||
case Command::Set: return QStringLiteral("SET");
|
||||
case Command::Get: return QStringLiteral("GET");
|
||||
case Command::Inc: return QStringLiteral("INC");
|
||||
case Command::Dec: return QStringLiteral("DEC");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString zoneToken(Zone zone)
|
||||
{
|
||||
switch (zone) {
|
||||
case Zone::Zone1: return QStringLiteral("ZONE1");
|
||||
case Zone::Zone2: return QStringLiteral("ZONE2");
|
||||
case Zone::None: break;
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString sourceToken(Source src)
|
||||
{
|
||||
switch (src) {
|
||||
case Source::A: return QStringLiteral("A");
|
||||
case Source::B: return QStringLiteral("B");
|
||||
case Source::C: return QStringLiteral("C");
|
||||
case Source::D: return QStringLiteral("D");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
std::optional<Source> sourceFromToken(const QString &token)
|
||||
{
|
||||
const QString t = token.trimmed().toUpper();
|
||||
if (t == QLatin1String("A")) return Source::A;
|
||||
if (t == QLatin1String("B")) return Source::B;
|
||||
if (t == QLatin1String("C")) return Source::C;
|
||||
if (t == QLatin1String("D")) return Source::D;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Zone> zoneFromToken(const QString &token)
|
||||
{
|
||||
const QString t = token.trimmed().toUpper();
|
||||
if (t == QLatin1String("ZONE1")) return Zone::Zone1;
|
||||
if (t == QLatin1String("ZONE2")) return Zone::Zone2;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
int clampLevel(int value)
|
||||
{
|
||||
if (value <= kLevelOff) return kLevelOff;
|
||||
if (value > kLevelMax) return kLevelMax;
|
||||
if (value < kLevelMin) return kLevelOff; // anything below -79 collapses to OFF
|
||||
return value;
|
||||
}
|
||||
|
||||
static int snap(int value, int lo, int hi, int step)
|
||||
{
|
||||
if (value < lo) value = lo;
|
||||
if (value > hi) value = hi;
|
||||
const int steps = static_cast<int>(std::lround(double(value - lo) / step));
|
||||
int snapped = lo + steps * step;
|
||||
if (snapped > hi) snapped = hi;
|
||||
return snapped;
|
||||
}
|
||||
|
||||
int normalizeEq(int value) { return snap(value, kEqMin, kEqMax, kEqStep); }
|
||||
int normalizeGain(int value) { return snap(value, kGainMin, kGainMax, kGainStep); }
|
||||
|
||||
int clampStep(int step)
|
||||
{
|
||||
if (step < 1) return 1;
|
||||
if (step > 10) return 10;
|
||||
return step;
|
||||
}
|
||||
|
||||
QString levelToDisplay(int level)
|
||||
{
|
||||
if (level <= kLevelOff)
|
||||
return QStringLiteral("OFF");
|
||||
return QStringLiteral("%1 dB").arg(level);
|
||||
}
|
||||
|
||||
QString levelToken(int level)
|
||||
{
|
||||
if (level <= kLevelOff)
|
||||
return QStringLiteral("OFF");
|
||||
return QString::number(level);
|
||||
}
|
||||
|
||||
QByteArray buildCommand(Command cmd, Attribute attr, Zone zone,
|
||||
std::optional<Source> source,
|
||||
std::optional<QString> value)
|
||||
{
|
||||
QString s = commandToken(cmd);
|
||||
s += QLatin1Char(' ');
|
||||
s += attributeToken(attr);
|
||||
if (source.has_value()) {
|
||||
s += QLatin1Char(' ');
|
||||
s += sourceToken(*source);
|
||||
}
|
||||
if (zone != Zone::None) {
|
||||
s += QLatin1Char(' ');
|
||||
s += zoneToken(zone);
|
||||
}
|
||||
if (value.has_value() && !value->isEmpty()) {
|
||||
s += QLatin1Char(' ');
|
||||
s += *value;
|
||||
}
|
||||
s += QLatin1Char(kCr);
|
||||
return s.toLatin1();
|
||||
}
|
||||
|
||||
QByteArray buildGet(Attribute attr, Zone zone, std::optional<Source> source)
|
||||
{
|
||||
return buildCommand(Command::Get, attr, zone, source, std::nullopt);
|
||||
}
|
||||
|
||||
QByteArray buildSetBool(Attribute attr, bool on, Zone zone)
|
||||
{
|
||||
return buildCommand(Command::Set, attr, zone, std::nullopt,
|
||||
on ? QStringLiteral("ON") : QStringLiteral("OFF"));
|
||||
}
|
||||
|
||||
QByteArray buildSetInt(Attribute attr, int value, Zone zone,
|
||||
std::optional<Source> source)
|
||||
{
|
||||
return buildCommand(Command::Set, attr, zone, source,
|
||||
QString::number(value));
|
||||
}
|
||||
|
||||
QByteArray buildSetString(Attribute attr, const QString &value,
|
||||
std::optional<Source> source)
|
||||
{
|
||||
return buildCommand(Command::Set, attr, Zone::None, source, value);
|
||||
}
|
||||
|
||||
QByteArray buildStep(Command incOrDec, Attribute attr, int step, Zone zone)
|
||||
{
|
||||
return buildCommand(incOrDec, attr, zone, std::nullopt,
|
||||
QString::number(clampStep(step)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static bool parseLevelToken(const QString &tok, int *out)
|
||||
{
|
||||
const QString t = tok.trimmed().toUpper();
|
||||
if (t == QLatin1String("OFF")) {
|
||||
*out = kLevelOff;
|
||||
return true;
|
||||
}
|
||||
bool ok = false;
|
||||
const int v = t.toInt(&ok);
|
||||
if (ok) {
|
||||
*out = v;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ParsedReply parseReply(const QString &line)
|
||||
{
|
||||
ParsedReply r;
|
||||
QString text = line;
|
||||
// Strip optional HEADER '>' prefix and trailing CR/LF/space.
|
||||
text = text.trimmed();
|
||||
if (text.startsWith(QLatin1Char('>')))
|
||||
text = text.mid(1).trimmed();
|
||||
r.rawText = text;
|
||||
|
||||
if (text.isEmpty()) {
|
||||
r.kind = ReplyKind::Unknown;
|
||||
return r;
|
||||
}
|
||||
|
||||
const QString upper = text.toUpper();
|
||||
|
||||
// --- Fixed message strings ---
|
||||
if (upper.startsWith(QLatin1String("ERROR"))) {
|
||||
r.kind = ReplyKind::Error;
|
||||
r.textValue = text;
|
||||
return r;
|
||||
}
|
||||
// Accept both the correct spelling and the documented typo "Exicuted".
|
||||
if (upper.startsWith(QLatin1String("COMMAND EXECUTED")) ||
|
||||
upper.startsWith(QLatin1String("COMMAND EXICUTED"))) {
|
||||
r.kind = ReplyKind::Acknowledge;
|
||||
return r;
|
||||
}
|
||||
if (upper.startsWith(QLatin1String("PLEASE CYCLE POWER"))) {
|
||||
r.kind = ReplyKind::PleaseCycle;
|
||||
return r;
|
||||
}
|
||||
if (upper.startsWith(QLatin1String("POWER DOWN"))) {
|
||||
r.kind = ReplyKind::PowerDown;
|
||||
return r;
|
||||
}
|
||||
if (upper.startsWith(QLatin1String("NOT YET IMPLEMENTED"))) {
|
||||
r.kind = ReplyKind::NotImplemented;
|
||||
return r;
|
||||
}
|
||||
if (upper.startsWith(QLatin1String("PAGING"))) {
|
||||
r.kind = ReplyKind::Paging;
|
||||
const QStringList parts = text.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
||||
// "PAGING ON/OFF" or "PAGING ZONEx ON/OFF"
|
||||
for (const QString &p : parts) {
|
||||
if (auto z = zoneFromToken(p)) r.zone = *z;
|
||||
}
|
||||
const QString last = parts.isEmpty() ? QString() : parts.last().toUpper();
|
||||
r.boolValue = (last == QLatin1String("ON"));
|
||||
r.hasBool = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
// --- Tokenised attribute lines ---
|
||||
const QStringList parts = text.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
||||
if (parts.isEmpty()) {
|
||||
r.kind = ReplyKind::Unknown;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Bare "STANDBY ON" (device-not-ready notice) vs. normal STANDBY value:
|
||||
// both shapes are handled by the generic attribute path below.
|
||||
|
||||
// Detect an echoed instruction (starts with a command keyword).
|
||||
{
|
||||
const QString first = parts.first().toUpper();
|
||||
if (first == QLatin1String("SET") || first == QLatin1String("GET") ||
|
||||
first == QLatin1String("INC") || first == QLatin1String("DEC")) {
|
||||
r.kind = ReplyKind::Echo;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
const Attribute attr = attributeFromToken(parts.first());
|
||||
if (attr == Attribute::Unknown) {
|
||||
r.kind = ReplyKind::Unknown;
|
||||
return r;
|
||||
}
|
||||
r.attribute = attr;
|
||||
r.kind = ReplyKind::Value;
|
||||
|
||||
// Remaining tokens after the attribute name.
|
||||
QStringList rest = parts.mid(1);
|
||||
|
||||
// Optional zone token.
|
||||
if (!rest.isEmpty()) {
|
||||
if (auto z = zoneFromToken(rest.first())) {
|
||||
r.zone = *z;
|
||||
rest.removeFirst();
|
||||
}
|
||||
}
|
||||
// Optional source token (single letter A-D) – only meaningful for
|
||||
// SourceName / InputGain replies.
|
||||
if (!rest.isEmpty() && (attr == Attribute::SourceName || attr == Attribute::InputGain)) {
|
||||
if (auto s = sourceFromToken(rest.first())) {
|
||||
r.source = *s;
|
||||
rest.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
const QString valueStr = rest.join(QLatin1Char(' ')).trimmed();
|
||||
r.textValue = valueStr;
|
||||
|
||||
switch (attr) {
|
||||
case Attribute::MultiZone:
|
||||
case Attribute::ZoneLink:
|
||||
case Attribute::Standby:
|
||||
case Attribute::AutoLoudness:
|
||||
case Attribute::PagingActive:
|
||||
case Attribute::Echo:
|
||||
case Attribute::LineFeed:
|
||||
case Attribute::BackSpace:
|
||||
case Attribute::Header:
|
||||
case Attribute::ValueFeedback:
|
||||
case Attribute::Restore:
|
||||
r.boolValue = (valueStr.toUpper() == QLatin1String("ON") ||
|
||||
valueStr == QLatin1String("1"));
|
||||
r.hasBool = true;
|
||||
break;
|
||||
case Attribute::Select: {
|
||||
if (auto s = sourceFromToken(valueStr))
|
||||
r.source = *s;
|
||||
break;
|
||||
}
|
||||
case Attribute::MusicLevel:
|
||||
case Attribute::MicLevel:
|
||||
case Attribute::MaxMusicLevel:
|
||||
case Attribute::MaxMicLevel: {
|
||||
int lvl = 0;
|
||||
if (parseLevelToken(valueStr, &lvl))
|
||||
r.intValue = lvl;
|
||||
break;
|
||||
}
|
||||
case Attribute::EqBass:
|
||||
case Attribute::EqTreble:
|
||||
case Attribute::InputGain: {
|
||||
bool ok = false;
|
||||
const int v = valueStr.toInt(&ok);
|
||||
if (ok) r.intValue = v;
|
||||
break;
|
||||
}
|
||||
case Attribute::SourceName:
|
||||
case Attribute::Serial:
|
||||
case Attribute::HwVersion:
|
||||
case Attribute::SwVersion:
|
||||
// textValue already holds the payload.
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace Concept1
|
||||
133
src/protocol/Concept1Protocol.h
Normal file
133
src/protocol/Concept1Protocol.h
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <optional>
|
||||
|
||||
// Low-level, stateless helpers for the Apart Concept 1 RS-232 ASCII protocol.
|
||||
//
|
||||
// Port settings (fixed by the device): 19200 baud, 8 data bits, no parity,
|
||||
// 1 stop bit, no flow control. Every instruction ends with <CR> (0x0D).
|
||||
namespace Concept1 {
|
||||
|
||||
constexpr int kBaudRate = 19200;
|
||||
constexpr char kCr = '\r';
|
||||
constexpr char kLf = '\n';
|
||||
|
||||
// Volume levels: -79..0 dB, with -80 meaning "OFF".
|
||||
constexpr int kLevelOff = -80;
|
||||
constexpr int kLevelMin = -79;
|
||||
constexpr int kLevelMax = 0;
|
||||
|
||||
// Equalizer (bass/treble): -14..14 in steps of 2.
|
||||
constexpr int kEqMin = -14;
|
||||
constexpr int kEqMax = 14;
|
||||
constexpr int kEqStep = 2;
|
||||
|
||||
// Input gain: -20..14 in steps of 2.
|
||||
constexpr int kGainMin = -20;
|
||||
constexpr int kGainMax = 14;
|
||||
constexpr int kGainStep = 2;
|
||||
|
||||
enum class Command { Set, Get, Inc, Dec };
|
||||
|
||||
enum class Zone { None, Zone1, Zone2 };
|
||||
|
||||
enum class Source { A, B, C, D };
|
||||
|
||||
// All attributes understood by the Concept 1.
|
||||
enum class Attribute {
|
||||
MultiZone,
|
||||
ZoneLink,
|
||||
MusicLevel, // MSCLVL
|
||||
MicLevel, // MICLVL
|
||||
MaxMusicLevel, // MAXMSCLVL
|
||||
MaxMicLevel, // MAXMICLVL
|
||||
Select, // SELECT A/B/C/D
|
||||
EqBass, // EQBASS
|
||||
EqTreble, // EQTREB
|
||||
Standby, // STANDBY
|
||||
AutoLoudness, // AUTOLD
|
||||
PagingActive, // PAGACT
|
||||
InputGain, // IPGAIN (per source)
|
||||
Echo, // ECHO
|
||||
LineFeed, // LF
|
||||
BackSpace, // BS
|
||||
Header, // HEADER
|
||||
ValueFeedback, // VALFB
|
||||
Info, // INFO (GET only)
|
||||
SourceName, // SOURCENAME (per source)
|
||||
Serial, // SERIAL (GET only)
|
||||
HwVersion, // HWVRSN (GET only)
|
||||
SwVersion, // SWVRSN (GET only)
|
||||
Restore, // RESTORE (SET only)
|
||||
Unknown
|
||||
};
|
||||
|
||||
// Classification of a line received from the device.
|
||||
enum class ReplyKind {
|
||||
Value, // an attribute value, e.g. "MSCLVL ZONE1 -16"
|
||||
Info, // a single line of a GET INFO dump (also Value-shaped)
|
||||
Paging, // "PAGING ZONEx ON/OFF" or "PAGING ON/OFF"
|
||||
Acknowledge, // "Command Executed!" (also accepts the doc typo "Exicuted")
|
||||
PleaseCycle, // "Please Cycle Power!"
|
||||
PowerDown, // "POWER DOWN!"
|
||||
Standby, // bare "STANDBY ON" sent while not ready
|
||||
Error, // "ERROR: ...!"
|
||||
NotImplemented, // "Not Yet Implemented!"
|
||||
Echo, // an echoed instruction (when ECHO is on)
|
||||
Unknown
|
||||
};
|
||||
|
||||
struct ParsedReply {
|
||||
ReplyKind kind = ReplyKind::Unknown;
|
||||
Attribute attribute = Attribute::Unknown;
|
||||
Zone zone = Zone::None;
|
||||
std::optional<Source> source;
|
||||
QString rawText; // full original line (trimmed)
|
||||
QString textValue; // string value (source names, serial, versions, error text)
|
||||
std::optional<int> intValue; // numeric value where applicable (dB / eq / gain)
|
||||
bool boolValue = false; // for ON/OFF attributes
|
||||
bool hasBool = false; // whether boolValue is meaningful
|
||||
};
|
||||
|
||||
// --- String mapping helpers ---------------------------------------------
|
||||
QString attributeToken(Attribute attr); // e.g. "MSCLVL"
|
||||
Attribute attributeFromToken(const QString &token); // case-insensitive
|
||||
QString commandToken(Command cmd);
|
||||
QString zoneToken(Zone zone); // "ZONE1"/"ZONE2"/""
|
||||
QString sourceToken(Source src); // "A".."D"
|
||||
std::optional<Source> sourceFromToken(const QString &token);
|
||||
std::optional<Zone> zoneFromToken(const QString &token);
|
||||
|
||||
// --- Value validation / normalisation -----------------------------------
|
||||
int clampLevel(int value); // -80..0
|
||||
int normalizeEq(int value); // snap to nearest valid -14..14/2
|
||||
int normalizeGain(int value); // snap to nearest valid -20..14/2
|
||||
int clampStep(int step); // 1..10
|
||||
QString levelToDisplay(int level); // "OFF" or "-16 dB"
|
||||
QString levelToken(int level); // "OFF" or "-16" (protocol wire form)
|
||||
|
||||
// --- Command building ----------------------------------------------------
|
||||
// Builds a full instruction terminated with <CR>.
|
||||
QByteArray buildCommand(Command cmd, Attribute attr,
|
||||
Zone zone = Zone::None,
|
||||
std::optional<Source> source = std::nullopt,
|
||||
std::optional<QString> value = std::nullopt);
|
||||
|
||||
// Convenience builders.
|
||||
QByteArray buildGet(Attribute attr, Zone zone = Zone::None,
|
||||
std::optional<Source> source = std::nullopt);
|
||||
QByteArray buildSetBool(Attribute attr, bool on, Zone zone = Zone::None);
|
||||
QByteArray buildSetInt(Attribute attr, int value, Zone zone = Zone::None,
|
||||
std::optional<Source> source = std::nullopt);
|
||||
QByteArray buildSetString(Attribute attr, const QString &value,
|
||||
std::optional<Source> source = std::nullopt);
|
||||
QByteArray buildStep(Command incOrDec, Attribute attr, int step,
|
||||
Zone zone = Zone::None);
|
||||
|
||||
// --- Reply parsing -------------------------------------------------------
|
||||
// Parses a single line (without <CR>/<LF>) received from the device.
|
||||
ParsedReply parseReply(const QString &line);
|
||||
|
||||
} // namespace Concept1
|
||||
73
src/protocol/Concept1State.h
Normal file
73
src/protocol/Concept1State.h
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#pragma once
|
||||
|
||||
#include "Concept1Protocol.h"
|
||||
|
||||
#include <QString>
|
||||
#include <array>
|
||||
|
||||
namespace Concept1 {
|
||||
|
||||
// A snapshot of the full device state, kept in sync by Concept1Controller.
|
||||
struct DeviceState {
|
||||
// Mode
|
||||
bool multiZone = false; // MULTIZONE
|
||||
bool zoneLink = false; // ZONELINK
|
||||
|
||||
// Per-zone operating levels (index 0 = Zone1, 1 = Zone2).
|
||||
// In stereo mode only index 0 is meaningful.
|
||||
std::array<int, 2> musicLevel {{kLevelOff, kLevelOff}};
|
||||
std::array<int, 2> micLevel {{kLevelOff, kLevelOff}};
|
||||
std::array<int, 2> maxMusicLevel {{kLevelMax, kLevelMax}};
|
||||
std::array<int, 2> maxMicLevel {{kLevelMax, kLevelMax}};
|
||||
std::array<bool, 2> pagingActive {{false, false}};
|
||||
std::array<bool, 2> pagingState {{false, false}}; // live PAGING status
|
||||
|
||||
// Global operating settings
|
||||
Source select = Source::A;
|
||||
int eqBass = 0;
|
||||
int eqTreble = 0;
|
||||
bool standby = false;
|
||||
bool autoLoudness = false;
|
||||
|
||||
// Per-source settings (index 0..3 = A..D)
|
||||
std::array<int, 4> inputGain {{0, 0, 0, 0}};
|
||||
std::array<QString, 4> sourceName {{QStringLiteral("Source A"),
|
||||
QStringLiteral("Source B"),
|
||||
QStringLiteral("Source C"),
|
||||
QStringLiteral("Source D")}};
|
||||
|
||||
// RS-232 settings
|
||||
bool echo = false;
|
||||
bool lineFeed = false;
|
||||
bool backSpace = false;
|
||||
bool header = false;
|
||||
bool valueFeedback = true; // default ON
|
||||
|
||||
// Version info
|
||||
QString serial;
|
||||
QString hwVersion;
|
||||
QString swVersion;
|
||||
|
||||
// Derived helpers ----------------------------------------------------
|
||||
// Whether a zone argument must be supplied for level commands.
|
||||
// Per protocol: MSCLVL needs a zone when MULTIZONE && ZONELINK;
|
||||
// MICLVL / PAGACT need a zone when MULTIZONE.
|
||||
bool musicNeedsZone() const { return multiZone && zoneLink; }
|
||||
bool micNeedsZone() const { return multiZone; }
|
||||
|
||||
// True if two independent zones are presented in the UI.
|
||||
bool twoZones() const { return multiZone; }
|
||||
|
||||
static int zoneIndex(Zone z) { return z == Zone::Zone2 ? 1 : 0; }
|
||||
static int sourceIndex(Source s) {
|
||||
switch (s) {
|
||||
case Source::A: return 0;
|
||||
case Source::B: return 1;
|
||||
case Source::C: return 2;
|
||||
case Source::D: return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace Concept1
|
||||
32
src/transport/ITransport.h
Normal file
32
src/transport/ITransport.h
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
// Abstract byte-stream transport to the Concept 1.
|
||||
// Concrete implementations: SerialTransport (real RS-232) and
|
||||
// SimulatorTransport (virtual device). The controller talks only to this
|
||||
// interface, so it is agnostic of where the bytes come from.
|
||||
class ITransport : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ITransport(QObject *parent = nullptr) : QObject(parent) {}
|
||||
~ITransport() override = default;
|
||||
|
||||
virtual bool open() = 0;
|
||||
virtual void close() = 0;
|
||||
virtual bool isOpen() const = 0;
|
||||
virtual void write(const QByteArray &data) = 0;
|
||||
|
||||
// Human-readable name of the endpoint (port name or "Simulator").
|
||||
virtual QString endpointName() const = 0;
|
||||
|
||||
signals:
|
||||
// Emitted for every chunk of raw bytes received from the device.
|
||||
void bytesReceived(const QByteArray &data);
|
||||
// Emitted when the link goes up or down.
|
||||
void openedChanged(bool open);
|
||||
// Emitted on a transport-level error (with a human-readable message).
|
||||
void errorOccurred(const QString &message);
|
||||
};
|
||||
103
src/transport/SerialTransport.cpp
Normal file
103
src/transport/SerialTransport.cpp
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#include "SerialTransport.h"
|
||||
#include "protocol/Concept1Protocol.h"
|
||||
|
||||
#include <QSerialPort>
|
||||
#include <QSerialPortInfo>
|
||||
|
||||
SerialTransport::SerialTransport(const QString &portName, QObject *parent)
|
||||
: ITransport(parent)
|
||||
, m_port(new QSerialPort(this))
|
||||
, m_portName(portName)
|
||||
{
|
||||
m_port->setBaudRate(Concept1::kBaudRate);
|
||||
m_port->setDataBits(QSerialPort::Data8);
|
||||
m_port->setParity(QSerialPort::NoParity);
|
||||
m_port->setStopBits(QSerialPort::OneStop);
|
||||
m_port->setFlowControl(QSerialPort::NoFlowControl);
|
||||
|
||||
connect(m_port, &QSerialPort::readyRead, this, &SerialTransport::onReadyRead);
|
||||
connect(m_port, &QSerialPort::errorOccurred, this,
|
||||
[this](QSerialPort::SerialPortError e) {
|
||||
if (e == QSerialPort::NoError)
|
||||
return;
|
||||
emit errorOccurred(m_port->errorString());
|
||||
if (e == QSerialPort::ResourceError && m_port->isOpen()) {
|
||||
m_port->close();
|
||||
emit openedChanged(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
SerialTransport::~SerialTransport()
|
||||
{
|
||||
if (m_port->isOpen())
|
||||
m_port->close();
|
||||
}
|
||||
|
||||
void SerialTransport::setPortName(const QString &portName)
|
||||
{
|
||||
m_portName = portName;
|
||||
}
|
||||
|
||||
bool SerialTransport::open()
|
||||
{
|
||||
if (m_port->isOpen())
|
||||
return true;
|
||||
m_port->setPortName(m_portName);
|
||||
const bool ok = m_port->open(QIODevice::ReadWrite);
|
||||
if (ok) {
|
||||
m_port->setBaudRate(Concept1::kBaudRate);
|
||||
m_port->setDataBits(QSerialPort::Data8);
|
||||
m_port->setParity(QSerialPort::NoParity);
|
||||
m_port->setStopBits(QSerialPort::OneStop);
|
||||
m_port->setFlowControl(QSerialPort::NoFlowControl);
|
||||
emit openedChanged(true);
|
||||
} else {
|
||||
emit errorOccurred(m_port->errorString());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void SerialTransport::close()
|
||||
{
|
||||
if (m_port->isOpen()) {
|
||||
m_port->close();
|
||||
emit openedChanged(false);
|
||||
}
|
||||
}
|
||||
|
||||
bool SerialTransport::isOpen() const
|
||||
{
|
||||
return m_port->isOpen();
|
||||
}
|
||||
|
||||
void SerialTransport::write(const QByteArray &data)
|
||||
{
|
||||
if (m_port->isOpen())
|
||||
m_port->write(data);
|
||||
}
|
||||
|
||||
QString SerialTransport::endpointName() const
|
||||
{
|
||||
return m_portName;
|
||||
}
|
||||
|
||||
void SerialTransport::onReadyRead()
|
||||
{
|
||||
const QByteArray data = m_port->readAll();
|
||||
if (!data.isEmpty())
|
||||
emit bytesReceived(data);
|
||||
}
|
||||
|
||||
QStringList SerialTransport::availablePorts()
|
||||
{
|
||||
QStringList result;
|
||||
const auto ports = QSerialPortInfo::availablePorts();
|
||||
for (const QSerialPortInfo &info : ports) {
|
||||
QString entry = info.portName();
|
||||
if (!info.description().isEmpty())
|
||||
entry += QStringLiteral(" :: ") + info.description();
|
||||
result << entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
35
src/transport/SerialTransport.h
Normal file
35
src/transport/SerialTransport.h
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#pragma once
|
||||
|
||||
#include "ITransport.h"
|
||||
|
||||
#include <QStringList>
|
||||
|
||||
class QSerialPort;
|
||||
|
||||
// Real RS-232 transport using QtSerialPort.
|
||||
// Fixed line settings per the Concept 1 spec: 19200 8N1, no flow control.
|
||||
// Only compiled when Qt6 SerialPort is available (HAVE_QT_SERIALPORT).
|
||||
class SerialTransport : public ITransport {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SerialTransport(const QString &portName, QObject *parent = nullptr);
|
||||
~SerialTransport() override;
|
||||
|
||||
bool open() override;
|
||||
void close() override;
|
||||
bool isOpen() const override;
|
||||
void write(const QByteArray &data) override;
|
||||
QString endpointName() const override;
|
||||
|
||||
void setPortName(const QString &portName);
|
||||
|
||||
// List the system serial ports as "name :: description".
|
||||
static QStringList availablePorts();
|
||||
|
||||
private slots:
|
||||
void onReadyRead();
|
||||
|
||||
private:
|
||||
QSerialPort *m_port;
|
||||
QString m_portName;
|
||||
};
|
||||
412
src/transport/SimulatorTransport.cpp
Normal file
412
src/transport/SimulatorTransport.cpp
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
#include "SimulatorTransport.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
|
||||
using namespace Concept1;
|
||||
|
||||
SimulatorTransport::SimulatorTransport(QObject *parent)
|
||||
: ITransport(parent)
|
||||
{
|
||||
}
|
||||
|
||||
bool SimulatorTransport::open()
|
||||
{
|
||||
m_open = true;
|
||||
m_inBuf.clear();
|
||||
emit openedChanged(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SimulatorTransport::close()
|
||||
{
|
||||
if (m_open) {
|
||||
m_open = false;
|
||||
emit openedChanged(false);
|
||||
}
|
||||
}
|
||||
|
||||
bool SimulatorTransport::isOpen() const
|
||||
{
|
||||
return m_open;
|
||||
}
|
||||
|
||||
QString SimulatorTransport::endpointName() const
|
||||
{
|
||||
return QStringLiteral("Simulator");
|
||||
}
|
||||
|
||||
void SimulatorTransport::write(const QByteArray &data)
|
||||
{
|
||||
if (!m_open)
|
||||
return;
|
||||
m_inBuf.append(data);
|
||||
// Split on CR; ignore LF entirely.
|
||||
int idx;
|
||||
while ((idx = m_inBuf.indexOf('\r')) >= 0) {
|
||||
QByteArray lineBytes = m_inBuf.left(idx);
|
||||
m_inBuf.remove(0, idx + 1);
|
||||
QString line = QString::fromLatin1(lineBytes);
|
||||
line.remove(QLatin1Char('\n'));
|
||||
line = line.trimmed();
|
||||
if (line.startsWith(QLatin1Char('>')))
|
||||
line = line.mid(1).trimmed();
|
||||
if (m_state.echo) {
|
||||
// Echo back the received instruction (without re-framing header).
|
||||
QString e = line;
|
||||
e += QLatin1Char(kCr);
|
||||
if (m_state.lineFeed) e += QLatin1Char(kLf);
|
||||
const QByteArray bytes = e.toLatin1();
|
||||
QTimer::singleShot(0, this, [this, bytes]() {
|
||||
if (m_open) emit bytesReceived(bytes);
|
||||
});
|
||||
}
|
||||
if (!line.isEmpty())
|
||||
processInstruction(line);
|
||||
}
|
||||
emitQueued();
|
||||
}
|
||||
|
||||
QString SimulatorTransport::frame(const QString &line) const
|
||||
{
|
||||
QString s;
|
||||
if (m_state.header && !m_state.echo)
|
||||
s += QLatin1Char('>');
|
||||
s += line;
|
||||
s += QLatin1Char(kCr);
|
||||
if (m_state.lineFeed)
|
||||
s += QLatin1Char(kLf);
|
||||
return s;
|
||||
}
|
||||
|
||||
void SimulatorTransport::reply(const QString &line)
|
||||
{
|
||||
m_outLines << line;
|
||||
}
|
||||
|
||||
void SimulatorTransport::emitQueued()
|
||||
{
|
||||
if (m_outLines.isEmpty())
|
||||
return;
|
||||
QString out;
|
||||
for (const QString &l : m_outLines)
|
||||
out += frame(l);
|
||||
m_outLines.clear();
|
||||
const QByteArray bytes = out.toLatin1();
|
||||
// Slight async delay mimics serial timing.
|
||||
QTimer::singleShot(5, this, [this, bytes]() {
|
||||
if (m_open)
|
||||
emit bytesReceived(bytes);
|
||||
});
|
||||
}
|
||||
|
||||
QString SimulatorTransport::valueString(Attribute attr, Zone zone) const
|
||||
{
|
||||
const int zi = DeviceState::zoneIndex(zone);
|
||||
switch (attr) {
|
||||
case Attribute::MultiZone: return m_state.multiZone ? "ON" : "OFF";
|
||||
case Attribute::ZoneLink: return m_state.zoneLink ? "ON" : "OFF";
|
||||
case Attribute::MusicLevel: return levelToken(m_state.musicLevel[zi]);
|
||||
case Attribute::MicLevel: return levelToken(m_state.micLevel[zi]);
|
||||
case Attribute::MaxMusicLevel: return levelToken(m_state.maxMusicLevel[zi]);
|
||||
case Attribute::MaxMicLevel: return levelToken(m_state.maxMicLevel[zi]);
|
||||
case Attribute::Select: return sourceToken(m_state.select);
|
||||
case Attribute::EqBass: return QString::number(m_state.eqBass);
|
||||
case Attribute::EqTreble: return QString::number(m_state.eqTreble);
|
||||
case Attribute::Standby: return m_state.standby ? "ON" : "OFF";
|
||||
case Attribute::AutoLoudness: return m_state.autoLoudness ? "ON" : "OFF";
|
||||
case Attribute::PagingActive: return m_state.pagingActive[zi] ? "ON" : "OFF";
|
||||
case Attribute::Echo: return m_state.echo ? "ON" : "OFF";
|
||||
case Attribute::LineFeed: return m_state.lineFeed ? "ON" : "OFF";
|
||||
case Attribute::BackSpace: return m_state.backSpace ? "ON" : "OFF";
|
||||
case Attribute::Header: return m_state.header ? "ON" : "OFF";
|
||||
case Attribute::ValueFeedback: return m_state.valueFeedback ? "ON" : "OFF";
|
||||
default: return QString();
|
||||
}
|
||||
}
|
||||
|
||||
void SimulatorTransport::sendValueFeedback(Attribute attr, Zone zone)
|
||||
{
|
||||
if (!m_state.valueFeedback)
|
||||
return;
|
||||
QString line = attributeToken(attr);
|
||||
if (zone != Zone::None)
|
||||
line += QLatin1Char(' ') + zoneToken(zone);
|
||||
line += QLatin1Char(' ') + valueString(attr, zone);
|
||||
reply(line);
|
||||
}
|
||||
|
||||
void SimulatorTransport::sendInfoDump()
|
||||
{
|
||||
// Mirrors the real GET INFO dump. Zone-qualified when in 2-zone mode.
|
||||
reply(QStringLiteral("PAGING OFF"));
|
||||
reply(QStringLiteral("MULTIZONE ") + (m_state.multiZone ? "ON" : "OFF"));
|
||||
reply(QStringLiteral("ZONELINK ") + (m_state.zoneLink ? "ON" : "OFF"));
|
||||
if (m_state.multiZone) {
|
||||
reply(QStringLiteral("PAGACT ZONE1 ") + (m_state.pagingActive[0] ? "ON" : "OFF"));
|
||||
reply(QStringLiteral("PAGACT ZONE2 ") + (m_state.pagingActive[1] ? "ON" : "OFF"));
|
||||
} else {
|
||||
reply(QStringLiteral("PAGACT ") + (m_state.pagingActive[0] ? "ON" : "OFF"));
|
||||
}
|
||||
reply(QStringLiteral("AUTOLD ") + (m_state.autoLoudness ? "ON" : "OFF"));
|
||||
reply(QStringLiteral("SELECT ") + sourceToken(m_state.select));
|
||||
|
||||
const bool mscZone = m_state.musicNeedsZone();
|
||||
const bool micZone = m_state.micNeedsZone();
|
||||
if (mscZone) {
|
||||
reply("MSCLVL ZONE1 " + levelToken(m_state.musicLevel[0]));
|
||||
reply("MSCLVL ZONE2 " + levelToken(m_state.musicLevel[1]));
|
||||
} else {
|
||||
reply("MSCLVL " + levelToken(m_state.musicLevel[0]));
|
||||
}
|
||||
if (micZone) {
|
||||
reply("MICLVL ZONE1 " + levelToken(m_state.micLevel[0]));
|
||||
reply("MICLVL ZONE2 " + levelToken(m_state.micLevel[1]));
|
||||
} else {
|
||||
reply("MICLVL " + levelToken(m_state.micLevel[0]));
|
||||
}
|
||||
if (mscZone) {
|
||||
reply("MAXMSCLVL ZONE1 " + levelToken(m_state.maxMusicLevel[0]));
|
||||
reply("MAXMSCLVL ZONE2 " + levelToken(m_state.maxMusicLevel[1]));
|
||||
} else {
|
||||
reply("MAXMSCLVL " + levelToken(m_state.maxMusicLevel[0]));
|
||||
}
|
||||
if (micZone) {
|
||||
reply("MAXMICLVL ZONE1 " + levelToken(m_state.maxMicLevel[0]));
|
||||
reply("MAXMICLVL ZONE2 " + levelToken(m_state.maxMicLevel[1]));
|
||||
} else {
|
||||
reply("MAXMICLVL " + levelToken(m_state.maxMicLevel[0]));
|
||||
}
|
||||
reply("EQBASS " + QString::number(m_state.eqBass));
|
||||
reply("EQTREB " + QString::number(m_state.eqTreble));
|
||||
}
|
||||
|
||||
// Parse "OFF"/number into a level; returns false if not parseable.
|
||||
static bool parseLevel(const QString &tok, int *out)
|
||||
{
|
||||
const QString t = tok.trimmed().toUpper();
|
||||
if (t == QLatin1String("OFF")) { *out = kLevelOff; return true; }
|
||||
bool ok = false;
|
||||
const int v = t.toInt(&ok);
|
||||
if (ok) { *out = v; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
void SimulatorTransport::processInstruction(const QString &instruction)
|
||||
{
|
||||
const QStringList parts = instruction.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
||||
if (parts.isEmpty())
|
||||
return;
|
||||
|
||||
const QString cmdTok = parts.first().toUpper();
|
||||
Command cmd;
|
||||
if (cmdTok == QLatin1String("SET")) cmd = Command::Set;
|
||||
else if (cmdTok == QLatin1String("GET")) cmd = Command::Get;
|
||||
else if (cmdTok == QLatin1String("INC")) cmd = Command::Inc;
|
||||
else if (cmdTok == QLatin1String("DEC")) cmd = Command::Dec;
|
||||
else { reply("ERROR: Command Unknown!"); return; }
|
||||
|
||||
if (parts.size() < 2) { reply("ERROR: Unknown Attribute!"); return; }
|
||||
|
||||
const Attribute attr = attributeFromToken(parts.at(1));
|
||||
if (attr == Attribute::Unknown) { reply("ERROR: Unknown Attribute!"); return; }
|
||||
|
||||
QStringList rest = parts.mid(2);
|
||||
|
||||
// Optional source (for IPGAIN/SOURCENAME) comes right after attribute.
|
||||
std::optional<Source> source;
|
||||
if ((attr == Attribute::InputGain || attr == Attribute::SourceName) &&
|
||||
!rest.isEmpty()) {
|
||||
if (auto s = sourceFromToken(rest.first())) {
|
||||
source = *s;
|
||||
rest.removeFirst();
|
||||
}
|
||||
}
|
||||
// Optional zone.
|
||||
Zone zone = Zone::None;
|
||||
if (!rest.isEmpty()) {
|
||||
if (auto z = zoneFromToken(rest.first())) {
|
||||
zone = *z;
|
||||
rest.removeFirst();
|
||||
}
|
||||
}
|
||||
const QString valueStr = rest.join(QLatin1Char(' '));
|
||||
const int zi = DeviceState::zoneIndex(zone);
|
||||
|
||||
auto stepValue = [&]() -> int {
|
||||
bool ok = false;
|
||||
int s = valueStr.trimmed().toInt(&ok);
|
||||
if (!ok) s = 1;
|
||||
return clampStep(s);
|
||||
};
|
||||
|
||||
switch (cmd) {
|
||||
case Command::Get:
|
||||
switch (attr) {
|
||||
case Attribute::Info:
|
||||
sendInfoDump();
|
||||
return;
|
||||
case Attribute::InputGain:
|
||||
if (!source) { reply("ERROR: Value Invalid!"); return; }
|
||||
reply("IPGAIN " + sourceToken(*source) + " " +
|
||||
QString::number(m_state.inputGain[DeviceState::sourceIndex(*source)]));
|
||||
return;
|
||||
case Attribute::SourceName:
|
||||
if (!source) { reply("ERROR: Value Invalid!"); return; }
|
||||
// Real device returns the bare name string.
|
||||
reply(m_state.sourceName[DeviceState::sourceIndex(*source)]);
|
||||
return;
|
||||
case Attribute::Serial:
|
||||
reply(m_state.serial.isEmpty() ? QStringLiteral("1A2B") : m_state.serial);
|
||||
return;
|
||||
case Attribute::HwVersion:
|
||||
reply(m_state.hwVersion.isEmpty() ? QStringLiteral("ACPT 1V05") : m_state.hwVersion);
|
||||
return;
|
||||
case Attribute::SwVersion:
|
||||
reply(m_state.swVersion.isEmpty() ? QStringLiteral("SW 1V12") : m_state.swVersion);
|
||||
return;
|
||||
default: {
|
||||
QString line = attributeToken(attr);
|
||||
if (zone != Zone::None)
|
||||
line += QLatin1Char(' ') + zoneToken(zone);
|
||||
line += QLatin1Char(' ') + valueString(attr, zone);
|
||||
reply(line);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case Command::Set:
|
||||
switch (attr) {
|
||||
case Attribute::MultiZone:
|
||||
m_state.multiZone = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::ZoneLink:
|
||||
m_state.zoneLink = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::MusicLevel: {
|
||||
int lvl; if (!parseLevel(valueStr, &lvl)) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.musicLevel[zi] = clampLevel(lvl);
|
||||
sendValueFeedback(attr, zone);
|
||||
return;
|
||||
}
|
||||
case Attribute::MicLevel: {
|
||||
int lvl; if (!parseLevel(valueStr, &lvl)) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.micLevel[zi] = clampLevel(lvl);
|
||||
sendValueFeedback(attr, zone);
|
||||
return;
|
||||
}
|
||||
case Attribute::MaxMusicLevel: {
|
||||
int lvl; if (!parseLevel(valueStr, &lvl)) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.maxMusicLevel[zi] = clampLevel(lvl);
|
||||
sendValueFeedback(attr, zone);
|
||||
return;
|
||||
}
|
||||
case Attribute::MaxMicLevel: {
|
||||
int lvl; if (!parseLevel(valueStr, &lvl)) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.maxMicLevel[zi] = clampLevel(lvl);
|
||||
sendValueFeedback(attr, zone);
|
||||
return;
|
||||
}
|
||||
case Attribute::Select: {
|
||||
auto s = sourceFromToken(valueStr);
|
||||
if (!s) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.select = *s;
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
}
|
||||
case Attribute::EqBass: {
|
||||
bool ok=false; int v=valueStr.toInt(&ok);
|
||||
if (!ok) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.eqBass = normalizeEq(v);
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
}
|
||||
case Attribute::EqTreble: {
|
||||
bool ok=false; int v=valueStr.toInt(&ok);
|
||||
if (!ok) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.eqTreble = normalizeEq(v);
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
}
|
||||
case Attribute::Standby:
|
||||
m_state.standby = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::AutoLoudness:
|
||||
m_state.autoLoudness = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::PagingActive:
|
||||
m_state.pagingActive[zi] = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr, zone);
|
||||
return;
|
||||
case Attribute::InputGain: {
|
||||
if (!source) { reply("ERROR: Value Invalid!"); return; }
|
||||
bool ok=false; int v=valueStr.toInt(&ok);
|
||||
if (!ok) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.inputGain[DeviceState::sourceIndex(*source)] = normalizeGain(v);
|
||||
reply("Command Executed!"); // IPGAIN is not auto-fed back
|
||||
return;
|
||||
}
|
||||
case Attribute::Echo:
|
||||
m_state.echo = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::LineFeed:
|
||||
m_state.lineFeed = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::BackSpace:
|
||||
m_state.backSpace = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::Header:
|
||||
m_state.header = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::ValueFeedback:
|
||||
m_state.valueFeedback = (valueStr.toUpper() == "ON" || valueStr == "1");
|
||||
// Always acknowledge the change itself.
|
||||
sendValueFeedback(attr);
|
||||
return;
|
||||
case Attribute::SourceName: {
|
||||
if (!source) { reply("ERROR: Value Invalid!"); return; }
|
||||
m_state.sourceName[DeviceState::sourceIndex(*source)] = valueStr;
|
||||
reply("Command Executed!");
|
||||
return;
|
||||
}
|
||||
case Attribute::Restore:
|
||||
if (valueStr.toUpper() == "ON" || valueStr == "1") {
|
||||
m_state = DeviceState{}; // factory defaults
|
||||
reply("Please Cycle Power!");
|
||||
} else {
|
||||
reply("ERROR: Value Invalid!");
|
||||
}
|
||||
return;
|
||||
default:
|
||||
reply("ERROR: Unknown Instruction!");
|
||||
return;
|
||||
}
|
||||
|
||||
case Command::Inc:
|
||||
case Command::Dec: {
|
||||
const int delta = (cmd == Command::Inc ? 1 : -1) * stepValue();
|
||||
int *target = nullptr;
|
||||
switch (attr) {
|
||||
case Attribute::MusicLevel: target = &m_state.musicLevel[zi]; break;
|
||||
case Attribute::MicLevel: target = &m_state.micLevel[zi]; break;
|
||||
case Attribute::MaxMusicLevel: target = &m_state.maxMusicLevel[zi]; break;
|
||||
case Attribute::MaxMicLevel: target = &m_state.maxMicLevel[zi]; break;
|
||||
default: reply("ERROR: Unknown Instruction!"); return;
|
||||
}
|
||||
int base = *target;
|
||||
if (base <= kLevelOff) base = kLevelOff; // OFF acts as -80 for stepping
|
||||
int next = base + delta;
|
||||
*target = clampLevel(next);
|
||||
sendValueFeedback(attr, zone);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/transport/SimulatorTransport.h
Normal file
39
src/transport/SimulatorTransport.h
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#pragma once
|
||||
|
||||
#include "ITransport.h"
|
||||
#include "protocol/Concept1State.h"
|
||||
|
||||
#include <QByteArray>
|
||||
|
||||
// A fully in-memory virtual Concept 1. It interprets incoming ASCII
|
||||
// instructions exactly like the real amplifier and replies with value
|
||||
// feedback / acknowledgements / errors, so the whole GUI can be exercised
|
||||
// without any hardware attached.
|
||||
class SimulatorTransport : public ITransport {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SimulatorTransport(QObject *parent = nullptr);
|
||||
~SimulatorTransport() override = default;
|
||||
|
||||
bool open() override;
|
||||
void close() override;
|
||||
bool isOpen() const override;
|
||||
void write(const QByteArray &data) override;
|
||||
QString endpointName() const override;
|
||||
|
||||
private:
|
||||
void processInstruction(const QString &instruction);
|
||||
void reply(const QString &line); // queue one reply line
|
||||
void emitQueued(); // flush queued lines as bytes
|
||||
QString frame(const QString &line) const; // apply header/CR/LF framing
|
||||
|
||||
void sendValueFeedback(Concept1::Attribute attr,
|
||||
Concept1::Zone zone = Concept1::Zone::None);
|
||||
QString valueString(Concept1::Attribute attr, Concept1::Zone zone) const;
|
||||
void sendInfoDump();
|
||||
|
||||
Concept1::DeviceState m_state;
|
||||
bool m_open = false;
|
||||
QByteArray m_inBuf;
|
||||
QStringList m_outLines;
|
||||
};
|
||||
752
src/ui/MainWindow.cpp
Normal file
752
src/ui/MainWindow.cpp
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
#include "MainWindow.h"
|
||||
|
||||
#include "core/Concept1Controller.h"
|
||||
#include "ui/ThemeManager.h"
|
||||
#include "transport/SimulatorTransport.h"
|
||||
#ifdef HAVE_QT_SERIALPORT
|
||||
#include "transport/SerialTransport.h"
|
||||
#endif
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDateTime>
|
||||
#include <QFormLayout>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QSlider>
|
||||
#include <QSpinBox>
|
||||
#include <QStatusBar>
|
||||
#include <QStyle>
|
||||
#include <QTabWidget>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
using namespace Concept1;
|
||||
|
||||
namespace {
|
||||
constexpr int kTransportSimulator = 0;
|
||||
constexpr int kTransportSerial = 1;
|
||||
|
||||
QString sourceLetter(int idx) { return QString(QChar('A' + idx)); }
|
||||
}
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("Apart Concept 1 — RS232 Controller"));
|
||||
resize(880, 720);
|
||||
|
||||
m_controller = new Concept1Controller(this);
|
||||
m_theme = new ThemeManager(this);
|
||||
|
||||
auto *central = new QWidget(this);
|
||||
auto *root = new QVBoxLayout(central);
|
||||
root->setContentsMargins(14, 14, 14, 14);
|
||||
root->setSpacing(12);
|
||||
|
||||
root->addWidget(buildConnectionBar());
|
||||
|
||||
auto *tabs = new QTabWidget(this);
|
||||
tabs->addTab(buildControlTab(), QStringLiteral("Steuerung"));
|
||||
tabs->addTab(buildConfigTab(), QStringLiteral("Konfiguration"));
|
||||
tabs->addTab(buildSystemTab(), QStringLiteral("RS232 / System"));
|
||||
tabs->addTab(buildTerminalTab(), QStringLiteral("Terminal"));
|
||||
root->addWidget(tabs, 1);
|
||||
|
||||
setCentralWidget(central);
|
||||
statusBar()->showMessage(QStringLiteral("Bereit."));
|
||||
|
||||
wireController();
|
||||
|
||||
m_theme->loadSaved();
|
||||
connect(m_theme, &ThemeManager::themeChanged, this, [this](ThemeManager::Theme) {
|
||||
m_themeBtn->setText(m_theme->isDark()
|
||||
? QStringLiteral("\u2600 Light")
|
||||
: QStringLiteral("\u263E Dark"));
|
||||
});
|
||||
m_themeBtn->setText(m_theme->isDark()
|
||||
? QStringLiteral("\u2600 Light")
|
||||
: QStringLiteral("\u263E Dark"));
|
||||
|
||||
onRefreshPorts();
|
||||
onTransportKindChanged();
|
||||
updateEnabledStates();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() = default;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
QWidget *MainWindow::buildConnectionBar()
|
||||
{
|
||||
auto *bar = new QGroupBox(this);
|
||||
auto *lay = new QHBoxLayout(bar);
|
||||
lay->setContentsMargins(12, 8, 12, 8);
|
||||
lay->setSpacing(8);
|
||||
|
||||
auto *title = new QLabel(QStringLiteral("Apart Concept 1"));
|
||||
title->setObjectName(QStringLiteral("title"));
|
||||
lay->addWidget(title);
|
||||
lay->addSpacing(12);
|
||||
|
||||
m_transportCombo = new QComboBox(this);
|
||||
m_transportCombo->addItem(QStringLiteral("Simulator"));
|
||||
#ifdef HAVE_QT_SERIALPORT
|
||||
m_transportCombo->addItem(QStringLiteral("Serielle Schnittstelle"));
|
||||
#else
|
||||
m_transportCombo->addItem(QStringLiteral("Seriell (nicht verfügbar)"));
|
||||
#endif
|
||||
lay->addWidget(m_transportCombo);
|
||||
|
||||
m_portCombo = new QComboBox(this);
|
||||
m_portCombo->setMinimumWidth(220);
|
||||
lay->addWidget(m_portCombo, 1);
|
||||
|
||||
m_refreshPortsBtn = new QPushButton(this);
|
||||
m_refreshPortsBtn->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
|
||||
m_refreshPortsBtn->setFixedWidth(40);
|
||||
m_refreshPortsBtn->setToolTip(QStringLiteral("Portliste aktualisieren"));
|
||||
lay->addWidget(m_refreshPortsBtn);
|
||||
|
||||
m_connectBtn = new QPushButton(QStringLiteral("Verbinden"), this);
|
||||
m_connectBtn->setObjectName(QStringLiteral("accent"));
|
||||
lay->addWidget(m_connectBtn);
|
||||
|
||||
m_connStatus = new QLabel(QStringLiteral("Getrennt"), this);
|
||||
m_connStatus->setObjectName(QStringLiteral("statusBad"));
|
||||
lay->addWidget(m_connStatus);
|
||||
|
||||
lay->addStretch(1);
|
||||
|
||||
m_themeBtn = new QPushButton(this);
|
||||
lay->addWidget(m_themeBtn);
|
||||
|
||||
connect(m_transportCombo, &QComboBox::currentIndexChanged,
|
||||
this, &MainWindow::onTransportKindChanged);
|
||||
connect(m_refreshPortsBtn, &QPushButton::clicked, this, &MainWindow::onRefreshPorts);
|
||||
connect(m_connectBtn, &QPushButton::clicked, this, &MainWindow::onConnectClicked);
|
||||
connect(m_themeBtn, &QPushButton::clicked, this, &MainWindow::onThemeToggle);
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
QWidget *MainWindow::buildControlTab()
|
||||
{
|
||||
auto *page = new QWidget(this);
|
||||
auto *lay = new QVBoxLayout(page);
|
||||
lay->setSpacing(12);
|
||||
|
||||
// Top row: standby + mode + source
|
||||
auto *topBox = new QGroupBox(QStringLiteral("Allgemein"), this);
|
||||
auto *topLay = new QHBoxLayout(topBox);
|
||||
|
||||
m_standbyCheck = new QCheckBox(QStringLiteral("Standby"), this);
|
||||
topLay->addWidget(m_standbyCheck);
|
||||
topLay->addSpacing(16);
|
||||
|
||||
m_modeLabel = new QLabel(QStringLiteral("Stereo-Modus"), this);
|
||||
m_modeLabel->setObjectName(QStringLiteral("valueBadge"));
|
||||
topLay->addWidget(m_modeLabel);
|
||||
topLay->addStretch(1);
|
||||
|
||||
topLay->addWidget(new QLabel(QStringLiteral("Quelle:"), this));
|
||||
m_sourceCombo = new QComboBox(this);
|
||||
for (int i = 0; i < 4; ++i)
|
||||
m_sourceCombo->addItem(QStringLiteral("Source %1").arg(sourceLetter(i)));
|
||||
m_sourceCombo->setMinimumWidth(180);
|
||||
topLay->addWidget(m_sourceCombo);
|
||||
lay->addWidget(topBox);
|
||||
|
||||
// Zone boxes
|
||||
auto *zonesRow = new QHBoxLayout();
|
||||
for (int z = 0; z < 2; ++z) {
|
||||
auto *box = new QGroupBox(z == 0 ? QStringLiteral("Zone 1")
|
||||
: QStringLiteral("Zone 2"), this);
|
||||
m_zoneBox[z] = box;
|
||||
auto *g = new QGridLayout(box);
|
||||
g->setVerticalSpacing(8);
|
||||
|
||||
g->addWidget(new QLabel(QStringLiteral("Musik")), 0, 0);
|
||||
m_musicSlider[z] = new QSlider(Qt::Horizontal, this);
|
||||
m_musicSlider[z]->setRange(kLevelOff, kLevelMax);
|
||||
g->addWidget(m_musicSlider[z], 0, 1);
|
||||
m_musicLabel[z] = new QLabel(QStringLiteral("OFF"), this);
|
||||
m_musicLabel[z]->setObjectName(QStringLiteral("valueBadge"));
|
||||
m_musicLabel[z]->setMinimumWidth(64);
|
||||
m_musicLabel[z]->setAlignment(Qt::AlignCenter);
|
||||
g->addWidget(m_musicLabel[z], 0, 2);
|
||||
m_musicMute[z] = new QCheckBox(QStringLiteral("Mute"), this);
|
||||
g->addWidget(m_musicMute[z], 0, 3);
|
||||
|
||||
g->addWidget(new QLabel(QStringLiteral("Mikrofon")), 1, 0);
|
||||
m_micSlider[z] = new QSlider(Qt::Horizontal, this);
|
||||
m_micSlider[z]->setRange(kLevelOff, kLevelMax);
|
||||
g->addWidget(m_micSlider[z], 1, 1);
|
||||
m_micLabel[z] = new QLabel(QStringLiteral("OFF"), this);
|
||||
m_micLabel[z]->setObjectName(QStringLiteral("valueBadge"));
|
||||
m_micLabel[z]->setMinimumWidth(64);
|
||||
m_micLabel[z]->setAlignment(Qt::AlignCenter);
|
||||
g->addWidget(m_micLabel[z], 1, 2);
|
||||
|
||||
zonesRow->addWidget(box);
|
||||
|
||||
const Zone zoneEnum = (z == 0) ? Zone::Zone1 : Zone::Zone2;
|
||||
|
||||
connect(m_musicSlider[z], &QSlider::valueChanged, this, [this, z, zoneEnum](int v) {
|
||||
m_musicLabel[z]->setText(levelToDisplay(v));
|
||||
if (m_syncing) return;
|
||||
if (m_musicMute[z]->isChecked() && v > kLevelOff) {
|
||||
QSignalBlocker b(m_musicMute[z]);
|
||||
m_musicMute[z]->setChecked(false);
|
||||
}
|
||||
m_controller->setMusicLevel(zoneEnum, v);
|
||||
});
|
||||
connect(m_musicMute[z], &QCheckBox::toggled, this, [this, z, zoneEnum](bool on) {
|
||||
if (m_syncing) return;
|
||||
if (on) {
|
||||
m_controller->setMusicLevel(zoneEnum, kLevelOff);
|
||||
} else {
|
||||
int v = m_musicSlider[z]->value();
|
||||
if (v <= kLevelOff) v = -40;
|
||||
m_controller->setMusicLevel(zoneEnum, v);
|
||||
}
|
||||
});
|
||||
connect(m_micSlider[z], &QSlider::valueChanged, this, [this, z, zoneEnum](int v) {
|
||||
m_micLabel[z]->setText(levelToDisplay(v));
|
||||
if (m_syncing) return;
|
||||
m_controller->setMicLevel(zoneEnum, v);
|
||||
});
|
||||
}
|
||||
lay->addLayout(zonesRow);
|
||||
|
||||
// Equalizer + loudness
|
||||
auto *eqBox = new QGroupBox(QStringLiteral("Klang"), this);
|
||||
auto *eqLay = new QHBoxLayout(eqBox);
|
||||
eqLay->addWidget(new QLabel(QStringLiteral("Bass:")));
|
||||
m_bassSpin = new QSpinBox(this);
|
||||
m_bassSpin->setRange(kEqMin, kEqMax);
|
||||
m_bassSpin->setSingleStep(kEqStep);
|
||||
m_bassSpin->setSuffix(QStringLiteral(" dB"));
|
||||
eqLay->addWidget(m_bassSpin);
|
||||
eqLay->addSpacing(16);
|
||||
eqLay->addWidget(new QLabel(QStringLiteral("Höhen:")));
|
||||
m_trebleSpin = new QSpinBox(this);
|
||||
m_trebleSpin->setRange(kEqMin, kEqMax);
|
||||
m_trebleSpin->setSingleStep(kEqStep);
|
||||
m_trebleSpin->setSuffix(QStringLiteral(" dB"));
|
||||
eqLay->addWidget(m_trebleSpin);
|
||||
eqLay->addSpacing(24);
|
||||
m_autoLoudCheck = new QCheckBox(QStringLiteral("Auto-Loudness (nur Stereo)"), this);
|
||||
eqLay->addWidget(m_autoLoudCheck);
|
||||
eqLay->addStretch(1);
|
||||
lay->addWidget(eqBox);
|
||||
|
||||
lay->addStretch(1);
|
||||
|
||||
// wiring
|
||||
connect(m_standbyCheck, &QCheckBox::toggled, this, [this](bool on) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setStandby(on);
|
||||
});
|
||||
connect(m_sourceCombo, &QComboBox::currentIndexChanged, this, [this](int idx) {
|
||||
if (m_syncing || idx < 0) return;
|
||||
m_controller->selectSource(static_cast<Source>(idx));
|
||||
});
|
||||
connect(m_bassSpin, &QSpinBox::valueChanged, this, [this](int v) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setEqBass(v);
|
||||
});
|
||||
connect(m_trebleSpin, &QSpinBox::valueChanged, this, [this](int v) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setEqTreble(v);
|
||||
});
|
||||
connect(m_autoLoudCheck, &QCheckBox::toggled, this, [this](bool on) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setAutoLoudness(on);
|
||||
});
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
QWidget *MainWindow::buildConfigTab()
|
||||
{
|
||||
auto *page = new QWidget(this);
|
||||
auto *outer = new QVBoxLayout(page);
|
||||
|
||||
// Mode
|
||||
auto *modeBox = new QGroupBox(QStringLiteral("Betriebsmodus"), this);
|
||||
auto *modeLay = new QHBoxLayout(modeBox);
|
||||
m_multiZoneCheck = new QCheckBox(QStringLiteral("Multizone (2 Zonen)"), this);
|
||||
m_zoneLinkCheck = new QCheckBox(QStringLiteral("Zonen verknüpfen (Zone-Link)"), this);
|
||||
modeLay->addWidget(m_multiZoneCheck);
|
||||
modeLay->addSpacing(20);
|
||||
modeLay->addWidget(m_zoneLinkCheck);
|
||||
modeLay->addStretch(1);
|
||||
outer->addWidget(modeBox);
|
||||
|
||||
// Max levels + paging per zone
|
||||
auto *zoneRow = new QHBoxLayout();
|
||||
for (int z = 0; z < 2; ++z) {
|
||||
auto *box = new QGroupBox(z == 0 ? QStringLiteral("Zone 1")
|
||||
: QStringLiteral("Zone 2"), this);
|
||||
auto *form = new QFormLayout(box);
|
||||
m_maxMusicSpin[z] = new QSpinBox(this);
|
||||
m_maxMusicSpin[z]->setRange(kLevelOff, kLevelMax);
|
||||
m_maxMusicSpin[z]->setSpecialValueText(QStringLiteral("OFF"));
|
||||
m_maxMusicSpin[z]->setSuffix(QStringLiteral(" dB"));
|
||||
form->addRow(QStringLiteral("Max. Musik:"), m_maxMusicSpin[z]);
|
||||
|
||||
m_maxMicSpin[z] = new QSpinBox(this);
|
||||
m_maxMicSpin[z]->setRange(kLevelOff, kLevelMax);
|
||||
m_maxMicSpin[z]->setSpecialValueText(QStringLiteral("OFF"));
|
||||
m_maxMicSpin[z]->setSuffix(QStringLiteral(" dB"));
|
||||
form->addRow(QStringLiteral("Max. Mikrofon:"), m_maxMicSpin[z]);
|
||||
|
||||
m_pagActiveCheck[z] = new QCheckBox(QStringLiteral("Paging aktiv"), this);
|
||||
form->addRow(QString(), m_pagActiveCheck[z]);
|
||||
|
||||
m_pagStateLabel[z] = new QLabel(QStringLiteral("Paging: inaktiv"), this);
|
||||
m_pagStateLabel[z]->setObjectName(QStringLiteral("subtle"));
|
||||
form->addRow(QString(), m_pagStateLabel[z]);
|
||||
|
||||
zoneRow->addWidget(box);
|
||||
|
||||
const Zone zoneEnum = (z == 0) ? Zone::Zone1 : Zone::Zone2;
|
||||
connect(m_maxMusicSpin[z], &QSpinBox::valueChanged, this, [this, zoneEnum](int v) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setMaxMusicLevel(zoneEnum, v);
|
||||
});
|
||||
connect(m_maxMicSpin[z], &QSpinBox::valueChanged, this, [this, zoneEnum](int v) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setMaxMicLevel(zoneEnum, v);
|
||||
});
|
||||
connect(m_pagActiveCheck[z], &QCheckBox::toggled, this, [this, zoneEnum](bool on) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setPagingActive(zoneEnum, on);
|
||||
});
|
||||
}
|
||||
outer->addLayout(zoneRow);
|
||||
|
||||
// Input gain + source names
|
||||
auto *srcBox = new QGroupBox(QStringLiteral("Eingänge (A–D)"), this);
|
||||
auto *grid = new QGridLayout(srcBox);
|
||||
grid->addWidget(new QLabel(QStringLiteral("<b>Quelle</b>")), 0, 0);
|
||||
grid->addWidget(new QLabel(QStringLiteral("<b>Name</b>")), 0, 1);
|
||||
grid->addWidget(new QLabel(QStringLiteral("<b>Eingangs-Gain</b>")), 0, 2);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
grid->addWidget(new QLabel(sourceLetter(i)), i + 1, 0);
|
||||
m_nameEdit[i] = new QLineEdit(this);
|
||||
m_nameEdit[i]->setPlaceholderText(QStringLiteral("Source %1").arg(sourceLetter(i)));
|
||||
grid->addWidget(m_nameEdit[i], i + 1, 1);
|
||||
m_gainSpin[i] = new QSpinBox(this);
|
||||
m_gainSpin[i]->setRange(kGainMin, kGainMax);
|
||||
m_gainSpin[i]->setSingleStep(kGainStep);
|
||||
m_gainSpin[i]->setSuffix(QStringLiteral(" dB"));
|
||||
grid->addWidget(m_gainSpin[i], i + 1, 2);
|
||||
|
||||
const Source s = static_cast<Source>(i);
|
||||
connect(m_gainSpin[i], &QSpinBox::valueChanged, this, [this, s](int v) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setInputGain(s, v);
|
||||
});
|
||||
connect(m_nameEdit[i], &QLineEdit::editingFinished, this, [this, i, s]() {
|
||||
if (m_syncing) return;
|
||||
const QString name = m_nameEdit[i]->text();
|
||||
if (!name.isEmpty())
|
||||
m_controller->setSourceName(s, name);
|
||||
});
|
||||
}
|
||||
grid->setColumnStretch(1, 1);
|
||||
outer->addWidget(srcBox);
|
||||
outer->addStretch(1);
|
||||
|
||||
connect(m_multiZoneCheck, &QCheckBox::toggled, this, [this](bool on) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setMultiZone(on);
|
||||
});
|
||||
connect(m_zoneLinkCheck, &QCheckBox::toggled, this, [this](bool on) {
|
||||
if (m_syncing) return;
|
||||
m_controller->setZoneLink(on);
|
||||
});
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
QWidget *MainWindow::buildSystemTab()
|
||||
{
|
||||
auto *page = new QWidget(this);
|
||||
auto *outer = new QVBoxLayout(page);
|
||||
|
||||
auto *rsBox = new QGroupBox(QStringLiteral("RS-232 Einstellungen"), this);
|
||||
auto *rsLay = new QVBoxLayout(rsBox);
|
||||
m_echoCheck = new QCheckBox(QStringLiteral("ECHO — empfangene Zeichen zurücksenden"), this);
|
||||
m_lfCheck = new QCheckBox(QStringLiteral("LF — Line Feed nach jedem CR"), this);
|
||||
m_bsCheck = new QCheckBox(QStringLiteral("BS — Backspace-Behandlung"), this);
|
||||
m_headerCheck = new QCheckBox(QStringLiteral("HEADER — Strings mit \">\" einleiten"), this);
|
||||
m_valfbCheck = new QCheckBox(QStringLiteral("VALFB — Value Feedback (Statusabgleich)"), this);
|
||||
rsLay->addWidget(m_echoCheck);
|
||||
rsLay->addWidget(m_lfCheck);
|
||||
rsLay->addWidget(m_bsCheck);
|
||||
rsLay->addWidget(m_headerCheck);
|
||||
rsLay->addWidget(m_valfbCheck);
|
||||
outer->addWidget(rsBox);
|
||||
|
||||
auto *infoBox = new QGroupBox(QStringLiteral("Geräteinformation"), this);
|
||||
auto *infoLay = new QFormLayout(infoBox);
|
||||
m_serialLabel = new QLabel(QStringLiteral("—"), this);
|
||||
m_hwLabel = new QLabel(QStringLiteral("—"), this);
|
||||
m_swLabel = new QLabel(QStringLiteral("—"), this);
|
||||
infoLay->addRow(QStringLiteral("Seriennummer:"), m_serialLabel);
|
||||
infoLay->addRow(QStringLiteral("Hardware-Version:"), m_hwLabel);
|
||||
infoLay->addRow(QStringLiteral("Software-Version:"), m_swLabel);
|
||||
auto *refreshInfoBtn = new QPushButton(QStringLiteral("Aktualisieren"), this);
|
||||
infoLay->addRow(QString(), refreshInfoBtn);
|
||||
outer->addWidget(infoBox);
|
||||
|
||||
auto *resetBox = new QGroupBox(QStringLiteral("Werkseinstellungen"), this);
|
||||
auto *resetLay = new QHBoxLayout(resetBox);
|
||||
auto *restoreBtn = new QPushButton(QStringLiteral("Werksreset (RESTORE)"), this);
|
||||
restoreBtn->setObjectName(QStringLiteral("danger"));
|
||||
resetLay->addWidget(restoreBtn);
|
||||
resetLay->addWidget(new QLabel(
|
||||
QStringLiteral("Setzt alle Einstellungen zurück. Danach Gerät aus-/einschalten.")));
|
||||
resetLay->addStretch(1);
|
||||
outer->addWidget(resetBox);
|
||||
outer->addStretch(1);
|
||||
|
||||
auto bindBool = [this](QCheckBox *cb, void (Concept1Controller::*fn)(bool)) {
|
||||
connect(cb, &QCheckBox::toggled, this, [this, fn](bool on) {
|
||||
if (m_syncing) return;
|
||||
(m_controller->*fn)(on);
|
||||
});
|
||||
};
|
||||
bindBool(m_echoCheck, &Concept1Controller::setEcho);
|
||||
bindBool(m_lfCheck, &Concept1Controller::setLineFeed);
|
||||
bindBool(m_bsCheck, &Concept1Controller::setBackSpace);
|
||||
bindBool(m_headerCheck, &Concept1Controller::setHeader);
|
||||
bindBool(m_valfbCheck, &Concept1Controller::setValueFeedback);
|
||||
|
||||
connect(refreshInfoBtn, &QPushButton::clicked, this, [this]() {
|
||||
m_controller->refreshVersions();
|
||||
m_controller->refreshInfo();
|
||||
});
|
||||
connect(restoreBtn, &QPushButton::clicked, this, [this]() {
|
||||
const auto r = QMessageBox::warning(this, QStringLiteral("Werksreset"),
|
||||
QStringLiteral("Wirklich alle Einstellungen auf Werkszustand zurücksetzen?"),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
||||
if (r == QMessageBox::Yes)
|
||||
m_controller->restoreFactory();
|
||||
});
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
QWidget *MainWindow::buildTerminalTab()
|
||||
{
|
||||
auto *page = new QWidget(this);
|
||||
auto *lay = new QVBoxLayout(page);
|
||||
|
||||
m_terminal = new QPlainTextEdit(this);
|
||||
m_terminal->setObjectName(QStringLiteral("terminal"));
|
||||
m_terminal->setReadOnly(true);
|
||||
m_terminal->setMaximumBlockCount(2000);
|
||||
lay->addWidget(m_terminal, 1);
|
||||
|
||||
auto *row = new QHBoxLayout();
|
||||
m_cmdEdit = new QLineEdit(this);
|
||||
m_cmdEdit->setPlaceholderText(QStringLiteral("Rohbefehl, z.B. get info oder set msclvl -20"));
|
||||
row->addWidget(m_cmdEdit, 1);
|
||||
auto *sendBtn = new QPushButton(QStringLiteral("Senden"), this);
|
||||
sendBtn->setObjectName(QStringLiteral("accent"));
|
||||
row->addWidget(sendBtn);
|
||||
auto *clearBtn = new QPushButton(QStringLiteral("Leeren"), this);
|
||||
row->addWidget(clearBtn);
|
||||
lay->addLayout(row);
|
||||
|
||||
auto sendCmd = [this]() {
|
||||
const QString cmd = m_cmdEdit->text().trimmed();
|
||||
if (cmd.isEmpty()) return;
|
||||
m_controller->sendRaw(cmd);
|
||||
m_cmdEdit->clear();
|
||||
};
|
||||
connect(sendBtn, &QPushButton::clicked, this, sendCmd);
|
||||
connect(m_cmdEdit, &QLineEdit::returnPressed, this, sendCmd);
|
||||
connect(clearBtn, &QPushButton::clicked, this, [this]() { m_terminal->clear(); });
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void MainWindow::wireController()
|
||||
{
|
||||
connect(m_controller, &Concept1Controller::connectionChanged,
|
||||
this, &MainWindow::onConnectionChanged);
|
||||
connect(m_controller, &Concept1Controller::modeChanged,
|
||||
this, &MainWindow::onModeChanged);
|
||||
connect(m_controller, &Concept1Controller::stateChanged,
|
||||
this, &MainWindow::onStateChanged);
|
||||
connect(m_controller, &Concept1Controller::errorReceived,
|
||||
this, &MainWindow::onError);
|
||||
connect(m_controller, &Concept1Controller::notice,
|
||||
this, &MainWindow::onNotice);
|
||||
connect(m_controller, &Concept1Controller::lineSent,
|
||||
this, &MainWindow::onLineSent);
|
||||
connect(m_controller, &Concept1Controller::lineReceived,
|
||||
this, &MainWindow::onLineReceived);
|
||||
connect(m_controller, &Concept1Controller::pagingStateChanged,
|
||||
this, [this](Zone z, bool active) {
|
||||
const int zi = DeviceState::zoneIndex(z);
|
||||
m_pagStateLabel[zi]->setText(active ? QStringLiteral("Paging: AKTIV")
|
||||
: QStringLiteral("Paging: inaktiv"));
|
||||
m_pagStateLabel[zi]->setObjectName(active ? QStringLiteral("statusOk")
|
||||
: QStringLiteral("subtle"));
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::onTransportKindChanged()
|
||||
{
|
||||
const bool serial = (m_transportCombo->currentIndex() == kTransportSerial);
|
||||
m_portCombo->setEnabled(serial);
|
||||
m_refreshPortsBtn->setEnabled(serial);
|
||||
#ifndef HAVE_QT_SERIALPORT
|
||||
if (serial) {
|
||||
m_connectBtn->setEnabled(false);
|
||||
statusBar()->showMessage(
|
||||
QStringLiteral("Qt6 SerialPort nicht installiert — bitte 'qt6-serialport' "
|
||||
"installieren. Simulator ist verfügbar."));
|
||||
} else {
|
||||
m_connectBtn->setEnabled(true);
|
||||
statusBar()->showMessage(QStringLiteral("Bereit."));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void MainWindow::onRefreshPorts()
|
||||
{
|
||||
m_portCombo->clear();
|
||||
#ifdef HAVE_QT_SERIALPORT
|
||||
const QStringList ports = SerialTransport::availablePorts();
|
||||
if (ports.isEmpty()) {
|
||||
m_portCombo->addItem(QStringLiteral("(keine Ports gefunden)"));
|
||||
m_portCombo->setEnabled(false);
|
||||
} else {
|
||||
for (const QString &p : ports)
|
||||
m_portCombo->addItem(p);
|
||||
}
|
||||
#else
|
||||
m_portCombo->addItem(QStringLiteral("(SerialPort nicht verfügbar)"));
|
||||
m_portCombo->setEnabled(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
void MainWindow::onConnectClicked()
|
||||
{
|
||||
if (m_controller->isConnected()) {
|
||||
m_controller->disconnectDevice();
|
||||
return;
|
||||
}
|
||||
|
||||
ITransport *transport = nullptr;
|
||||
if (m_transportCombo->currentIndex() == kTransportSerial) {
|
||||
#ifdef HAVE_QT_SERIALPORT
|
||||
QString port = m_portCombo->currentText();
|
||||
const int sep = port.indexOf(QStringLiteral(" :: "));
|
||||
if (sep > 0) port = port.left(sep);
|
||||
if (port.isEmpty() || port.startsWith(QLatin1Char('('))) {
|
||||
QMessageBox::warning(this, QStringLiteral("Kein Port"),
|
||||
QStringLiteral("Bitte eine gültige serielle Schnittstelle wählen."));
|
||||
return;
|
||||
}
|
||||
transport = new SerialTransport(port);
|
||||
#else
|
||||
QMessageBox::warning(this, QStringLiteral("Nicht verfügbar"),
|
||||
QStringLiteral("Serielle Unterstützung wurde nicht einkompiliert."));
|
||||
return;
|
||||
#endif
|
||||
} else {
|
||||
transport = new SimulatorTransport();
|
||||
}
|
||||
|
||||
m_controller->setTransport(transport);
|
||||
if (!m_controller->connectDevice()) {
|
||||
statusBar()->showMessage(QStringLiteral("Verbindung fehlgeschlagen."));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onThemeToggle()
|
||||
{
|
||||
m_theme->toggle();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void MainWindow::onConnectionChanged(bool connected, const QString &endpoint)
|
||||
{
|
||||
if (connected) {
|
||||
m_connStatus->setText(QStringLiteral("Verbunden: %1").arg(endpoint));
|
||||
m_connStatus->setObjectName(QStringLiteral("statusOk"));
|
||||
m_connectBtn->setText(QStringLiteral("Trennen"));
|
||||
statusBar()->showMessage(QStringLiteral("Verbunden mit %1").arg(endpoint));
|
||||
} else {
|
||||
m_connStatus->setText(QStringLiteral("Getrennt"));
|
||||
m_connStatus->setObjectName(QStringLiteral("statusBad"));
|
||||
m_connectBtn->setText(QStringLiteral("Verbinden"));
|
||||
statusBar()->showMessage(QStringLiteral("Getrennt."));
|
||||
}
|
||||
// Re-polish to apply the changed objectName style.
|
||||
m_connStatus->style()->unpolish(m_connStatus);
|
||||
m_connStatus->style()->polish(m_connStatus);
|
||||
updateEnabledStates();
|
||||
}
|
||||
|
||||
void MainWindow::onModeChanged()
|
||||
{
|
||||
const auto &s = m_controller->state();
|
||||
QString text;
|
||||
if (!s.multiZone)
|
||||
text = QStringLiteral("Stereo-Modus");
|
||||
else
|
||||
text = s.zoneLink ? QStringLiteral("2 Zonen (verknüpft)")
|
||||
: QStringLiteral("2 Zonen (unabhängig)");
|
||||
m_modeLabel->setText(text);
|
||||
m_zoneBox[1]->setVisible(s.multiZone);
|
||||
{
|
||||
QSignalBlocker b1(m_multiZoneCheck), b2(m_zoneLinkCheck);
|
||||
m_multiZoneCheck->setChecked(s.multiZone);
|
||||
m_zoneLinkCheck->setChecked(s.zoneLink);
|
||||
}
|
||||
m_zoneLinkCheck->setEnabled(s.multiZone);
|
||||
updateEnabledStates();
|
||||
}
|
||||
|
||||
void MainWindow::onStateChanged()
|
||||
{
|
||||
refreshFromState();
|
||||
}
|
||||
|
||||
void MainWindow::refreshFromState()
|
||||
{
|
||||
const auto &s = m_controller->state();
|
||||
m_syncing = true;
|
||||
|
||||
m_standbyCheck->setChecked(s.standby);
|
||||
m_sourceCombo->setCurrentIndex(DeviceState::sourceIndex(s.select));
|
||||
m_bassSpin->setValue(s.eqBass);
|
||||
m_trebleSpin->setValue(s.eqTreble);
|
||||
m_autoLoudCheck->setChecked(s.autoLoudness);
|
||||
|
||||
for (int z = 0; z < 2; ++z) {
|
||||
m_musicSlider[z]->setValue(s.musicLevel[z]);
|
||||
m_musicLabel[z]->setText(levelToDisplay(s.musicLevel[z]));
|
||||
m_musicMute[z]->setChecked(s.musicLevel[z] <= kLevelOff);
|
||||
m_micSlider[z]->setValue(s.micLevel[z]);
|
||||
m_micLabel[z]->setText(levelToDisplay(s.micLevel[z]));
|
||||
m_maxMusicSpin[z]->setValue(s.maxMusicLevel[z]);
|
||||
m_maxMicSpin[z]->setValue(s.maxMicLevel[z]);
|
||||
m_pagActiveCheck[z]->setChecked(s.pagingActive[z]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
m_gainSpin[i]->setValue(s.inputGain[i]);
|
||||
if (!m_nameEdit[i]->hasFocus())
|
||||
m_nameEdit[i]->setText(s.sourceName[i]);
|
||||
m_sourceCombo->setItemText(i, QStringLiteral("%1 (%2)")
|
||||
.arg(s.sourceName[i], sourceLetter(i)));
|
||||
}
|
||||
|
||||
m_echoCheck->setChecked(s.echo);
|
||||
m_lfCheck->setChecked(s.lineFeed);
|
||||
m_bsCheck->setChecked(s.backSpace);
|
||||
m_headerCheck->setChecked(s.header);
|
||||
m_valfbCheck->setChecked(s.valueFeedback);
|
||||
|
||||
m_serialLabel->setText(s.serial.isEmpty() ? QStringLiteral("—") : s.serial);
|
||||
m_hwLabel->setText(s.hwVersion.isEmpty() ? QStringLiteral("—") : s.hwVersion);
|
||||
m_swLabel->setText(s.swVersion.isEmpty() ? QStringLiteral("—") : s.swVersion);
|
||||
|
||||
m_syncing = false;
|
||||
}
|
||||
|
||||
void MainWindow::updateEnabledStates()
|
||||
{
|
||||
const bool connected = m_controller->isConnected();
|
||||
const bool standby = m_controller->state().standby;
|
||||
const bool active = connected && !standby;
|
||||
|
||||
// Standby toggle stays usable while connected.
|
||||
m_standbyCheck->setEnabled(connected);
|
||||
|
||||
for (int z = 0; z < 2; ++z) {
|
||||
m_musicSlider[z]->setEnabled(active);
|
||||
m_musicMute[z]->setEnabled(active);
|
||||
m_micSlider[z]->setEnabled(active);
|
||||
}
|
||||
m_sourceCombo->setEnabled(active);
|
||||
m_bassSpin->setEnabled(active);
|
||||
m_trebleSpin->setEnabled(active);
|
||||
m_autoLoudCheck->setEnabled(active && !m_controller->state().multiZone);
|
||||
|
||||
m_multiZoneCheck->setEnabled(connected);
|
||||
m_zoneLinkCheck->setEnabled(connected && m_controller->state().multiZone);
|
||||
for (int z = 0; z < 2; ++z) {
|
||||
m_maxMusicSpin[z]->setEnabled(connected);
|
||||
m_maxMicSpin[z]->setEnabled(connected);
|
||||
m_pagActiveCheck[z]->setEnabled(connected);
|
||||
}
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
m_gainSpin[i]->setEnabled(connected);
|
||||
m_nameEdit[i]->setEnabled(connected);
|
||||
}
|
||||
m_echoCheck->setEnabled(connected);
|
||||
m_lfCheck->setEnabled(connected);
|
||||
m_bsCheck->setEnabled(connected);
|
||||
m_headerCheck->setEnabled(connected);
|
||||
m_valfbCheck->setEnabled(connected);
|
||||
m_cmdEdit->setEnabled(connected);
|
||||
}
|
||||
|
||||
void MainWindow::onError(const QString &message)
|
||||
{
|
||||
appendTerminal(message, QStringLiteral("err"));
|
||||
statusBar()->showMessage(message, 5000);
|
||||
}
|
||||
|
||||
void MainWindow::onNotice(const QString &message)
|
||||
{
|
||||
appendTerminal(message, QStringLiteral("note"));
|
||||
statusBar()->showMessage(message, 5000);
|
||||
}
|
||||
|
||||
void MainWindow::onLineSent(const QString &line)
|
||||
{
|
||||
appendTerminal(QStringLiteral("\u2192 ") + line, QStringLiteral("tx"));
|
||||
}
|
||||
|
||||
void MainWindow::onLineReceived(const QString &line)
|
||||
{
|
||||
appendTerminal(QStringLiteral("\u2190 ") + line, QStringLiteral("rx"));
|
||||
}
|
||||
|
||||
void MainWindow::appendTerminal(const QString &text, const QString &colorRole)
|
||||
{
|
||||
QString color;
|
||||
if (colorRole == QLatin1String("tx")) color = QStringLiteral("#6fa8ff");
|
||||
else if (colorRole == QLatin1String("rx")) color = QStringLiteral("#7fd6a3");
|
||||
else if (colorRole == QLatin1String("err")) color = QStringLiteral("#ff7a6b");
|
||||
else if (colorRole == QLatin1String("note")) color = QStringLiteral("#e0b34a");
|
||||
else color = QStringLiteral("#c8d0da");
|
||||
|
||||
const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz"));
|
||||
QString esc = text.toHtmlEscaped();
|
||||
m_terminal->appendHtml(
|
||||
QStringLiteral("<span style='color:#7a818d'>%1</span> "
|
||||
"<span style='color:%2'>%3</span>")
|
||||
.arg(ts, color, esc));
|
||||
}
|
||||
105
src/ui/MainWindow.h
Normal file
105
src/ui/MainWindow.h
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#pragma once
|
||||
|
||||
#include "protocol/Concept1Protocol.h"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <array>
|
||||
|
||||
class Concept1Controller;
|
||||
class ThemeManager;
|
||||
|
||||
class QComboBox;
|
||||
class QPushButton;
|
||||
class QLabel;
|
||||
class QSlider;
|
||||
class QSpinBox;
|
||||
class QCheckBox;
|
||||
class QLineEdit;
|
||||
class QPlainTextEdit;
|
||||
class QGroupBox;
|
||||
class QWidget;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
private slots:
|
||||
void onConnectClicked();
|
||||
void onRefreshPorts();
|
||||
void onTransportKindChanged();
|
||||
void onThemeToggle();
|
||||
|
||||
// controller -> ui
|
||||
void onConnectionChanged(bool connected, const QString &endpoint);
|
||||
void onModeChanged();
|
||||
void onStateChanged();
|
||||
void onError(const QString &message);
|
||||
void onNotice(const QString &message);
|
||||
void onLineSent(const QString &line);
|
||||
void onLineReceived(const QString &line);
|
||||
|
||||
private:
|
||||
QWidget *buildConnectionBar();
|
||||
QWidget *buildControlTab();
|
||||
QWidget *buildConfigTab();
|
||||
QWidget *buildSystemTab();
|
||||
QWidget *buildTerminalTab();
|
||||
|
||||
void wireController();
|
||||
void refreshFromState(); // push full m_controller state into widgets
|
||||
void updateEnabledStates(); // enable/disable per connection + standby
|
||||
void appendTerminal(const QString &text, const QString &colorRole);
|
||||
|
||||
Concept1Controller *m_controller = nullptr;
|
||||
ThemeManager *m_theme = nullptr;
|
||||
|
||||
bool m_syncing = false; // true while pushing device state into widgets
|
||||
|
||||
// Connection bar
|
||||
QComboBox *m_transportCombo = nullptr;
|
||||
QComboBox *m_portCombo = nullptr;
|
||||
QPushButton *m_refreshPortsBtn = nullptr;
|
||||
QPushButton *m_connectBtn = nullptr;
|
||||
QLabel *m_connStatus = nullptr;
|
||||
QPushButton *m_themeBtn = nullptr;
|
||||
|
||||
// Control tab
|
||||
QCheckBox *m_standbyCheck = nullptr;
|
||||
QLabel *m_modeLabel = nullptr;
|
||||
QComboBox *m_sourceCombo = nullptr;
|
||||
QSpinBox *m_bassSpin = nullptr;
|
||||
QSpinBox *m_trebleSpin = nullptr;
|
||||
QCheckBox *m_autoLoudCheck = nullptr;
|
||||
std::array<QGroupBox *, 2> m_zoneBox {{nullptr, nullptr}};
|
||||
std::array<QSlider *, 2> m_musicSlider {{nullptr, nullptr}};
|
||||
std::array<QLabel *, 2> m_musicLabel {{nullptr, nullptr}};
|
||||
std::array<QCheckBox *, 2> m_musicMute {{nullptr, nullptr}};
|
||||
std::array<QSlider *, 2> m_micSlider {{nullptr, nullptr}};
|
||||
std::array<QLabel *, 2> m_micLabel {{nullptr, nullptr}};
|
||||
|
||||
// Config tab
|
||||
QCheckBox *m_multiZoneCheck = nullptr;
|
||||
QCheckBox *m_zoneLinkCheck = nullptr;
|
||||
std::array<QSpinBox *, 2> m_maxMusicSpin {{nullptr, nullptr}};
|
||||
std::array<QSpinBox *, 2> m_maxMicSpin {{nullptr, nullptr}};
|
||||
std::array<QCheckBox *, 2> m_pagActiveCheck {{nullptr, nullptr}};
|
||||
std::array<QLabel *, 2> m_pagStateLabel {{nullptr, nullptr}};
|
||||
std::array<QSpinBox *, 4> m_gainSpin {{nullptr, nullptr, nullptr, nullptr}};
|
||||
std::array<QLineEdit *, 4> m_nameEdit {{nullptr, nullptr, nullptr, nullptr}};
|
||||
|
||||
// System tab
|
||||
QCheckBox *m_echoCheck = nullptr;
|
||||
QCheckBox *m_lfCheck = nullptr;
|
||||
QCheckBox *m_bsCheck = nullptr;
|
||||
QCheckBox *m_headerCheck = nullptr;
|
||||
QCheckBox *m_valfbCheck = nullptr;
|
||||
QLabel *m_serialLabel = nullptr;
|
||||
QLabel *m_hwLabel = nullptr;
|
||||
QLabel *m_swLabel = nullptr;
|
||||
|
||||
// Terminal tab
|
||||
QPlainTextEdit *m_terminal = nullptr;
|
||||
QLineEdit *m_cmdEdit = nullptr;
|
||||
};
|
||||
48
src/ui/ThemeManager.cpp
Normal file
48
src/ui/ThemeManager.cpp
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#include "ThemeManager.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QFile>
|
||||
#include <QSettings>
|
||||
#include <QTextStream>
|
||||
|
||||
ThemeManager::ThemeManager(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
QString ThemeManager::stylesheetFor(Theme theme)
|
||||
{
|
||||
const QString path = theme == Theme::Dark
|
||||
? QStringLiteral(":/styles/dark.qss")
|
||||
: QStringLiteral(":/styles/light.qss");
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
return QString();
|
||||
QTextStream ts(&f);
|
||||
return ts.readAll();
|
||||
}
|
||||
|
||||
void ThemeManager::apply(Theme theme)
|
||||
{
|
||||
m_theme = theme;
|
||||
if (auto *app = qobject_cast<QApplication *>(QApplication::instance()))
|
||||
app->setStyleSheet(stylesheetFor(theme));
|
||||
QSettings settings;
|
||||
settings.setValue(QStringLiteral("ui/theme"),
|
||||
theme == Theme::Dark ? QStringLiteral("dark")
|
||||
: QStringLiteral("light"));
|
||||
emit themeChanged(theme);
|
||||
}
|
||||
|
||||
void ThemeManager::toggle()
|
||||
{
|
||||
apply(m_theme == Theme::Dark ? Theme::Light : Theme::Dark);
|
||||
}
|
||||
|
||||
void ThemeManager::loadSaved()
|
||||
{
|
||||
QSettings settings;
|
||||
const QString saved = settings.value(QStringLiteral("ui/theme"),
|
||||
QStringLiteral("dark")).toString();
|
||||
apply(saved == QStringLiteral("light") ? Theme::Light : Theme::Dark);
|
||||
}
|
||||
28
src/ui/ThemeManager.h
Normal file
28
src/ui/ThemeManager.h
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
// Loads dark/light QSS stylesheets from the Qt resource system, applies them
|
||||
// to the whole application, and persists the choice via QSettings.
|
||||
class ThemeManager : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum class Theme { Dark, Light };
|
||||
|
||||
explicit ThemeManager(QObject *parent = nullptr);
|
||||
|
||||
Theme theme() const { return m_theme; }
|
||||
bool isDark() const { return m_theme == Theme::Dark; }
|
||||
|
||||
void apply(Theme theme);
|
||||
void toggle();
|
||||
void loadSaved(); // load persisted theme (defaults to Dark)
|
||||
|
||||
signals:
|
||||
void themeChanged(Theme theme);
|
||||
|
||||
private:
|
||||
static QString stylesheetFor(Theme theme);
|
||||
Theme m_theme = Theme::Dark;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue