Apart Concept 1 Serial Controller Program with modern UI

This commit is contained in:
Tronax 2026-06-13 09:26:58 +02:00
commit c4bde4c18c
103 changed files with 38385 additions and 0 deletions

View 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();
}

View 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
View 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();
}

View 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

View 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

View 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

View 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);
};

View 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;
}

View 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;
};

View 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;
}
}
}

View 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
View 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 (AD)"), 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
View 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
View 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
View 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;
};