mirror of
https://github.com/f4exb/sdrangel.git
synced 2026-10-09 00:31:24 -04:00
Tools: listen and scan reclaim their earlier channels before retuning the device, so a scanner left running cannot move it in between; scan counts a range before building it; booleans are read from JSON booleans, numbers and strings alike, so replace "false" is false and freeze 1 freezes; a MIMO device set takes receive and transmit channel types; deletion is serialised from lookup to completion so two concurrent reclaims cannot take a third channel; tune_gain refuses a gain or AGC key the device lacks; start_iq_recording checks the name first and cleans up by UID; describe_settings emits a definition matched by name; remove_last_deviceset reports a timeout; a device set a failed listen or scan created is removed again. Capture: a name resolving to the capture directory itself is refused, a drive root works, and files are listed by prefix rather than wildcard. The audio clock starts before the wait for the sample rate. Subscriptions are capped per session, device set URIs checked, and absent device sets skipped by the poll. The Codex writer drops dotted managed keys and refuses a spelling it cannot rewrite rather than adding a duplicate table. Bridge: one session at a time when SDRangel comes back, the stream socket is shut down rather than closed from the other thread, SIGPIPE cannot end the process, and a failed notification gets no reply. Also: tool calls and audio captures give up when the server stops, so stopping the feature cannot deadlock on a call waiting for the main thread; channels are held by UID throughout; socket timeouts in the bridge; ResourceNotFound for a resources/read of a URI that is not there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
677 lines
28 KiB
C++
677 lines
28 KiB
C++
///////////////////////////////////////////////////////////////////////////////////
|
|
// Copyright (C) 2026 Jon Beniston, M7RCE <jon@beniston.com> //
|
|
// Some code by AI //
|
|
// //
|
|
// This program is free software; you can redistribute it and/or modify //
|
|
// it under the terms of the GNU General Public License as published by //
|
|
// the Free Software Foundation as version 3 of the License, or //
|
|
// (at your option) any later version. //
|
|
// //
|
|
// This program is distributed in the hope that it will be useful, //
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of //
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the //
|
|
// GNU General Public License V3 for more details. //
|
|
// //
|
|
// You should have received a copy of the GNU General Public License //
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>. //
|
|
///////////////////////////////////////////////////////////////////////////////////
|
|
|
|
#include <QDebug>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QCoreApplication>
|
|
|
|
#include "mcpstreams.h"
|
|
#include "mcpprotocol.h"
|
|
|
|
const char* const MCPProtocol::m_serverName = "SDRangel";
|
|
const char* const MCPProtocol::m_latestProtocolVersion = "2025-06-18";
|
|
|
|
MCPProtocol::MCPProtocol(WebAPIAdapterInterface *webAPIAdapterInterface) :
|
|
m_webAPIAdapterInterface(webAPIAdapterInterface),
|
|
m_tools(webAPIAdapterInterface),
|
|
m_streams(nullptr)
|
|
{
|
|
}
|
|
|
|
QJsonObject MCPProtocol::makeResult(const QJsonValue& id, const QJsonValue& result)
|
|
{
|
|
QJsonObject response;
|
|
response["jsonrpc"] = "2.0";
|
|
response["id"] = id;
|
|
response["result"] = result;
|
|
return response;
|
|
}
|
|
|
|
QJsonObject MCPProtocol::makeError(const QJsonValue& id, int code, const QString& message)
|
|
{
|
|
QJsonObject error;
|
|
error["code"] = code;
|
|
error["message"] = message;
|
|
QJsonObject response;
|
|
response["jsonrpc"] = "2.0";
|
|
response["id"] = id.isUndefined() ? QJsonValue() : id;
|
|
response["error"] = error;
|
|
return response;
|
|
}
|
|
|
|
// JSON-RPC 2.0 rules: a message with an "id" member is a request and always gets a reply
|
|
// carrying that id, even if the id is null (MCP forbids null ids but a client that sends one
|
|
// must still learn what happened). A message with a method and no id is a notification and
|
|
// gets no reply. Anything that is neither a valid request, notification nor response is
|
|
// answered with an Invalid Request error whose id is null when it cannot be identified.
|
|
bool MCPProtocol::handleMessage(const QJsonObject& message, QJsonObject& response, Context& context)
|
|
{
|
|
bool hasId = message.contains("id");
|
|
QJsonValue id = hasId ? message["id"] : QJsonValue();
|
|
bool hasMethod = message["method"].isString() && !message["method"].toString().isEmpty();
|
|
|
|
if (!hasMethod)
|
|
{
|
|
// A response to a server initiated request. We never send any, so there is nothing to do.
|
|
if (hasId && (message.contains("result") || message.contains("error"))) {
|
|
return false;
|
|
}
|
|
|
|
response = makeError(id, InvalidRequest, "Invalid request: missing method");
|
|
return true;
|
|
}
|
|
|
|
if (message["jsonrpc"].toString() != "2.0")
|
|
{
|
|
response = makeError(id, InvalidRequest, "Invalid request: jsonrpc must be \"2.0\"");
|
|
return true;
|
|
}
|
|
|
|
QString method = message["method"].toString();
|
|
QJsonObject params = message["params"].toObject();
|
|
|
|
try
|
|
{
|
|
// Calls are serialized so that tool handlers never run concurrently, except for the
|
|
// capture tools: those deliberately block for up to 30 seconds, and holding the lock
|
|
// across one would stall every other request, including ping and the status calls a
|
|
// client needs while a capture runs. They protect their own state instead.
|
|
QJsonValue result;
|
|
|
|
if (isLongRunning(method, params))
|
|
{
|
|
result = dispatch(method, params, context);
|
|
}
|
|
else
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
result = dispatch(method, params, context);
|
|
}
|
|
|
|
if (!hasId) {
|
|
return false; // notification: no reply
|
|
}
|
|
|
|
response = makeResult(id, result);
|
|
return true;
|
|
}
|
|
catch (const MCPError& e)
|
|
{
|
|
qDebug() << "MCPProtocol::handleMessage:" << method << "error:" << e.code << e.message;
|
|
|
|
if (!hasId) {
|
|
return false;
|
|
}
|
|
|
|
response = makeError(id, e.code, e.message);
|
|
return true;
|
|
}
|
|
catch (const MCPToolError& e)
|
|
{
|
|
// A tool level failure raised outside tools/call: resources/read shares the tool
|
|
// helpers, and a resource that is not there (a device set index that does not exist,
|
|
// an unknown schema type) surfaces here. Left uncaught it would leave the HTTP thread
|
|
// and take the application down, since MCPToolError is not a std::exception
|
|
qDebug() << "MCPProtocol::handleMessage:" << method << "error:" << e.message;
|
|
|
|
if (!hasId) {
|
|
return false;
|
|
}
|
|
|
|
response = makeError(id, method.startsWith("resources/") ? ResourceNotFound : InvalidParams, e.message);
|
|
return true;
|
|
}
|
|
catch (const std::exception& e)
|
|
{
|
|
qWarning() << "MCPProtocol::handleMessage:" << method << "exception:" << e.what();
|
|
|
|
if (!hasId) {
|
|
return false;
|
|
}
|
|
|
|
response = makeError(id, InternalError, QString("Internal error: %1").arg(e.what()));
|
|
return true;
|
|
}
|
|
catch (...)
|
|
{
|
|
// Nothing thrown here may reach the connection thread, which has no handler
|
|
qWarning() << "MCPProtocol::handleMessage:" << method << "unknown exception";
|
|
|
|
if (!hasId) {
|
|
return false;
|
|
}
|
|
|
|
response = makeError(id, InternalError, "Internal error");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// The capture tools sleep for the requested duration, so they run without the dispatch lock.
|
|
// MCPCapture and MCPAudioCapture each serialize their own work, and everything they call
|
|
// through the Web API adapter is safe to use from several threads.
|
|
bool MCPProtocol::isLongRunning(const QString& method, const QJsonObject& params)
|
|
{
|
|
if (method != "tools/call") {
|
|
return false;
|
|
}
|
|
|
|
static const QStringList blocking = {"record_iq", "capture_audio", "listen", "scan"};
|
|
return blocking.contains(params["name"].toString());
|
|
}
|
|
|
|
QJsonValue MCPProtocol::dispatch(const QString& method, const QJsonObject& params, Context& context)
|
|
{
|
|
if (method == "initialize") {
|
|
return initialize(params, context);
|
|
} else if (method == "ping") {
|
|
return QJsonObject();
|
|
} else if (method.startsWith("notifications/")) {
|
|
return QJsonValue(); // initialized, cancelled, progress, roots/list_changed: nothing to do
|
|
} else if (method == "tools/list") {
|
|
return toolsList(params);
|
|
} else if (method == "tools/call") {
|
|
return toolsCall(params);
|
|
} else if (method == "resources/list") {
|
|
return resourcesList(params);
|
|
} else if (method == "resources/templates/list") {
|
|
return resourcesTemplatesList(params);
|
|
} else if (method == "resources/read") {
|
|
return resourcesRead(params);
|
|
} else if (method == "resources/subscribe") {
|
|
return resourcesSubscribe(params, context, true);
|
|
} else if (method == "resources/unsubscribe") {
|
|
return resourcesSubscribe(params, context, false);
|
|
} else if (method == "prompts/list") {
|
|
return promptsList(params);
|
|
} else if (method == "prompts/get") {
|
|
return promptsGet(params);
|
|
} else if (method == "logging/setLevel") {
|
|
return QJsonObject();
|
|
} else if (method == "completion/complete") {
|
|
QJsonObject completion;
|
|
completion["values"] = QJsonArray();
|
|
completion["hasMore"] = false;
|
|
QJsonObject result;
|
|
result["completion"] = completion;
|
|
return result;
|
|
} else {
|
|
throw MCPError(MethodNotFound, QString("Method not found: %1").arg(method));
|
|
}
|
|
}
|
|
|
|
// Protocol revisions this server implements, newest first. 2024-11-05 is deliberately absent:
|
|
// it specifies the two endpoint HTTP+SSE transport, with a separate message endpoint announced
|
|
// by an initial endpoint event, and this server only implements Streamable HTTP.
|
|
QStringList MCPProtocol::supportedVersions()
|
|
{
|
|
return QStringList{"2025-06-18", "2025-03-26"};
|
|
}
|
|
|
|
bool MCPProtocol::isSupportedVersion(const QString& version)
|
|
{
|
|
return supportedVersions().contains(version);
|
|
}
|
|
|
|
QJsonValue MCPProtocol::initialize(const QJsonObject& params, Context& context)
|
|
{
|
|
// Echo the client's version if we implement it, otherwise offer the newest we have
|
|
QString requested = params["protocolVersion"].toString();
|
|
QString version = isSupportedVersion(requested) ? requested : QString(m_latestProtocolVersion);
|
|
|
|
QJsonObject clientInfo = params["clientInfo"].toObject();
|
|
qInfo("MCPProtocol::initialize: client %s %s protocol %s",
|
|
qPrintable(clientInfo["name"].toString()),
|
|
qPrintable(clientInfo["version"].toString()),
|
|
qPrintable(requested));
|
|
|
|
QJsonObject tools;
|
|
tools["listChanged"] = false;
|
|
QJsonObject resources;
|
|
resources["subscribe"] = true;
|
|
resources["listChanged"] = false;
|
|
QJsonObject prompts;
|
|
prompts["listChanged"] = false;
|
|
QJsonObject capabilities;
|
|
capabilities["tools"] = tools;
|
|
capabilities["resources"] = resources;
|
|
capabilities["prompts"] = prompts;
|
|
|
|
QJsonObject serverInfo;
|
|
serverInfo["name"] = m_serverName;
|
|
serverInfo["title"] = "SDRangel SDR";
|
|
serverInfo["version"] = qApp->applicationVersion();
|
|
|
|
// A session lets subscriptions and the event stream of this client be told apart from
|
|
// those of any other. Clients that ignore the session id share an anonymous one.
|
|
if (m_streams) {
|
|
context.m_newSessionId = m_streams->createSession();
|
|
}
|
|
|
|
QJsonObject result;
|
|
result["protocolVersion"] = version;
|
|
result["capabilities"] = capabilities;
|
|
result["serverInfo"] = serverInfo;
|
|
result["instructions"] = instructions();
|
|
return result;
|
|
}
|
|
|
|
// resources/subscribe and resources/unsubscribe. Updates are delivered as
|
|
// notifications/resources/updated on the SSE stream opened with GET.
|
|
QJsonValue MCPProtocol::resourcesSubscribe(const QJsonObject& params, Context& context, bool subscribe)
|
|
{
|
|
QString uri = params["uri"].toString();
|
|
|
|
if (uri.isEmpty()) {
|
|
throw MCPError(InvalidParams, "Invalid params: missing uri");
|
|
}
|
|
|
|
if (!m_streams) {
|
|
throw MCPError(InternalError, "Subscriptions are not available");
|
|
}
|
|
|
|
// Only the resources that change are worth subscribing to
|
|
static const QStringList subscribable = {
|
|
"sdrangel://instance", "sdrangel://packets", "sdrangel://map/items"
|
|
};
|
|
|
|
if (subscribe && !subscribable.contains(uri) && !uri.startsWith("sdrangel://deviceset/"))
|
|
{
|
|
throw MCPError(InvalidParams, QString("Resource %1 does not change, so it cannot be subscribed to. "
|
|
"Subscribable resources are %2 and sdrangel://deviceset/{index}").arg(uri).arg(subscribable.join(", ")));
|
|
}
|
|
|
|
// A device set URI has to name one that could exist: the index the Web API accepts is two
|
|
// digits, so anything else is a mistake rather than a resource to watch for
|
|
if (subscribe && uri.startsWith("sdrangel://deviceset/"))
|
|
{
|
|
static const QRegularExpression deviceSetUri("^sdrangel://deviceset/([0-9]{1,2})$");
|
|
|
|
if (!deviceSetUri.match(uri).hasMatch()) {
|
|
throw MCPError(InvalidParams, QString("Resource %1 is not a device set: use sdrangel://deviceset/{index} with an index from 0 to 99").arg(uri));
|
|
}
|
|
}
|
|
|
|
if (subscribe)
|
|
{
|
|
if (!m_streams->subscribe(context.m_sessionId, uri)) {
|
|
throw MCPError(InvalidParams, QString("This session already has %1 subscriptions; unsubscribe from one first").arg(MCPStreams::m_maxSubscriptions));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
m_streams->unsubscribe(context.m_sessionId, uri);
|
|
}
|
|
|
|
return QJsonObject();
|
|
}
|
|
|
|
QString MCPProtocol::instructions()
|
|
{
|
|
return
|
|
"SDRangel is a software defined radio (SDR) application. This server controls a running SDRangel instance.\n"
|
|
"\n"
|
|
"Start with the intent tools: listen (receive a frequency in a mode, one call) and scan (find and follow activity across "
|
|
"frequencies). get_status is the cheapest view of what is running. Use the object level tools to adjust what they set up.\n"
|
|
"\n"
|
|
"Object model: a device set (R0, R1... for receivers, T0... for transmitters) holds one sampling device (an SDR such as an "
|
|
"RTL-SDR) and any number of channels (demodulators/modulators) processing its baseband. Device settings include centerFrequency "
|
|
"(Hz), sample rate and gain; channel settings include inputFrequencyOffset (Hz from the device centre). Features are device "
|
|
"independent plugins (Map, AIS, APRS...) in the single feature set (index 0).\n"
|
|
"\n"
|
|
"Settings keys are specific to each type, but the common ones (centerFrequency, inputFrequencyOffset, rfBandwidth, squelch, "
|
|
"volume, gain) can be set straight away: a set_* tool applies what it recognises, replies with just the keys changed, and names "
|
|
"any it did not recognise. Call describe_settings only for an unfamiliar key or when you need its range. get_receiving_guide "
|
|
"says which demodulator, frequency and sample rate a named signal needs; get_docs has a plugin's full readme, by section.\n"
|
|
"\n"
|
|
"A receiver's baseband rate (for RTL-SDR, devSampleRate / 2^log2Decim) must exceed every channel's RF bandwidth; listen "
|
|
"chooses it for you, and get_channel_report shows the resulting channelSampleRate.\n"
|
|
"\n"
|
|
"Recording: record_iq writes baseband IQ for a fixed time, start_iq_recording/stop_iq_recording for longer; capture_audio "
|
|
"writes a demodulator's audio to WAV and says whether it was silent. Files go under the capture directory shown by "
|
|
"get_server_status. Live data: get_packets (AIS, APRS, LoRa, M17, Meshtastic, Inmarsat, radiosondes) and get_map_items "
|
|
"(aircraft, ships, sondes, satellites) return what running plugins have decoded.\n"
|
|
"\n"
|
|
"Frequencies are in Hz. Direction is \"rx\" or \"tx\". Creation tools wait for SDRangel to finish and return the new index; "
|
|
"setting changes are applied asynchronously.";
|
|
}
|
|
|
|
QJsonValue MCPProtocol::toolsList(const QJsonObject& params)
|
|
{
|
|
(void) params;
|
|
QJsonObject result;
|
|
result["tools"] = m_tools.listTools();
|
|
return result;
|
|
}
|
|
|
|
QJsonValue MCPProtocol::toolsCall(const QJsonObject& params)
|
|
{
|
|
QString name = params["name"].toString();
|
|
|
|
if (name.isEmpty()) {
|
|
throw MCPError(InvalidParams, "Invalid params: missing tool name");
|
|
}
|
|
|
|
return m_tools.callTool(name, params["arguments"].toObject());
|
|
}
|
|
|
|
QJsonObject MCPProtocol::textContent(const QString& text)
|
|
{
|
|
QJsonObject content;
|
|
content["type"] = "text";
|
|
content["text"] = text;
|
|
return content;
|
|
}
|
|
|
|
struct MCPResourceDef {
|
|
const char *uri;
|
|
const char *name;
|
|
const char *description;
|
|
};
|
|
|
|
static const MCPResourceDef staticResources[] = {
|
|
{"sdrangel://instance", "Instance summary", "Version, device sets with their devices and channels, and features currently configured"},
|
|
{"sdrangel://plugins/devices", "Available sampling devices", "SDR hardware (and software sources/sinks) that can be selected in a device set"},
|
|
{"sdrangel://plugins/channels", "Available channel types", "Demodulator and modulator plugins that can be added to a device set"},
|
|
{"sdrangel://plugins/features", "Available feature types", "Feature plugins that can be added to the feature set"},
|
|
{"sdrangel://presets", "Presets", "Saved device set presets (device and channel settings) grouped by name"},
|
|
{"sdrangel://configurations", "Configurations", "Saved whole-instance configurations grouped by name"},
|
|
{"sdrangel://guide", "Receiving guide", "Which demodulator and frequency to use for a given signal, the minimum sample rate some modes need, and why nothing is received"},
|
|
{"sdrangel://docs", "Documentation index", "Documentation available for the devices, channels and features registered in this instance, and for GUI pages such as the spectrum display, with section headings"},
|
|
{"sdrangel://packets", "Recent packets", "The most recent packets decoded by AIS, packet (AX.25/APRS), LoRa, M17, Meshtastic, MeshCore, Inmarsat and radiosonde demodulators"},
|
|
{"sdrangel://map/items", "Map items", "Objects currently plotted on the map: aircraft, ships, APRS stations, radiosondes, satellites, beacons"},
|
|
};
|
|
|
|
QJsonValue MCPProtocol::resourcesList(const QJsonObject& params)
|
|
{
|
|
(void) params;
|
|
QJsonArray resources;
|
|
|
|
for (const auto& def : staticResources)
|
|
{
|
|
QJsonObject resource;
|
|
resource["uri"] = def.uri;
|
|
resource["name"] = def.name;
|
|
resource["description"] = def.description;
|
|
resource["mimeType"] = "application/json";
|
|
resources.append(resource);
|
|
}
|
|
|
|
QJsonObject result;
|
|
result["resources"] = resources;
|
|
return result;
|
|
}
|
|
|
|
QJsonValue MCPProtocol::resourcesTemplatesList(const QJsonObject& params)
|
|
{
|
|
(void) params;
|
|
QJsonArray templates;
|
|
|
|
QJsonObject schema;
|
|
schema["uriTemplate"] = "sdrangel://schema/{type}";
|
|
schema["name"] = "Settings schema";
|
|
schema["description"] = "Documentation of the settings, report and actions keys for a device (hardware id such as RTLSDR), channel (type such as ADSBDemod) or feature (type such as Map)";
|
|
schema["mimeType"] = "text/plain";
|
|
templates.append(schema);
|
|
|
|
QJsonObject deviceset;
|
|
deviceset["uriTemplate"] = "sdrangel://deviceset/{deviceSetIndex}";
|
|
deviceset["name"] = "Device set";
|
|
deviceset["description"] = "Sampling device and channels of a device set";
|
|
deviceset["mimeType"] = "application/json";
|
|
templates.append(deviceset);
|
|
|
|
QJsonObject docs;
|
|
docs["uriTemplate"] = "sdrangel://docs/{kind}/{id}";
|
|
docs["name"] = "Plugin documentation";
|
|
docs["description"] = "User documentation (readme) of a plugin: kind is device, channel or feature and id is the type id (e.g. channel/ADSBDemod, device/RTLSDR, feature/Map)";
|
|
docs["mimeType"] = "text/markdown";
|
|
templates.append(docs);
|
|
|
|
QJsonObject result;
|
|
result["resourceTemplates"] = templates;
|
|
return result;
|
|
}
|
|
|
|
QJsonValue MCPProtocol::resourcesRead(const QJsonObject& params)
|
|
{
|
|
QString uri = params["uri"].toString();
|
|
QJsonObject content;
|
|
content["uri"] = uri;
|
|
|
|
if (uri == "sdrangel://instance")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.getInstanceSummary()).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri == "sdrangel://plugins/devices")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.getAvailableDevices()).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri == "sdrangel://plugins/channels")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.getAvailableChannels()).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri == "sdrangel://plugins/features")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.getAvailableFeatures()).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri == "sdrangel://presets")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.getPresets()).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri == "sdrangel://configurations")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.getConfigurations()).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri.startsWith("sdrangel://schema/"))
|
|
{
|
|
content["mimeType"] = "text/plain";
|
|
content["text"] = m_tools.describeType(uri.mid(QString("sdrangel://schema/").size()), "");
|
|
}
|
|
else if (uri == "sdrangel://guide")
|
|
{
|
|
content["mimeType"] = "text/markdown";
|
|
content["text"] = m_tools.docs().guide();
|
|
}
|
|
else if (uri == "sdrangel://packets")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.dataFeed().getPackets(QString(), QString(), 100, 0)).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri == "sdrangel://map/items")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.dataFeed().getMapItems(QString(), QString(), 500, false)).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri == "sdrangel://docs")
|
|
{
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.docs().index()).toJson(QJsonDocument::Compact));
|
|
}
|
|
else if (uri.startsWith("sdrangel://docs/"))
|
|
{
|
|
QString rest = uri.mid(QString("sdrangel://docs/").size());
|
|
QString kind = rest.section('/', 0, 0);
|
|
QString id = rest.section('/', 1);
|
|
const MCPDocs::Doc *doc = m_tools.docs().find(id, kind);
|
|
|
|
if (!doc) {
|
|
throw MCPError(ResourceNotFound, QString("Resource not found: %1. Read sdrangel://docs for the available documentation").arg(uri));
|
|
}
|
|
|
|
content["mimeType"] = "text/markdown";
|
|
content["text"] = m_tools.docs().text(*doc);
|
|
}
|
|
else if (uri.startsWith("sdrangel://deviceset/"))
|
|
{
|
|
bool ok;
|
|
int index = uri.mid(QString("sdrangel://deviceset/").size()).toInt(&ok);
|
|
|
|
if (!ok) {
|
|
throw MCPError(InvalidParams, QString("Invalid device set index in %1").arg(uri));
|
|
}
|
|
|
|
content["mimeType"] = "application/json";
|
|
content["text"] = QString(QJsonDocument(m_tools.getDeviceSet(index)).toJson(QJsonDocument::Compact));
|
|
}
|
|
else
|
|
{
|
|
throw MCPError(ResourceNotFound, QString("Resource not found: %1").arg(uri));
|
|
}
|
|
|
|
QJsonArray contents;
|
|
contents.append(content);
|
|
QJsonObject result;
|
|
result["contents"] = contents;
|
|
return result;
|
|
}
|
|
|
|
QJsonObject MCPProtocol::promptMessage(const QString& text)
|
|
{
|
|
QJsonObject message;
|
|
message["role"] = "user";
|
|
message["content"] = textContent(text);
|
|
return message;
|
|
}
|
|
|
|
QJsonValue MCPProtocol::promptsList(const QJsonObject& params)
|
|
{
|
|
(void) params;
|
|
QJsonArray prompts;
|
|
|
|
{
|
|
QJsonObject arg1;
|
|
arg1["name"] = "signal";
|
|
arg1["description"] = "What to receive, e.g. ADS-B, AIS, APRS, broadcast FM, an airband frequency in MHz";
|
|
arg1["required"] = true;
|
|
QJsonObject arg2;
|
|
arg2["name"] = "device";
|
|
arg2["description"] = "Which SDR to use (hardware type or serial). Optional: the first available receiver is used otherwise";
|
|
arg2["required"] = false;
|
|
QJsonObject prompt;
|
|
prompt["name"] = "setup_receiver";
|
|
prompt["title"] = "Set up a receiver";
|
|
prompt["description"] = "Configure an SDR device and demodulator to receive a given signal or protocol";
|
|
prompt["arguments"] = QJsonArray({arg1, arg2});
|
|
prompts.append(prompt);
|
|
}
|
|
{
|
|
QJsonObject arg1;
|
|
arg1["name"] = "frequency";
|
|
arg1["description"] = "Centre frequency to record, e.g. 126 MHz";
|
|
arg1["required"] = true;
|
|
QJsonObject arg2;
|
|
arg2["name"] = "duration";
|
|
arg2["description"] = "How long to record for, e.g. 30 seconds";
|
|
arg2["required"] = false;
|
|
QJsonObject arg3;
|
|
arg3["name"] = "file";
|
|
arg3["description"] = "Output file path";
|
|
arg3["required"] = false;
|
|
QJsonObject prompt;
|
|
prompt["name"] = "record_iq";
|
|
prompt["title"] = "Record IQ data";
|
|
prompt["description"] = "Record baseband IQ samples from an SDR to a file";
|
|
prompt["arguments"] = QJsonArray({arg1, arg2, arg3});
|
|
prompts.append(prompt);
|
|
}
|
|
{
|
|
QJsonObject prompt;
|
|
prompt["name"] = "explain_configuration";
|
|
prompt["title"] = "Explain the current configuration";
|
|
prompt["description"] = "Describe what SDRangel is currently set up to do";
|
|
prompt["arguments"] = QJsonArray();
|
|
prompts.append(prompt);
|
|
}
|
|
|
|
QJsonObject result;
|
|
result["prompts"] = prompts;
|
|
return result;
|
|
}
|
|
|
|
QJsonValue MCPProtocol::promptsGet(const QJsonObject& params)
|
|
{
|
|
QString name = params["name"].toString();
|
|
QJsonObject args = params["arguments"].toObject();
|
|
QJsonObject result;
|
|
QJsonArray messages;
|
|
|
|
if (name == "setup_receiver")
|
|
{
|
|
QString signal = args["signal"].toString();
|
|
QString device = args["device"].toString();
|
|
result["description"] = QString("Set up SDRangel to receive %1").arg(signal);
|
|
messages.append(promptMessage(QString(
|
|
"Set up SDRangel to receive: %1.%2\n\n"
|
|
"1. If you do not already know the frequency and which demodulator this signal needs, call get_receiving_guide.\n"
|
|
"2. Call listen with that frequency and mode%3. It picks a device, sets a sample rate that suits the mode, adds the "
|
|
"demodulator, starts everything and reports the run state and signal level, so one call replaces the whole "
|
|
"device set, settings and channel sequence. Modes are bfm, wfm, nfm, am, ssb, usb, lsb, dab, adsb, ais and dsd, "
|
|
"or any channel type id from list_channel_types.\n"
|
|
"3. Read what listen returned. If it reports a note, act on it. Adjust with set_channel_settings if the signal needs "
|
|
"it (squelch, volume, rfBandwidth) and use get_channel_report to confirm.\n"
|
|
"4. Summarise what is now running, including anything that did not work.")
|
|
.arg(signal)
|
|
.arg(device.isEmpty() ? "" : QString(" Use this device: %1.").arg(device))
|
|
.arg(device.isEmpty() ? "" : QString(" and device \"%1\"").arg(device))));
|
|
}
|
|
else if (name == "record_iq")
|
|
{
|
|
QString frequency = args["frequency"].toString();
|
|
QString duration = args["duration"].toString();
|
|
QString file = args["file"].toString();
|
|
result["description"] = QString("Record IQ data at %1").arg(frequency);
|
|
messages.append(promptMessage(QString(
|
|
"Record baseband IQ data from an SDR centred on %1%2%3.\n\n"
|
|
"1. Call get_status. If a receiver is already running on that frequency, record from it. Otherwise get one running: "
|
|
"listen is the quickest way and confirms a signal is there, or use add_deviceset, set_device_settings and "
|
|
"start_device if you want no demodulator.\n"
|
|
"2. Call record_iq with the device set, duration and file name for a short capture, or start_iq_recording and "
|
|
"stop_iq_recording for a longer one. It adds and removes its own File Sink channel; get_server_status shows the "
|
|
"directory the files go to.\n"
|
|
"3. Report the files written with their size and duration. Warn the user if the capture will be large: the file grows "
|
|
"by roughly four bytes per sample, so a 2 MS/s capture uses about 8 MB per second.")
|
|
.arg(frequency)
|
|
.arg(duration.isEmpty() ? "" : QString(" for %1").arg(duration))
|
|
.arg(file.isEmpty() ? "" : QString(" to the file %1").arg(file))));
|
|
}
|
|
else if (name == "explain_configuration")
|
|
{
|
|
result["description"] = "Explain the current SDRangel configuration";
|
|
messages.append(promptMessage(
|
|
"Call get_status: it returns one line per device set and feature, with the device, its frequency, its run state and "
|
|
"the channels on it. That is usually enough. Only call get_device_settings, get_channel_settings or get_channel_report "
|
|
"for a device set the user asks about in more detail, since those return every key and are far larger.\n\n"
|
|
"Then explain in plain language what SDRangel is currently configured to do: which SDRs are in use, what frequencies they "
|
|
"are tuned to, which demodulators are running and what they are decoding, and whether the devices are running or stopped."));
|
|
}
|
|
else
|
|
{
|
|
throw MCPError(InvalidParams, QString("Prompt not found: %1").arg(name));
|
|
}
|
|
|
|
result["messages"] = messages;
|
|
return result;
|
|
}
|