// // OSCScriptingInterface.cpp // interface/src/scripting // // Created by Ada on 2025-12-13 // Copyright 2025 Overte e.V. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // #include #include #include #include #include "ScriptEngine.h" #include "OSCScriptingInterface.h" Q_LOGGING_CATEGORY(osc_cat, "osc") enum OSCTag: char { Int = 'i', Float = 'f', String = 's', Blob = 'b', False = 'F', True = 'T', Null = 'N', }; static const QRegularExpression invalidCharacters = QRegularExpression("([ #*,?\\[\\]{}])"); static const QMap typeNameMap = { { OSCTag::Int, QVariant::Int }, { OSCTag::Float, QVariant::Double }, { OSCTag::String, QVariant::String }, { OSCTag::Blob, QVariant::ByteArray }, { OSCTag::False, QVariant::Bool }, { OSCTag::True, QVariant::Bool }, { OSCTag::Null, QVariant::Invalid }, }; OSCScriptingInterface::OSCScriptingInterface(QObject* parent) : QObject(parent), _receivePort("osc/receivePort", 9000), _receiveHost("osc/receiveHost", "127.0.0.1"), _sendPort("osc/sendPort", 9001), _sendHost("osc/sendHost", "127.0.0.1"), _socket(std::make_shared(this)) { _socket->bind(QHostAddress(_receiveHost.get()), _receivePort.get()); connect(_socket.get(), &QUdpSocket::readyRead, this, &OSCScriptingInterface::readPacket); qCInfo(osc_cat) << "Listening on" << _socket->localAddress(); } OSCScriptingInterface::~OSCScriptingInterface() { _socket->close(); qCInfo(osc_cat) << "Closed listen socket"; } void OSCScriptingInterface::readPacket() { auto datagram = _socket->receiveDatagram(); if (!datagram.isValid()) { return; } auto next4 = [](int i) { return i + (4 - i % 4); }; auto data = datagram.data(); int cursor = 0; QString addr; QByteArray argTypes; QVariantList args; // address for (int i = 0; (i + cursor) < data.length(); i++) { if (data[i + cursor] == '\x00') { addr = QString::fromUtf8(data.constData(), i); cursor = next4(cursor + i); break; } } if (addr.isEmpty()) { return; } // argument types for (int i = 0; (i + cursor) < data.length(); i++) { if (data[i + cursor] == '\x00') { argTypes = QByteArray(data.constData() + cursor, i); cursor = next4(cursor + i); break; } } // +1 to exclude the comma for (int arg = 1; arg < argTypes.length(); arg++) { switch (argTypes[arg]) { case OSCTag::Int: { const char* ptr = data.constData() + cursor; qint32 tmp = qFromBigEndian(ptr); args.append(QVariantMap { {"type", QString(OSCTag::Int)}, {"value", tmp}, }); cursor += 4; } break; case OSCTag::Float: { const char* ptr = data.constData() + cursor; qint32 tmp = qFromBigEndian(ptr); args.append(QVariantMap { {"type", QString(OSCTag::Float)}, {"value", *reinterpret_cast(&tmp)}, }); cursor += 4; } break; case OSCTag::String: { QString tmp; for (int i = 0; i + cursor < data.length(); i++) { if (data[i + cursor] == '\x00') { tmp = QString::fromUtf8(data.constData() + cursor, i); cursor = next4(cursor + i); break; } } args.append(QVariantMap { {"type", QString(OSCTag::String)}, {"value", tmp}, }); } break; case OSCTag::Blob: { const char* ptr = data.constData() + cursor; auto len = qFromBigEndian(ptr); cursor += 4; args.append(QVariantMap { {"type", QString(OSCTag::Blob)}, {"value", QByteArray(data.constData() + cursor, len)}, }); cursor = next4(cursor + len); } break; case OSCTag::False: args.append(QVariantMap { {"type", QString(OSCTag::False)}, {"value", false}, }); break; case OSCTag::True: args.append(QVariantMap { {"type", QString(OSCTag::True)}, {"value", true}, }); break; case OSCTag::Null: args.append(QVariantMap { {"type", QString(OSCTag::Null)}, {"value", QVariant()}, }); break; default: qCCritical(osc_cat, "Unknown type '%c'", static_cast(argTypes[arg])); return; } } emit packetReceived(addr, args); } void OSCScriptingInterface::sendPacket(const QString& address, const QVariantList& arguments) { Q_ASSERT(_socket); // FIXME: how do you get the script engine??? struct HackRemoveThis { void raiseException(QString msg) { qCCritical(osc_cat) << msg; } }; auto scriptEngine = std::make_unique(); //_scriptManager->engine(); if (address.length() < 2 || !address.startsWith('/') || address.endsWith('/')) { scriptEngine->raiseException("address must be at least two characters and start with '/'"); return; } if (auto match = invalidCharacters.match(address); match.hasMatch()) { scriptEngine->raiseException(QString("address contains invalid character '%1'").arg(match.captured())); return; } auto pad4 = [](QByteArray& array) { array.append((4 - array.length() % 4) % 4, 0); }; QByteArray bytes = address.toUtf8(); // pad to 4-byte boundary with at least one zero for the address string bytes.append('\x00'); pad4(bytes); // comma at the start of the argument list, even if empty bytes.append(','); QByteArray bodyBytes; for (int i = 0; i < arguments.length(); i++) { auto arg = arguments[i]; QVariant::Type expectedType; QVariant value; if (arg.type() == QVariant::Map) { auto map = arg.toMap(); auto typeName = map.value("type").toString(); if (!typeNameMap.contains(typeName[0])) { scriptEngine->raiseException(QString("Unknown type '%c' on argument %d").arg(typeName).arg(i)); return; } expectedType = typeNameMap[typeName[0]]; value = map.value("value"); } else { expectedType = arg.type(); value = arg; } if (value.isNull()) { bytes.append(OSCTag::Null); continue; } switch (expectedType) { case QVariant::Int: { bytes.append(OSCTag::Int); auto* ptr = bodyBytes.data() + bodyBytes.length(); bodyBytes.append(4, 0); qToBigEndian(static_cast(value.toDouble()), ptr); } break; case QVariant::Double: { bytes.append(OSCTag::Float); auto* ptr = bodyBytes.data() + bodyBytes.length(); bodyBytes.append(4, 0); // FIXME: use std::bit_cast instead of this hack once c++20 is in auto tmp = static_cast(value.toDouble()); qToBigEndian(*reinterpret_cast(&tmp), ptr); } break; case QVariant::String: { bytes.append(OSCTag::String); auto stringArg = value.toString(); if (stringArg.contains(QChar(0))) { scriptEngine->raiseException("String contains null (0x00) character"); return; } bodyBytes.append(stringArg.toUtf8()); // pad to 4-byte boundary with at least one zero for the string bodyBytes.append('\x00'); pad4(bodyBytes); } break; case QVariant::ByteArray: { bytes.append(OSCTag::Blob); auto bytesArg = value.toByteArray(); // write length prefix auto* ptr = bodyBytes.data() + bodyBytes.length(); bodyBytes.append(4, 0); qToBigEndian(bytesArg.length(), ptr); // write body bodyBytes.append(bytesArg); // pad to 4-byte boundary pad4(bodyBytes); } break; case QVariant::Bool: bytes.append(value.toBool() ? OSCTag::True : OSCTag::False); break; default: scriptEngine->raiseException(QString("Unserializable type %1").arg(value.typeName())); return; } } // pad argument type string to 4-byte boundary with at least one zero bytes.append('\x00'); pad4(bytes); // pack the header + body data together for the datagram bytes.append(bodyBytes); _socket->writeDatagram(bytes, QHostAddress(_sendHost.get()), _sendPort.get()); qCDebug(osc_cat) << QHostAddress(_sendHost.get()) << _sendPort.get() << ":" << bytes.toHex(' '); }