//// OSCScriptingInterface.cpp// interface/src/scripting//// Created by Ada <ada@thingvellir.net> 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 <QLoggingCategory>#include <QRegularExpression>#include <QtEndian> #include "ScriptEngine.h"#include "OSCScriptingInterface.h" Q_LOGGING_CATEGORY(osc, "overte.osc") enum OSCTag: char { Int = 'i', Float = 'f', String = 's', Blob = 'b',}; static const QRegularExpression invalidCharacters = QRegularExpression("([ #*,/?\\[\\]{}])");static const QMap<QChar, QVariant::Type> typeNameMap = { { OSCTag::Int, QVariant::Int }, { OSCTag::Float, QVariant::Double }, { OSCTag::String, QVariant::String }, { OSCTag::Blob, QVariant::ByteArray },}; OSCScriptingInterface::OSCScriptingInterface(ScriptManager* parent) : _scriptManager(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<QUdpSocket>(this)){ _socket->bind(QHostAddress(_receiveHost.get()), _receivePort.get()); connect(_socket.get(), &QUdpSocket::readyRead, this, &OSCScriptingInterface::receivePacket); qCInfo(osc) << "Listening on" << _socket->localAddress();} OSCScriptingInterface::~OSCScriptingInterface() { _socket->close(); qCInfo(osc) << "Closed listen socket";} void OSCScriptingInterface::receivePacket() { // TODO emit messageReceived("/test", "", QVariantList());} void OSCScriptingInterface::sendMessage(const QString& address, const QString &argumentTypes, const QVariantList& arguments) { Q_ASSERT(_socket); auto scriptEngine = _scriptManager->engine(); if (address.length() < 2 || address[0] != '/') { 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; } if (argumentTypes.length() != arguments.length()) { scriptEngine->raiseException(QString("argumentTypes.length (%1) does not match arguments.length (%2)").arg(argumentTypes.length()).arg(argumentTypes.length())); return; } QByteArray bytes = address.toUtf8(); // pad to 4-byte boundary with at least one zero for the address string bytes.append((bytes.length() + 1) % 4, 0); // 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]; if (!typeNameMap.contains(argumentTypes[i])) { scriptEngine->raiseException(QString("Unknown type tag '%1'").arg(argumentTypes[i])); return; } auto expectedType = typeNameMap[argumentTypes[i]]; if (expectedType != arg.type()) { scriptEngine->raiseException(QString("Specified argument type '%1' does not match value of type '%2'").arg(QVariant::typeToName(expectedType)).arg(arg.typeName())); return; } switch (arg.type()) { case QVariant::Int: { bytes.append(OSCTag::Int); auto* ptr = bodyBytes.data() + bodyBytes.length(); bodyBytes.append(4, 0); qToBigEndian(static_cast<qint32>(arg.toInt()), 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 = arg.toFloat(); qToBigEndian(*reinterpret_cast<quint32*>(&tmp), ptr); } break; case QVariant::String: { bytes.append(OSCTag::String); auto stringArg = arg.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((bodyBytes.length() + 1) % 4, 0); } break; case QVariant::ByteArray: { bytes.append(OSCTag::Blob); auto bytesArg = arg.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 with at least one zero for the string bodyBytes.append((bodyBytes.length() + 1) % 4, 0); } break; // unreachable default: Q_ASSERT(false); break; } } // pad to 4-byte boundary with at least one zero for the argument list string bytes.append((bytes.length() + 1) % 4, 0); // pack the header + body data together for the datagram bytes.append(bodyBytes); _socket->writeDatagram(bytes, QHostAddress(_sendHost.get()), _sendPort.get());}
//
// OSCScriptingInterface.cpp
// interface/src/scripting
//
// Created by Ada <ada@thingvellir.net> 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 <QLoggingCategory>
#include <QRegularExpression>
#include <QtEndian>
#include "ScriptEngine.h"
#include "OSCScriptingInterface.h"
Q_LOGGING_CATEGORY(osc, "overte.osc")
enum OSCTag: char {
Int = 'i',
Float = 'f',
String = 's',
Blob = 'b',
};
static const QRegularExpression invalidCharacters = QRegularExpression("([ #*,/?\\[\\]{}])");
static const QMap<QChar, QVariant::Type> typeNameMap = {
{ OSCTag::Int, QVariant::Int },
{ OSCTag::Float, QVariant::Double },
{ OSCTag::String, QVariant::String },
{ OSCTag::Blob, QVariant::ByteArray },
};
OSCScriptingInterface::OSCScriptingInterface(ScriptManager* parent) :
_scriptManager(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<QUdpSocket>(this))
{
_socket->bind(QHostAddress(_receiveHost.get()), _receivePort.get());
connect(_socket.get(), &QUdpSocket::readyRead, this, &OSCScriptingInterface::receivePacket);
qCInfo(osc) << "Listening on" << _socket->localAddress();
}
OSCScriptingInterface::~OSCScriptingInterface() {
_socket->close();
qCInfo(osc) << "Closed listen socket";
}
void OSCScriptingInterface::receivePacket() {
// TODO
emit messageReceived("/test", "", QVariantList());
}
void OSCScriptingInterface::sendMessage(const QString& address, const QString &argumentTypes, const QVariantList& arguments) {
Q_ASSERT(_socket);
auto scriptEngine = _scriptManager->engine();
if (address.length() < 2 || address[0] != '/') {
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;
}
if (argumentTypes.length() != arguments.length()) {
scriptEngine->raiseException(QString("argumentTypes.length (%1) does not match arguments.length (%2)").arg(argumentTypes.length()).arg(argumentTypes.length()));
return;
}
QByteArray bytes = address.toUtf8();
// pad to 4-byte boundary with at least one zero for the address string
bytes.append((bytes.length() + 1) % 4, 0);
// 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];
if (!typeNameMap.contains(argumentTypes[i])) {
scriptEngine->raiseException(QString("Unknown type tag '%1'").arg(argumentTypes[i]));
return;
}
auto expectedType = typeNameMap[argumentTypes[i]];
if (expectedType != arg.type()) {
scriptEngine->raiseException(QString("Specified argument type '%1' does not match value of type '%2'").arg(QVariant::typeToName(expectedType)).arg(arg.typeName()));
return;
}
switch (arg.type()) {
case QVariant::Int: {
bytes.append(OSCTag::Int);
auto* ptr = bodyBytes.data() + bodyBytes.length();
bodyBytes.append(4, 0);
qToBigEndian(static_cast<qint32>(arg.toInt()), 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 = arg.toFloat();
qToBigEndian(*reinterpret_cast<quint32*>(&tmp), ptr);
} break;
case QVariant::String: {
bytes.append(OSCTag::String);
auto stringArg = arg.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((bodyBytes.length() + 1) % 4, 0);
} break;
case QVariant::ByteArray: {
bytes.append(OSCTag::Blob);
auto bytesArg = arg.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 with at least one zero for the string
bodyBytes.append((bodyBytes.length() + 1) % 4, 0);
} break;
// unreachable
default:
Q_ASSERT(false);
break;
}
}
// pad to 4-byte boundary with at least one zero for the argument list string
bytes.append((bytes.length() + 1) % 4, 0);
// pack the header + body data together for the datagram
bytes.append(bodyBytes);
_socket->writeDatagram(bytes, QHostAddress(_sendHost.get()), _sendPort.get());
}