Add the digital interface, running MMTTY under Wine
MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and exchanges window messages with the engine. A Linux process cannot load that control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes lines over its standard input and output. docs/digital-bridge.md states the protocol and what the control needs. The window is N1MM's: receive pane with coloured callsigns, grab list, call stacking, twenty-four macro buttons and the engine controls. One left click copies what is under it — a callsign to the callsign box, anything else to the exchange box the contest keeps for that kind of value. Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the prefix first if it is not there. The engine, the bridge and the control start at the copies shipped beside the program. The bridge reads the control's own events. OnTranslateMessage carries only the messages the control has no event for, so nothing was ever decoded through it. hamlib's data mode names read as digital modes now, and a mode typed into the callsign box changes mode the way a frequency changes band, so a station with no radio can reach RTTY at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ obj/
|
|||||||
*.user
|
*.user
|
||||||
/N1MM/
|
/N1MM/
|
||||||
/N1MMsln/
|
/N1MMsln/
|
||||||
|
/bridge/nonemm-mmtty-bridge.exe
|
||||||
|
/mmtty/
|
||||||
|
/bridge/XMMT.ocx
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<Project Path="src/Nonemm.Spotting/Nonemm.Spotting.csproj" />
|
<Project Path="src/Nonemm.Spotting/Nonemm.Spotting.csproj" />
|
||||||
<Project Path="src/Nonemm.Network/Nonemm.Network.csproj" />
|
<Project Path="src/Nonemm.Network/Nonemm.Network.csproj" />
|
||||||
<Project Path="src/Nonemm.Keying/Nonemm.Keying.csproj" />
|
<Project Path="src/Nonemm.Keying/Nonemm.Keying.csproj" />
|
||||||
|
<Project Path="src/Nonemm.Digital/Nonemm.Digital.csproj" />
|
||||||
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
||||||
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
<Project Path="tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj" />
|
<Project Path="tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj" />
|
<Project Path="tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj" />
|
<Project Path="tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj" />
|
||||||
|
<Project Path="tests/Nonemm.Digital.Tests/Nonemm.Digital.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Rig.Tests/Nonemm.Rig.Tests.csproj" />
|
<Project Path="tests/Nonemm.Rig.Tests/Nonemm.Rig.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj" />
|
<Project Path="tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.App.Tests/Nonemm.App.Tests.csproj" />
|
<Project Path="tests/Nonemm.App.Tests/Nonemm.App.Tests.csproj" />
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -30,8 +30,11 @@ Nonemm.slnx`.
|
|||||||
To work a contest: **Config → Station** for your callsign and zones, then
|
To work a contest: **Config → Station** for your callsign and zones, then
|
||||||
**File → New Database** and **File → New Contest**. In the entry window, type a
|
**File → New Database** and **File → New Contest**. In the entry window, type a
|
||||||
callsign and press **space** to move to the exchange, then **Enter** to log.
|
callsign and press **space** to move to the exchange, then **Enter** to log.
|
||||||
Type a frequency into the callsign box and press Enter to change band. **View**
|
Type a frequency into the callsign box and press Enter to change band, or a
|
||||||
opens the log, check, bandmap, score and telnet windows.
|
mode — `CW`, `SSB`, `USB`, `LSB`, `RTTY`, `FT8` and the rest — to change mode.
|
||||||
|
A radio that is connected is moved with it; with no radio it is the way to
|
||||||
|
reach a mode at all. **View** opens the log, check, bandmap, score and telnet
|
||||||
|
windows.
|
||||||
|
|
||||||
Everything that judges a station — the bar under the callsign box, a row in the
|
Everything that judges a station — the bar under the callsign box, a row in the
|
||||||
check window, a spot on the bandmap, a line in the log — is coloured by the same
|
check window, a spot on the bandmap, a line in the log — is coloured by the same
|
||||||
@@ -44,13 +47,43 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
|
|||||||
| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, OK/OM DX (CW), YOTA, IOTA, EU DX Contest, CQ 160, ARRL 10M, REF, Ukrainian DX, Russian DX RTTY, RDAC, YO DX HF, Oceania DX, All Asian, SARTG RTTY, BARTG Sprint, Mexico RTTY, IARU Region 1 Field Day, general logging, and user-defined `.udc` contests |
|
| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, OK/OM DX (CW), YOTA, IOTA, EU DX Contest, CQ 160, ARRL 10M, REF, Ukrainian DX, Russian DX RTTY, RDAC, YO DX HF, Oceania DX, All Asian, SARTG RTTY, BARTG Sprint, Mexico RTTY, IARU Region 1 Field Day, general logging, and user-defined `.udc` contests |
|
||||||
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
|
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
|
||||||
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
|
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
|
||||||
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet |
|
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet, digital interface |
|
||||||
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
|
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
|
||||||
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
|
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
|
||||||
| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
|
| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
|
||||||
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
|
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
|
||||||
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
|
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
|
||||||
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros |
|
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros |
|
||||||
|
| Digital | N1MM's digital interface window, running MMTTY or 2Tone under Wine through the same `XMMT.ocx` N1MM drives them with |
|
||||||
|
|
||||||
|
### The digital window
|
||||||
|
|
||||||
|
**Window ▸ Digital Interface** opens N1MM's digital interface. **Engine ▸
|
||||||
|
Settings** says where MMTTY or 2Tone is; **Engine ▸ Start** runs it under Wine.
|
||||||
|
`docs/digital-bridge.md` covers setting the Wine prefix up.
|
||||||
|
|
||||||
|
What the window does, and what each part of it is for:
|
||||||
|
|
||||||
|
| Part | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Receive pane | the decoded text. Every word shaped like a callsign is coloured by what working it would bring, the same four colours the entry window uses, and our own call is coloured as ours |
|
||||||
|
| Left click on a call | puts that call in the entry window. With call stacking on, the call already being typed goes on the stack first, so neither is lost |
|
||||||
|
| Left click on anything else | copies the word into the exchange box the contest keeps for that kind of value: a serial number to the number box, a section or a state to the box that holds the list it is in. The box the cursor is in takes the word where it fits, which is what N1MM does. 599, a colon and the Z on a time are cut off first |
|
||||||
|
| Left click on a gap | stops the pane scrolling so a call can be read while text keeps arriving. Moving the pointer off the pane starts it again |
|
||||||
|
| Right click | presses Enter in the entry window, which is ESM's next message. Off by default: Options ▸ Right click presses Enter |
|
||||||
|
| Pointer | the word under the pointer is underlined and shown at the bottom left |
|
||||||
|
| Grab list | the calls heard, newest first and coloured the same way. Click one to work it, right-click to drop it, or press Grab for the next one |
|
||||||
|
| Macro buttons | three rows of eight. Right-click any of them to edit all twenty-four. `{TX}` starts the transmission, `{RX}` ends it, `{ENTER}` sends a carriage return, and the rest are the macros the function keys use |
|
||||||
|
| Transmit pane | what is typed goes out as it is typed. Escape stops sending |
|
||||||
|
| Buttons | Clr RX and CLR empty the two panes, Align puts the demodulator back on the mark tone, TX and RX key and unkey, Ham loads MMTTY's default profile, Lock is AFC and Rev swaps the tones |
|
||||||
|
|
||||||
|
Options ▸ Grab list decides which calls are kept: everything heard, everything
|
||||||
|
but dupes, or only calls the callsign database knows. Call stacking chooses the
|
||||||
|
order stacked calls come back off: multipliers first, first in, last in, or off.
|
||||||
|
|
||||||
|
On a digital mode the function keys, ESM and the QTC window all send through the
|
||||||
|
engine rather than through the CW keyer, so the whole entry window works the way
|
||||||
|
it does on CW.
|
||||||
|
|
||||||
### The country file and the callsign database
|
### The country file and the callsign database
|
||||||
|
|
||||||
@@ -776,6 +809,7 @@ windows redraw where they stand.
|
|||||||
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
|
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
|
||||||
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
|
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
|
||||||
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer |
|
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer |
|
||||||
|
| `Nonemm.Digital` | MMTTY and 2Tone, over the Wine bridge in `bridge/` |
|
||||||
| `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit |
|
| `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit |
|
||||||
| `Nonemm.App` | The Avalonia windows |
|
| `Nonemm.App` | The Avalonia windows |
|
||||||
|
|
||||||
|
|||||||
44
bridge/ControlSite.cpp
Normal file
44
bridge/ControlSite.cpp
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#include "ControlSite.h"
|
||||||
|
|
||||||
|
STDMETHODIMP ControlSite::QueryInterface(REFIID iid, void** out) {
|
||||||
|
if (iid == IID_IUnknown || iid == IID_IOleClientSite) {
|
||||||
|
*out = static_cast<IOleClientSite*>(this);
|
||||||
|
} else if (iid == IID_IOleWindow || iid == IID_IOleInPlaceSite) {
|
||||||
|
*out = static_cast<IOleInPlaceSite*>(this);
|
||||||
|
} else if (iid == IID_IOleInPlaceUIWindow || iid == IID_IOleInPlaceFrame) {
|
||||||
|
*out = static_cast<IOleInPlaceFrame*>(this);
|
||||||
|
} else {
|
||||||
|
*out = nullptr;
|
||||||
|
return E_NOINTERFACE;
|
||||||
|
}
|
||||||
|
AddRef();
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP_(ULONG) ControlSite::AddRef() {
|
||||||
|
return ++references_;
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP_(ULONG) ControlSite::Release() {
|
||||||
|
ULONG left = --references_;
|
||||||
|
if (left == 0) {
|
||||||
|
delete this;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP ControlSite::GetWindowContext(IOleInPlaceFrame** frame,
|
||||||
|
IOleInPlaceUIWindow** document, LPRECT position,
|
||||||
|
LPRECT clip, LPOLEINPLACEFRAMEINFO info) {
|
||||||
|
*frame = static_cast<IOleInPlaceFrame*>(this);
|
||||||
|
AddRef();
|
||||||
|
*document = nullptr;
|
||||||
|
GetClientRect(parent_, position);
|
||||||
|
*clip = *position;
|
||||||
|
info->cb = sizeof(OLEINPLACEFRAMEINFO);
|
||||||
|
info->fMDIApp = FALSE;
|
||||||
|
info->hwndFrame = parent_;
|
||||||
|
info->haccel = nullptr;
|
||||||
|
info->cAccelEntries = 0;
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
70
bridge/ControlSite.h
Normal file
70
bridge/ControlSite.h
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <ole2.h>
|
||||||
|
|
||||||
|
// The least a window has to provide for an ActiveX control to activate in it.
|
||||||
|
// XMMT.ocx draws nothing we look at, so every method that decides layout,
|
||||||
|
// menus or scrolling answers with the default.
|
||||||
|
class ControlSite : public IOleClientSite, public IOleInPlaceSite, public IOleInPlaceFrame {
|
||||||
|
public:
|
||||||
|
explicit ControlSite(HWND parent) : parent_(parent) {}
|
||||||
|
virtual ~ControlSite() = default;
|
||||||
|
|
||||||
|
STDMETHODIMP QueryInterface(REFIID iid, void** out) override;
|
||||||
|
STDMETHODIMP_(ULONG) AddRef() override;
|
||||||
|
STDMETHODIMP_(ULONG) Release() override;
|
||||||
|
|
||||||
|
// IOleClientSite
|
||||||
|
STDMETHODIMP SaveObject() override { return S_OK; }
|
||||||
|
STDMETHODIMP GetMoniker(DWORD, DWORD, IMoniker** moniker) override {
|
||||||
|
*moniker = nullptr;
|
||||||
|
return E_NOTIMPL;
|
||||||
|
}
|
||||||
|
STDMETHODIMP GetContainer(IOleContainer** container) override {
|
||||||
|
*container = nullptr;
|
||||||
|
return E_NOINTERFACE;
|
||||||
|
}
|
||||||
|
STDMETHODIMP ShowObject() override { return S_OK; }
|
||||||
|
STDMETHODIMP OnShowWindow(BOOL) override { return S_OK; }
|
||||||
|
STDMETHODIMP RequestNewObjectLayout() override { return E_NOTIMPL; }
|
||||||
|
|
||||||
|
// IOleWindow, shared by the site and the frame
|
||||||
|
STDMETHODIMP GetWindow(HWND* window) override {
|
||||||
|
*window = parent_;
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
STDMETHODIMP ContextSensitiveHelp(BOOL) override { return E_NOTIMPL; }
|
||||||
|
|
||||||
|
// IOleInPlaceSite
|
||||||
|
STDMETHODIMP CanInPlaceActivate() override { return S_OK; }
|
||||||
|
STDMETHODIMP OnInPlaceActivate() override { return S_OK; }
|
||||||
|
STDMETHODIMP OnUIActivate() override { return S_OK; }
|
||||||
|
STDMETHODIMP GetWindowContext(IOleInPlaceFrame** frame, IOleInPlaceUIWindow** document,
|
||||||
|
LPRECT position, LPRECT clip,
|
||||||
|
LPOLEINPLACEFRAMEINFO info) override;
|
||||||
|
STDMETHODIMP Scroll(SIZE) override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP OnUIDeactivate(BOOL) override { return S_OK; }
|
||||||
|
STDMETHODIMP OnInPlaceDeactivate() override { return S_OK; }
|
||||||
|
STDMETHODIMP DiscardUndoState() override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP DeactivateAndUndo() override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP OnPosRectChange(LPCRECT) override { return S_OK; }
|
||||||
|
|
||||||
|
// IOleInPlaceUIWindow
|
||||||
|
STDMETHODIMP GetBorder(LPRECT) override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP RequestBorderSpace(LPCBORDERWIDTHS) override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP SetBorderSpace(LPCBORDERWIDTHS) override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP SetActiveObject(IOleInPlaceActiveObject*, LPCOLESTR) override { return S_OK; }
|
||||||
|
|
||||||
|
// IOleInPlaceFrame
|
||||||
|
STDMETHODIMP InsertMenus(HMENU, LPOLEMENUGROUPWIDTHS) override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP SetMenu(HMENU, HOLEMENU, HWND) override { return S_OK; }
|
||||||
|
STDMETHODIMP RemoveMenus(HMENU) override { return E_NOTIMPL; }
|
||||||
|
STDMETHODIMP SetStatusText(LPCOLESTR) override { return S_OK; }
|
||||||
|
STDMETHODIMP EnableModeless(BOOL) override { return S_OK; }
|
||||||
|
STDMETHODIMP TranslateAccelerator(LPMSG, WORD) override { return S_FALSE; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
HWND parent_;
|
||||||
|
ULONG references_ = 1;
|
||||||
|
};
|
||||||
123
bridge/EventSink.cpp
Normal file
123
bridge/EventSink.cpp
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#include "EventSink.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Turns a dispid into the event name the type library gives it, so the caller
|
||||||
|
// works in names rather than in numbers the OCX is free to renumber.
|
||||||
|
class EventSink : public IDispatch {
|
||||||
|
public:
|
||||||
|
EventSink(ITypeInfo* events, SinkHandler handler)
|
||||||
|
: events_(events), handler_(std::move(handler)) {
|
||||||
|
if (events_ != nullptr) {
|
||||||
|
events_->AddRef();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual ~EventSink() {
|
||||||
|
if (events_ != nullptr) {
|
||||||
|
events_->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP QueryInterface(REFIID iid, void** out) override {
|
||||||
|
if (iid == IID_IUnknown || iid == IID_IDispatch || iid == source_) {
|
||||||
|
*out = static_cast<IDispatch*>(this);
|
||||||
|
AddRef();
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
*out = nullptr;
|
||||||
|
return E_NOINTERFACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP_(ULONG) AddRef() override { return ++references_; }
|
||||||
|
|
||||||
|
STDMETHODIMP_(ULONG) Release() override {
|
||||||
|
ULONG left = --references_;
|
||||||
|
if (left == 0) {
|
||||||
|
delete this;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP GetTypeInfoCount(UINT* count) override {
|
||||||
|
*count = 0;
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP GetTypeInfo(UINT, LCID, ITypeInfo**) override { return E_NOTIMPL; }
|
||||||
|
|
||||||
|
STDMETHODIMP GetIDsOfNames(REFIID, LPOLESTR*, UINT, LCID, DISPID*) override {
|
||||||
|
return E_NOTIMPL;
|
||||||
|
}
|
||||||
|
|
||||||
|
STDMETHODIMP Invoke(DISPID dispid, REFIID, LCID, WORD, DISPPARAMS* arguments, VARIANT*,
|
||||||
|
EXCEPINFO*, UINT*) override {
|
||||||
|
handler_(nameOf(dispid), arguments);
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSource(REFIID source) { source_ = source; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::wstring nameOf(DISPID dispid) {
|
||||||
|
BSTR name = nullptr;
|
||||||
|
if (events_ == nullptr ||
|
||||||
|
FAILED(events_->GetDocumentation(dispid, &name, nullptr, nullptr, nullptr))) {
|
||||||
|
return L"";
|
||||||
|
}
|
||||||
|
std::wstring found(name, SysStringLen(name));
|
||||||
|
SysFreeString(name);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
ITypeInfo* events_;
|
||||||
|
SinkHandler handler_;
|
||||||
|
IID source_ = IID_NULL;
|
||||||
|
ULONG references_ = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ITypeInfo* sourceTypeInfo(IDispatch* control, IID* source) {
|
||||||
|
IProvideClassInfo2* provider = nullptr;
|
||||||
|
if (FAILED(control->QueryInterface(IID_IProvideClassInfo2,
|
||||||
|
reinterpret_cast<void**>(&provider)))) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
ITypeInfo* events = nullptr;
|
||||||
|
if (SUCCEEDED(provider->GetGUID(GUIDKIND_DEFAULT_SOURCE_DISP_IID, source))) {
|
||||||
|
ITypeInfo* coclass = nullptr;
|
||||||
|
if (SUCCEEDED(provider->GetClassInfo(&coclass))) {
|
||||||
|
TYPEATTR* attributes = nullptr;
|
||||||
|
if (SUCCEEDED(coclass->GetTypeAttr(&attributes))) {
|
||||||
|
for (UINT i = 0; i < attributes->cImplTypes && events == nullptr; i++) {
|
||||||
|
HREFTYPE reference = 0;
|
||||||
|
ITypeInfo* implemented = nullptr;
|
||||||
|
if (FAILED(coclass->GetRefTypeOfImplType(i, &reference)) ||
|
||||||
|
FAILED(coclass->GetRefTypeInfo(reference, &implemented))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TYPEATTR* theirs = nullptr;
|
||||||
|
if (SUCCEEDED(implemented->GetTypeAttr(&theirs))) {
|
||||||
|
if (theirs->guid == *source) {
|
||||||
|
events = implemented;
|
||||||
|
implemented->AddRef();
|
||||||
|
}
|
||||||
|
implemented->ReleaseTypeAttr(theirs);
|
||||||
|
}
|
||||||
|
implemented->Release();
|
||||||
|
}
|
||||||
|
coclass->ReleaseTypeAttr(attributes);
|
||||||
|
}
|
||||||
|
coclass->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider->Release();
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
IDispatch* createEventSink(ITypeInfo* events, REFIID source, SinkHandler handler) {
|
||||||
|
EventSink* sink = new EventSink(events, std::move(handler));
|
||||||
|
sink->setSource(source);
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
18
bridge/EventSink.h
Normal file
18
bridge/EventSink.h
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <ocidl.h>
|
||||||
|
#include <ole2.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using SinkHandler = std::function<void(const std::wstring& name, DISPPARAMS* arguments)>;
|
||||||
|
|
||||||
|
// The events interface the control declares as its default source. Its type
|
||||||
|
// info is where the event names come from.
|
||||||
|
ITypeInfo* sourceTypeInfo(IDispatch* control, IID* source);
|
||||||
|
|
||||||
|
// A sink that turns a dispid into the event name before handing the call on, so
|
||||||
|
// the caller works in names rather than in numbers the OCX is free to renumber.
|
||||||
|
IDispatch* createEventSink(ITypeInfo* events, REFIID source, SinkHandler handler);
|
||||||
10
bridge/Makefile
Normal file
10
bridge/Makefile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# The bridge is a 32-bit Windows program because XMMT.ocx is a 32-bit control.
|
||||||
|
CXX = i686-w64-mingw32-g++
|
||||||
|
CXXFLAGS = -std=c++17 -O2 -Wall -Wextra -DUNICODE -D_UNICODE
|
||||||
|
LDFLAGS = -static-libgcc -static-libstdc++ -static -lole32 -loleaut32 -luuid
|
||||||
|
|
||||||
|
nonemm-mmtty-bridge.exe: main.cpp XmmrControl.cpp EventSink.cpp ControlSite.cpp Protocol.cpp
|
||||||
|
$(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f nonemm-mmtty-bridge.exe
|
||||||
86
bridge/Protocol.cpp
Normal file
86
bridge/Protocol.cpp
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#include "Protocol.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace protocol {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::mutex writing;
|
||||||
|
|
||||||
|
std::string escape(const std::string& field) {
|
||||||
|
std::string escaped;
|
||||||
|
for (char c : field) {
|
||||||
|
switch (c) {
|
||||||
|
case '\\': escaped += "\\\\"; break;
|
||||||
|
case '\t': escaped += "\\t"; break;
|
||||||
|
case '\r': escaped += "\\r"; break;
|
||||||
|
case '\n': escaped += "\\n"; break;
|
||||||
|
default: escaped += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string unescape(const std::string& field) {
|
||||||
|
std::string plain;
|
||||||
|
for (size_t i = 0; i < field.size(); i++) {
|
||||||
|
if (field[i] != '\\' || i + 1 == field.size()) {
|
||||||
|
plain += field[i];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (field[++i]) {
|
||||||
|
case 't': plain += '\t'; break;
|
||||||
|
case 'r': plain += '\r'; break;
|
||||||
|
case 'n': plain += '\n'; break;
|
||||||
|
default: plain += field[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string Line::field(size_t index) const {
|
||||||
|
return index < fields.size() ? fields[index] : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
|
long Line::number(size_t index) const {
|
||||||
|
return std::strtol(field(index).c_str(), nullptr, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
Line read(const std::string& text) {
|
||||||
|
Line line;
|
||||||
|
size_t start = 0;
|
||||||
|
while (true) {
|
||||||
|
size_t tab = text.find('\t', start);
|
||||||
|
std::string part = text.substr(start, tab == std::string::npos ? tab : tab - start);
|
||||||
|
if (start == 0) {
|
||||||
|
line.verb = part;
|
||||||
|
} else {
|
||||||
|
line.fields.push_back(unescape(part));
|
||||||
|
}
|
||||||
|
if (tab == std::string::npos) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
start = tab + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void write(const std::string& verb, const std::vector<std::string>& fields) {
|
||||||
|
std::string line = verb;
|
||||||
|
for (const std::string& field : fields) {
|
||||||
|
line += '\t' + escape(field);
|
||||||
|
}
|
||||||
|
line += '\n';
|
||||||
|
std::lock_guard<std::mutex> held(writing);
|
||||||
|
std::fwrite(line.data(), 1, line.size(), stdout);
|
||||||
|
std::fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void write(const std::string& verb, long value) {
|
||||||
|
write(verb, {std::to_string(value)});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace protocol
|
||||||
27
bridge/Protocol.h
Normal file
27
bridge/Protocol.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// One line of the bridge protocol: a verb and its fields, separated by tabs.
|
||||||
|
// Nonemm.Digital/BridgeLine.cs is the other end of the same format.
|
||||||
|
namespace protocol {
|
||||||
|
|
||||||
|
struct Line {
|
||||||
|
std::string verb;
|
||||||
|
std::vector<std::string> fields;
|
||||||
|
|
||||||
|
// Empty when the field is not there, which keeps a short line from being an
|
||||||
|
// error on either side.
|
||||||
|
std::string field(size_t index) const;
|
||||||
|
long number(size_t index) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
Line read(const std::string& line);
|
||||||
|
|
||||||
|
// Writes one line to standard output and flushes it. Safe to call from any
|
||||||
|
// thread.
|
||||||
|
void write(const std::string& verb, const std::vector<std::string>& fields = {});
|
||||||
|
void write(const std::string& verb, long value);
|
||||||
|
|
||||||
|
} // namespace protocol
|
||||||
205
bridge/XmmrControl.cpp
Normal file
205
bridge/XmmrControl.cpp
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
#include "XmmrControl.h"
|
||||||
|
|
||||||
|
#include <ocidl.h>
|
||||||
|
#include <olectl.h>
|
||||||
|
#include "EventSink.h"
|
||||||
|
std::string toUtf8(const wchar_t* text) {
|
||||||
|
if (text == nullptr) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
|
||||||
|
std::string converted(size > 0 ? size - 1 : 0, '\0');
|
||||||
|
WideCharToMultiByte(CP_UTF8, 0, text, -1, converted.data(), size, nullptr, nullptr);
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring toWide(const std::string& text) {
|
||||||
|
int size = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0);
|
||||||
|
std::wstring converted(size > 0 ? size - 1 : 0, L'\0');
|
||||||
|
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, converted.data(), size);
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XmmrControl::create(HWND parent, std::string* error) {
|
||||||
|
CLSID clsid;
|
||||||
|
HRESULT result = CLSIDFromProgID(L"XMMR.XMMRCtrl.1", &clsid);
|
||||||
|
if (FAILED(result)) {
|
||||||
|
*error = "XMMR.XMMRCtrl.1 is not registered in this Wine prefix";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
result = CoCreateInstance(clsid, nullptr, CLSCTX_INPROC_SERVER, IID_IOleObject,
|
||||||
|
reinterpret_cast<void**>(&object_));
|
||||||
|
if (FAILED(result)) {
|
||||||
|
*error = "XMMT.ocx would not load";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// MFC controls reject every dispatch call with E_UNEXPECTED until they are
|
||||||
|
// initialised, and this one is never loaded from a stream.
|
||||||
|
IPersistStreamInit* fresh = nullptr;
|
||||||
|
if (SUCCEEDED(object_->QueryInterface(IID_IPersistStreamInit,
|
||||||
|
reinterpret_cast<void**>(&fresh)))) {
|
||||||
|
fresh->InitNew();
|
||||||
|
fresh->Release();
|
||||||
|
}
|
||||||
|
OleSetContainedObject(object_, TRUE);
|
||||||
|
site_ = new ControlSite(parent);
|
||||||
|
object_->SetHostNames(L"Nonemm", L"Nonemm");
|
||||||
|
object_->SetClientSite(site_);
|
||||||
|
RECT area = {0, 0, 0, 0};
|
||||||
|
GetClientRect(parent, &area);
|
||||||
|
result = object_->DoVerb(OLEIVERB_INPLACEACTIVATE, nullptr, site_, 0, parent, &area);
|
||||||
|
if (FAILED(result)) {
|
||||||
|
*error = "the control would not activate";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (FAILED(object_->QueryInterface(IID_IDispatch, reinterpret_cast<void**>(&dispatch_)))) {
|
||||||
|
*error = "the control has no IDispatch";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return listen(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool XmmrControl::listen(std::string* error) {
|
||||||
|
IID source = IID_NULL;
|
||||||
|
ITypeInfo* events = sourceTypeInfo(dispatch_, &source);
|
||||||
|
IConnectionPointContainer* container = nullptr;
|
||||||
|
if (FAILED(dispatch_->QueryInterface(IID_IConnectionPointContainer,
|
||||||
|
reinterpret_cast<void**>(&container)))) {
|
||||||
|
*error = "the control has no connection points";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
HRESULT found = container->FindConnectionPoint(source, &point_);
|
||||||
|
container->Release();
|
||||||
|
if (FAILED(found)) {
|
||||||
|
*error = "the control's event interface was not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sink_ = createEventSink(events, source, handler_);
|
||||||
|
if (events != nullptr) {
|
||||||
|
events->Release();
|
||||||
|
}
|
||||||
|
if (FAILED(point_->Advise(sink_, &cookie_))) {
|
||||||
|
*error = "the control refused the event sink";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void XmmrControl::destroy() {
|
||||||
|
if (point_ != nullptr && cookie_ != 0) {
|
||||||
|
point_->Unadvise(cookie_);
|
||||||
|
cookie_ = 0;
|
||||||
|
}
|
||||||
|
if (point_ != nullptr) {
|
||||||
|
point_->Release();
|
||||||
|
point_ = nullptr;
|
||||||
|
}
|
||||||
|
if (sink_ != nullptr) {
|
||||||
|
sink_->Release();
|
||||||
|
sink_ = nullptr;
|
||||||
|
}
|
||||||
|
if (dispatch_ != nullptr) {
|
||||||
|
dispatch_->Release();
|
||||||
|
dispatch_ = nullptr;
|
||||||
|
}
|
||||||
|
if (object_ != nullptr) {
|
||||||
|
object_->SetClientSite(nullptr);
|
||||||
|
object_->Release();
|
||||||
|
object_ = nullptr;
|
||||||
|
}
|
||||||
|
if (site_ != nullptr) {
|
||||||
|
site_->Release();
|
||||||
|
site_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT XmmrControl::invoke(const wchar_t* name, WORD flags, DISPPARAMS* arguments,
|
||||||
|
VARIANT* result) {
|
||||||
|
if (dispatch_ == nullptr) {
|
||||||
|
return E_POINTER;
|
||||||
|
}
|
||||||
|
DISPID dispid = 0;
|
||||||
|
LPOLESTR wanted = const_cast<LPOLESTR>(name);
|
||||||
|
HRESULT found = dispatch_->GetIDsOfNames(IID_NULL, &wanted, 1, LOCALE_USER_DEFAULT, &dispid);
|
||||||
|
if (FAILED(found)) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return dispatch_->Invoke(dispid, IID_NULL, LOCALE_USER_DEFAULT, flags, arguments, result,
|
||||||
|
nullptr, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT XmmrControl::put(const wchar_t* name, VARIANT value) {
|
||||||
|
DISPID assigned = DISPID_PROPERTYPUT;
|
||||||
|
DISPPARAMS arguments = {&value, &assigned, 1, 1};
|
||||||
|
HRESULT result = invoke(name, DISPATCH_PROPERTYPUT, &arguments, nullptr);
|
||||||
|
VariantClear(&value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT XmmrControl::putBool(const wchar_t* name, bool value) {
|
||||||
|
VARIANT wanted;
|
||||||
|
VariantInit(&wanted);
|
||||||
|
wanted.vt = VT_BOOL;
|
||||||
|
wanted.boolVal = value ? VARIANT_TRUE : VARIANT_FALSE;
|
||||||
|
return put(name, wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT XmmrControl::putLong(const wchar_t* name, long value) {
|
||||||
|
VARIANT wanted;
|
||||||
|
VariantInit(&wanted);
|
||||||
|
wanted.vt = VT_I4;
|
||||||
|
wanted.lVal = value;
|
||||||
|
return put(name, wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT XmmrControl::putString(const wchar_t* name, const std::string& value) {
|
||||||
|
VARIANT wanted;
|
||||||
|
VariantInit(&wanted);
|
||||||
|
wanted.vt = VT_BSTR;
|
||||||
|
wanted.bstrVal = SysAllocString(toWide(value).c_str());
|
||||||
|
return put(name, wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT XmmrControl::call(const wchar_t* name, VARIANT* arguments, unsigned count) {
|
||||||
|
DISPPARAMS parameters = {arguments, nullptr, count, 0};
|
||||||
|
return invoke(name, DISPATCH_METHOD, ¶meters, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments go to a dispatch call last one first.
|
||||||
|
HRESULT XmmrControl::callLong(const wchar_t* name, long first, long second) {
|
||||||
|
VARIANT arguments[2];
|
||||||
|
VariantInit(&arguments[0]);
|
||||||
|
VariantInit(&arguments[1]);
|
||||||
|
arguments[0].vt = VT_I4;
|
||||||
|
arguments[0].lVal = second;
|
||||||
|
arguments[1].vt = VT_I4;
|
||||||
|
arguments[1].lVal = first;
|
||||||
|
return call(name, arguments, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
long XmmrControl::getLong(const wchar_t* name) {
|
||||||
|
VARIANT value;
|
||||||
|
VariantInit(&value);
|
||||||
|
DISPPARAMS none = {nullptr, nullptr, 0, 0};
|
||||||
|
if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long found = SUCCEEDED(VariantChangeType(&value, &value, 0, VT_I4)) ? value.lVal : 0;
|
||||||
|
VariantClear(&value);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string XmmrControl::getString(const wchar_t* name) {
|
||||||
|
VARIANT value;
|
||||||
|
VariantInit(&value);
|
||||||
|
DISPPARAMS none = {nullptr, nullptr, 0, 0};
|
||||||
|
if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
std::string found;
|
||||||
|
if (SUCCEEDED(VariantChangeType(&value, &value, 0, VT_BSTR))) {
|
||||||
|
found = toUtf8(value.bstrVal);
|
||||||
|
}
|
||||||
|
VariantClear(&value);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
51
bridge/XmmrControl.h
Normal file
51
bridge/XmmrControl.h
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <ole2.h>
|
||||||
|
#include <ocidl.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ControlSite.h"
|
||||||
|
#include "EventSink.h"
|
||||||
|
|
||||||
|
// The XMMR control out of XMMT.ocx, which is what N1MM drives MMTTY and 2Tone
|
||||||
|
// with. The control starts the engine, and the engine reports back through the
|
||||||
|
// control's events.
|
||||||
|
class XmmrControl {
|
||||||
|
public:
|
||||||
|
using EventHandler = SinkHandler;
|
||||||
|
|
||||||
|
explicit XmmrControl(EventHandler handler) : handler_(std::move(handler)) {}
|
||||||
|
~XmmrControl() { destroy(); }
|
||||||
|
|
||||||
|
bool create(HWND parent, std::string* error);
|
||||||
|
void destroy();
|
||||||
|
|
||||||
|
HRESULT put(const wchar_t* name, VARIANT value);
|
||||||
|
HRESULT putBool(const wchar_t* name, bool value);
|
||||||
|
HRESULT putLong(const wchar_t* name, long value);
|
||||||
|
HRESULT putString(const wchar_t* name, const std::string& value);
|
||||||
|
|
||||||
|
HRESULT call(const wchar_t* name, VARIANT* arguments, unsigned count);
|
||||||
|
HRESULT callLong(const wchar_t* name, long first, long second);
|
||||||
|
|
||||||
|
long getLong(const wchar_t* name);
|
||||||
|
std::string getString(const wchar_t* name);
|
||||||
|
|
||||||
|
private:
|
||||||
|
HRESULT invoke(const wchar_t* name, WORD flags, DISPPARAMS* arguments, VARIANT* result);
|
||||||
|
bool listen(std::string* error);
|
||||||
|
|
||||||
|
EventHandler handler_;
|
||||||
|
ControlSite* site_ = nullptr;
|
||||||
|
IOleObject* object_ = nullptr;
|
||||||
|
IDispatch* dispatch_ = nullptr;
|
||||||
|
IConnectionPoint* point_ = nullptr;
|
||||||
|
IUnknown* sink_ = nullptr;
|
||||||
|
DWORD cookie_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string toUtf8(const wchar_t* text);
|
||||||
|
std::wstring toWide(const std::string& text);
|
||||||
256
bridge/main.cpp
Normal file
256
bridge/main.cpp
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// Runs XMMT.ocx under Wine so that Nonemm, which is a Linux program, can drive
|
||||||
|
// MMTTY or 2Tone. Everything arrives on standard input and leaves on standard
|
||||||
|
// output, one protocol line at a time. Wine's own diagnostics go to standard
|
||||||
|
// error, which keeps them out of the protocol.
|
||||||
|
//
|
||||||
|
// The startup is N1MM's, in N1MM's order: title, PTT port and command line on
|
||||||
|
// the control, then bActive, then the host window handle to the engine.
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "Protocol.h"
|
||||||
|
#include "XmmrControl.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// One protocol line, handed from the reader thread to the thread the control
|
||||||
|
// lives on. COM calls have to stay on the one thread.
|
||||||
|
constexpr UINT WM_BRIDGE_LINE = WM_APP + 1;
|
||||||
|
|
||||||
|
constexpr long MessageHostWindow = 0;
|
||||||
|
constexpr long MessageShutdown = 2;
|
||||||
|
|
||||||
|
constexpr UINT_PTR QuitTimer = 1;
|
||||||
|
constexpr UINT QuitPatience = 5000;
|
||||||
|
|
||||||
|
void killEngine();
|
||||||
|
|
||||||
|
XmmrControl* control = nullptr;
|
||||||
|
HWND bridgeWindow = nullptr;
|
||||||
|
DWORD enginePid = 0;
|
||||||
|
bool quitting = false;
|
||||||
|
|
||||||
|
// Dispatch arguments arrive last one first; `position` counts them the way the
|
||||||
|
// event declares them.
|
||||||
|
long argument(DISPPARAMS* arguments, unsigned position) {
|
||||||
|
if (arguments == nullptr || position >= arguments->cArgs) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
VARIANT wanted;
|
||||||
|
VariantInit(&wanted);
|
||||||
|
if (FAILED(VariantChangeType(&wanted, &arguments->rgvarg[arguments->cArgs - 1 - position], 0,
|
||||||
|
VT_I4))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long value = wanted.lVal;
|
||||||
|
VariantClear(&wanted);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control turns each message MMTTY sends it into an event of its own. Only
|
||||||
|
// the messages it has no event for reach OnTranslateMessage, and every message
|
||||||
|
// the logger needs has one, so that event is not listened to.
|
||||||
|
void onEvent(const std::wstring& name, DISPPARAMS* arguments) {
|
||||||
|
if (name == L"OnConnected") {
|
||||||
|
protocol::write("connected", {control->getString(L"verMMTTY")});
|
||||||
|
} else if (name == L"OnDisconnected") {
|
||||||
|
protocol::write("disconnected", argument(arguments, 0));
|
||||||
|
if (quitting) {
|
||||||
|
killEngine();
|
||||||
|
PostQuitMessage(0);
|
||||||
|
}
|
||||||
|
} else if (name == L"OnCharRcvd") {
|
||||||
|
protocol::write("rx", argument(arguments, 0));
|
||||||
|
} else if (name == L"OnPttEvent") {
|
||||||
|
protocol::write("tx", argument(arguments, 0));
|
||||||
|
} else if (name == L"OnFreqChanged") {
|
||||||
|
protocol::write("mark", argument(arguments, 0));
|
||||||
|
protocol::write("space", argument(arguments, 1));
|
||||||
|
} else if (name == L"OnSwitchChanged") {
|
||||||
|
protocol::write("switch", argument(arguments, 0));
|
||||||
|
} else if (name == L"OnViewChanged") {
|
||||||
|
protocol::write("view", argument(arguments, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bridge started the engine, so the bridge stops it. MMTTY is asked first
|
||||||
|
// and killed only if it is still there when the wait runs out; the control's
|
||||||
|
// own shutdown call blocks forever under Wine, waiting for a message loop that
|
||||||
|
// is by then no longer pumping.
|
||||||
|
BOOL CALLBACK closeWindow(HWND window, LPARAM wanted) {
|
||||||
|
DWORD owner = 0;
|
||||||
|
GetWindowThreadProcessId(window, &owner);
|
||||||
|
if (owner == static_cast<DWORD>(wanted)) {
|
||||||
|
PostMessageW(window, WM_CLOSE, 0, 0);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopEngine() {
|
||||||
|
control->callLong(L"PostMmttyMessage", MessageShutdown, 0);
|
||||||
|
HWND engine = reinterpret_cast<HWND>(static_cast<uintptr_t>(control->getLong(L"hWndMmtty")));
|
||||||
|
if (engine == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GetWindowThreadProcessId(engine, &enginePid);
|
||||||
|
EnumWindows(closeWindow, static_cast<LPARAM>(enginePid));
|
||||||
|
}
|
||||||
|
|
||||||
|
void killEngine() {
|
||||||
|
if (enginePid == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HANDLE engine = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, enginePid);
|
||||||
|
if (engine == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (WaitForSingleObject(engine, 0) != WAIT_OBJECT_0) {
|
||||||
|
TerminateProcess(engine, 0);
|
||||||
|
}
|
||||||
|
CloseHandle(engine);
|
||||||
|
enginePid = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void report(const char* what, HRESULT result) {
|
||||||
|
if (FAILED(result)) {
|
||||||
|
char message[128];
|
||||||
|
std::snprintf(message, sizeof(message), "%s failed: 0x%08lx", what,
|
||||||
|
static_cast<unsigned long>(result));
|
||||||
|
protocol::write("error", {message});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void open(const protocol::Line& line) {
|
||||||
|
report("Visible", control->putBool(L"Visible", true));
|
||||||
|
report("VisibleMmtty", control->putBool(L"VisibleMmtty", true));
|
||||||
|
report("InvokeCommand", control->putString(L"InvokeCommand", line.field(2)));
|
||||||
|
report("ComName", control->putString(L"ComName", line.field(1)));
|
||||||
|
report("Title", control->putString(L"Title", line.field(0)));
|
||||||
|
report("bActive", control->putBool(L"bActive", true));
|
||||||
|
report("PostMmttyMessage",
|
||||||
|
control->callLong(L"PostMmttyMessage", MessageHostWindow, control->getLong(L"hwnd")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void send(const std::string& text) {
|
||||||
|
VARIANT argument;
|
||||||
|
VariantInit(&argument);
|
||||||
|
argument.vt = VT_BSTR;
|
||||||
|
argument.bstrVal = SysAllocString(toWide(text).c_str());
|
||||||
|
control->call(L"SendString", &argument, 1);
|
||||||
|
VariantClear(&argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPtt(long on) {
|
||||||
|
VARIANT argument;
|
||||||
|
VariantInit(&argument);
|
||||||
|
argument.vt = VT_I4;
|
||||||
|
argument.lVal = on;
|
||||||
|
control->call(L"SetMmttyPTT", &argument, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void act(const std::string& text) {
|
||||||
|
protocol::Line line = protocol::read(text);
|
||||||
|
if (line.verb == "open") {
|
||||||
|
open(line);
|
||||||
|
} else if (line.verb == "send") {
|
||||||
|
send(line.field(0));
|
||||||
|
} else if (line.verb == "ptt") {
|
||||||
|
setPtt(line.number(0));
|
||||||
|
} else if (line.verb == "post") {
|
||||||
|
control->callLong(L"PostMmttyMessage", line.number(0), line.number(1));
|
||||||
|
} else if (line.verb == "close") {
|
||||||
|
stopEngine();
|
||||||
|
} else if (line.verb == "quit") {
|
||||||
|
quitting = true;
|
||||||
|
stopEngine();
|
||||||
|
SetTimer(bridgeWindow, QuitTimer, QuitPatience, nullptr);
|
||||||
|
} else {
|
||||||
|
protocol::write("error", {"unknown verb: " + line.verb});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK onMessage(HWND window, UINT message, WPARAM first, LPARAM second) {
|
||||||
|
// The engine did not report itself gone, so stop waiting for it.
|
||||||
|
if (message == WM_TIMER && first == QuitTimer) {
|
||||||
|
killEngine();
|
||||||
|
PostQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_BRIDGE_LINE) {
|
||||||
|
std::string* line = reinterpret_cast<std::string*>(second);
|
||||||
|
act(*line);
|
||||||
|
delete line;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(window, message, first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
HWND createWindow(bool show) {
|
||||||
|
WNDCLASSEXW description = {};
|
||||||
|
description.cbSize = sizeof(description);
|
||||||
|
description.lpfnWndProc = onMessage;
|
||||||
|
description.hInstance = GetModuleHandleW(nullptr);
|
||||||
|
description.lpszClassName = L"NonemmMmttyBridge";
|
||||||
|
RegisterClassExW(&description);
|
||||||
|
HWND window = CreateWindowExW(0, description.lpszClassName, L"Nonemm MMTTY bridge",
|
||||||
|
WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 320, 200,
|
||||||
|
nullptr, nullptr, description.hInstance, nullptr);
|
||||||
|
if (window != nullptr && show) {
|
||||||
|
ShowWindow(window, SW_SHOW);
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
void readLines(HWND window) {
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(std::cin, line)) {
|
||||||
|
if (!line.empty() && line.back() == '\r') {
|
||||||
|
line.pop_back();
|
||||||
|
}
|
||||||
|
PostMessageW(window, WM_BRIDGE_LINE, 0, reinterpret_cast<LPARAM>(new std::string(line)));
|
||||||
|
}
|
||||||
|
PostMessageW(window, WM_QUIT, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int count, char** arguments) {
|
||||||
|
bool show = count > 1 && std::string(arguments[1]) == "--show";
|
||||||
|
HRESULT started = CoInitialize(nullptr);
|
||||||
|
if (FAILED(started)) {
|
||||||
|
protocol::write("error", {"COM would not start"});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
OleInitialize(nullptr);
|
||||||
|
HWND window = createWindow(show);
|
||||||
|
bridgeWindow = window;
|
||||||
|
if (window == nullptr) {
|
||||||
|
protocol::write("error", {"the bridge window would not open"});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
XmmrControl xmmr(onEvent);
|
||||||
|
control = &xmmr;
|
||||||
|
std::string error;
|
||||||
|
if (!xmmr.create(window, &error)) {
|
||||||
|
protocol::write("error", {error});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
protocol::write("ready");
|
||||||
|
std::thread reader(readLines, window);
|
||||||
|
reader.detach();
|
||||||
|
MSG message;
|
||||||
|
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
|
||||||
|
TranslateMessage(&message);
|
||||||
|
DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
// The control's own teardown waits for an engine that has already been told
|
||||||
|
// to shut down, and never returns. The bridge has nothing left to do here,
|
||||||
|
// so it leaves and lets Wine reclaim what it held.
|
||||||
|
protocol::write("closed");
|
||||||
|
ExitProcess(0);
|
||||||
|
}
|
||||||
141
docs/digital-bridge.md
Normal file
141
docs/digital-bridge.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# MMTTY and 2Tone through Wine
|
||||||
|
|
||||||
|
MMTTY is a Windows program with no socket, pipe or file interface. N1MM does not
|
||||||
|
talk to it directly either: it hosts `XMMT.ocx`, a 32-bit ActiveX control that
|
||||||
|
starts `MMTTY.EXE` and exchanges window messages with it. A Linux process cannot
|
||||||
|
load that control, so Nonemm runs a small Windows program under Wine —
|
||||||
|
`bridge/nonemm-mmtty-bridge.exe` — which hosts the control and passes lines over
|
||||||
|
its standard input and output.
|
||||||
|
|
||||||
|
```
|
||||||
|
Nonemm ──stdin/stdout──▶ bridge.exe (Wine) ──XMMT.ocx──▶ MMTTY.EXE / 2Tone
|
||||||
|
```
|
||||||
|
|
||||||
|
2Tone uses the same control, so it runs the same way. The only differences are
|
||||||
|
the command line and the window title, both in `MmttyOptions`.
|
||||||
|
|
||||||
|
## The line protocol
|
||||||
|
|
||||||
|
One line per message: a verb, then fields, separated by tabs. Backslash, tab,
|
||||||
|
carriage return and newline inside a field are backslash-escaped, so a field
|
||||||
|
never splits a line. `BridgeLine.cs` and `bridge/Protocol.cpp` are the two ends
|
||||||
|
of it.
|
||||||
|
|
||||||
|
Wine writes its own diagnostics to standard error, which leaves standard output
|
||||||
|
to the protocol.
|
||||||
|
|
||||||
|
To the bridge:
|
||||||
|
|
||||||
|
| Line | What the bridge does |
|
||||||
|
|---|---|
|
||||||
|
| `open <title> <port> <command line>` | sets `Title`, `ComName` and `InvokeCommand`, sets `bActive`, then posts the host window handle |
|
||||||
|
| `send <text>` | `SendString` |
|
||||||
|
| `ptt <0\|1>` | `SetMmttyPTT` |
|
||||||
|
| `post <message> <parameter>` | `PostMmttyMessage`, for everything in `MmttyMessage` |
|
||||||
|
| `close` | shuts the engine down and leaves the bridge running |
|
||||||
|
| `quit` | shuts the engine down and exits |
|
||||||
|
|
||||||
|
From the bridge:
|
||||||
|
|
||||||
|
| Line | Where it comes from |
|
||||||
|
|---|---|
|
||||||
|
| `ready` | the control is created and listening |
|
||||||
|
| `connected <version>` | `OnConnected`, with `verMMTTY` |
|
||||||
|
| `disconnected <status>` | `OnDisconnected`: 0 failed to close, 1 closed, 2 failed to start |
|
||||||
|
| `rx <code>` | one decoded character |
|
||||||
|
| `tx <0\|1>` | the engine started or stopped transmitting |
|
||||||
|
| `mark <hz>`, `space <hz>` | the tone pair moved |
|
||||||
|
| `switch <bits>`, `view <bits>` | AFC (4), net (8) and reverse (256), and the engine's view state |
|
||||||
|
| `log <text>`, `error <text>` | anything the bridge has to say |
|
||||||
|
|
||||||
|
Each of those comes from an event of the control's own: `OnCharRcvd`,
|
||||||
|
`OnPttEvent`, `OnFreqChanged` (mark and space in one event), `OnSwitchChanged`
|
||||||
|
and `OnViewChanged`. The control raises `OnTranslateMessage` only for the
|
||||||
|
MMTTY messages it has no event for — width, resolution, thread and the like —
|
||||||
|
so the bridge does not listen to it. N1MM reads the characters the same way, in
|
||||||
|
`DigitalMultiRXWindow.XMMR_OnCharRcvd`; the `OnTranslateMessage` handler in
|
||||||
|
`DigitalInterface.cs` that maps codes 32771 to 32778 never runs with this
|
||||||
|
version of the control.
|
||||||
|
|
||||||
|
## Starting the engine
|
||||||
|
|
||||||
|
The order is N1MM's, from `DigitalInterface.cs`:
|
||||||
|
|
||||||
|
1. Read `Mmtty.INI` beside the program. `[Define] PTT` is the serial port, and
|
||||||
|
`AFC`, `TxNet` and `Rev` are the switch word the engine starts with. Without
|
||||||
|
them, the first toggle moves the wrong way.
|
||||||
|
2. Build the command line: the quoted program, the window size (`-r` normal,
|
||||||
|
`-t` small, `-s` medium 1, `-u` medium 2), `-a` when the window is not on
|
||||||
|
top, and `-Z` for MMTTY but not for 2Tone.
|
||||||
|
3. `open`. The control appends `-h<handle>` of its own, which is how the engine
|
||||||
|
finds its way back.
|
||||||
|
4. An engine that answers `disconnected 2` is started again, up to ten times.
|
||||||
|
That is N1MM's retry, and it is needed: MMTTY does not always come up first
|
||||||
|
time.
|
||||||
|
|
||||||
|
## What the control needs that is not obvious
|
||||||
|
|
||||||
|
Three things cost a day between them, so they are written down:
|
||||||
|
|
||||||
|
- **`IPersistStreamInit::InitNew` before anything else.** The control is MFC
|
||||||
|
based, and until it is initialised every dispatch call returns `E_UNEXPECTED`
|
||||||
|
— property gets included. Creating and activating it is not enough.
|
||||||
|
- **A real window.** In-place activation needs a parent window, so the bridge
|
||||||
|
needs an X display even though it shows nothing.
|
||||||
|
- **The control's own shutdown blocks.** `IOleObject::Close` and setting
|
||||||
|
`bActive` to false wait for the engine while holding the thread that would
|
||||||
|
have to pump the messages, so neither returns. The bridge posts the shutdown
|
||||||
|
message, closes the engine's windows, and kills the process if it is still
|
||||||
|
there five seconds later.
|
||||||
|
|
||||||
|
## Setting it up
|
||||||
|
|
||||||
|
Config > Digital has a Register button. It does what the next paragraph
|
||||||
|
describes, in the prefix named in the same dialog, and writes the result under
|
||||||
|
the button. Press it once, before the first start.
|
||||||
|
|
||||||
|
The steps are in `WinePrefixSetup`. A prefix that is not there yet is made with
|
||||||
|
`wineboot -u` and `WINEARCH=win32`, because the control is 32 bit. The control
|
||||||
|
is then copied into the Windows folder and registered:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export WINEPREFIX=~/.wine-nonemm WINEARCH=win32
|
||||||
|
wineboot -u
|
||||||
|
cp bridge/XMMT.ocx "$WINEPREFIX/drive_c/windows/system32/"
|
||||||
|
wine regsvr32 'C:\windows\system32\XMMT.ocx'
|
||||||
|
```
|
||||||
|
|
||||||
|
A 64-bit prefix keeps 32-bit code in `syswow64` and has a second `regsvr32.exe`
|
||||||
|
there. That is the one to register the control with: the 64-bit one cannot load
|
||||||
|
it.
|
||||||
|
|
||||||
|
`XMMT.ocx` ships with N1MM Logger+ and is distributed with Nonemm in the
|
||||||
|
`bridge` folder. MMTTY itself is downloaded separately and can live anywhere in
|
||||||
|
the prefix.
|
||||||
|
|
||||||
|
Build the bridge with the mingw-w64 cross compiler:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make -C bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where the program looks
|
||||||
|
|
||||||
|
MMTTY, the bridge and `XMMT.ocx` are distributed with Nonemm, in `mmtty` and
|
||||||
|
`bridge` folders beside the program, and the build copies them there from the
|
||||||
|
working copy. Config > Digital starts at those three paths, so an operator who
|
||||||
|
runs the distributed copy has nothing to fill in. An engine kept somewhere else, or
|
||||||
|
2Tone in place of MMTTY, is browsed for in the same dialog. Neither program is
|
||||||
|
in the repository.
|
||||||
|
|
||||||
|
The bridge keeps its own window hidden. Started with `--show` it shows it, which
|
||||||
|
is the way to see what the control is doing.
|
||||||
|
|
||||||
|
## What has not been tested
|
||||||
|
|
||||||
|
The bridge has been run against MMTTY 1.70 under Wine 10: it starts the engine,
|
||||||
|
connects, reports the version, takes text, opens MMTTY's setup window, moves
|
||||||
|
the tone pair and reports `mark`, `space`, `switch`, `view` and `tx` back. It
|
||||||
|
has never been run with a sound card, so nothing has been decoded and no `rx`
|
||||||
|
line has ever come from a real signal. FSK keying through EXTFSK and PTT on a
|
||||||
|
serial port have not been tried either, and neither has 2Tone.
|
||||||
@@ -17,12 +17,23 @@ is still sitting there undiscovered.
|
|||||||
| `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. |
|
| `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. |
|
||||||
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
|
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
|
||||||
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. |
|
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. |
|
||||||
|
| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10, started and stopped through `XMMT.ocx` | no sound card, so nothing has been decoded or transmitted and no `rx` or `tx` line has come from a real signal. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. `docs/digital-bridge.md` |
|
||||||
| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. |
|
| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. |
|
||||||
|
|
||||||
The Cabrillo output has not been put in front of a contest sponsor's robot.
|
The Cabrillo output has not been put in front of a contest sponsor's robot.
|
||||||
|
|
||||||
## Half-built
|
## Half-built
|
||||||
|
|
||||||
|
**The digital window: what N1MM has and this does not.** The window is here —
|
||||||
|
receive pane, coloured callsigns, grab list, call stacking, twenty-four macro
|
||||||
|
buttons, the engine controls — and the function keys, ESM and the QTC window
|
||||||
|
send through the engine on a digital mode. Left out: the waterfall, the spectrum
|
||||||
|
and the XY scope, which the engine reports and nothing draws; more than one
|
||||||
|
receive pane, which is MMVARI's multi-channel decoding rather than MMTTY's;
|
||||||
|
sending a text file; and N1MM's Send All, which keys a whole QTC series at once.
|
||||||
|
The other interfaces N1MM offers — fldigi, MMVARI, the hardware TNCs — are not
|
||||||
|
here either, though `DigitalEngine` is the place to add them.
|
||||||
|
|
||||||
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
|
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
|
||||||
recording, and nothing implements it. No DVK support either, so the second radio
|
recording, and nothing implements it. No DVK support either, so the second radio
|
||||||
of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW
|
of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Nonemm.Contests;
|
|||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
using Nonemm.Core.Calls;
|
using Nonemm.Core.Calls;
|
||||||
using Nonemm.Core.Country;
|
using Nonemm.Core.Country;
|
||||||
|
using Nonemm.Digital;
|
||||||
using Nonemm.Keying;
|
using Nonemm.Keying;
|
||||||
using Nonemm.Network;
|
using Nonemm.Network;
|
||||||
using Nonemm.Rig;
|
using Nonemm.Rig;
|
||||||
@@ -26,6 +27,8 @@ public sealed class AppSession : IDisposable
|
|||||||
private StationNetwork? network;
|
private StationNetwork? network;
|
||||||
private MessageSender? keyer;
|
private MessageSender? keyer;
|
||||||
private AlternatingCq? alternating;
|
private AlternatingCq? alternating;
|
||||||
|
private MmttyEngine? digital;
|
||||||
|
private DigitalEngineSender? digitalSender;
|
||||||
private So2rBox? box;
|
private So2rBox? box;
|
||||||
|
|
||||||
/// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
|
/// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
|
||||||
@@ -123,6 +126,14 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public MessageSender? Keyer => keyer;
|
public MessageSender? Keyer => keyer;
|
||||||
|
|
||||||
|
/// The digital modem, started by the digital window rather than at startup:
|
||||||
|
/// it runs an engine under Wine, which is not something to do to an
|
||||||
|
/// operator who is working CW.
|
||||||
|
public MmttyEngine? Digital => digital;
|
||||||
|
|
||||||
|
/// The same engine as something a macro can be sent through.
|
||||||
|
public MessageSender? DigitalKeyer => digitalSender;
|
||||||
|
|
||||||
/// Alternating CQ, or null while there is no keyer. It needs two radios to
|
/// Alternating CQ, or null while there is no keyer. It needs two radios to
|
||||||
/// do anything, and a keyer that reports when a message has gone out.
|
/// do anything, and a keyer that reports when a message has gone out.
|
||||||
public AlternatingCq? Alternating => alternating;
|
public AlternatingCq? Alternating => alternating;
|
||||||
@@ -291,6 +302,46 @@ public sealed class AppSession : IDisposable
|
|||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts the digital engine under Wine and returns it. The engine that is
|
||||||
|
/// already running is handed back rather than started again.
|
||||||
|
public async Task<MmttyEngine> StartDigitalAsync()
|
||||||
|
{
|
||||||
|
if (digital is { IsConnected: true } running)
|
||||||
|
{
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
StopDigital();
|
||||||
|
MmttyOptions options = new()
|
||||||
|
{
|
||||||
|
EnginePath = Settings.DigitalEnginePath,
|
||||||
|
Number = ActiveRadioNumber,
|
||||||
|
Window = Enum.TryParse(Settings.DigitalEngineWindow, ignoreCase: true, out EngineWindow window)
|
||||||
|
? window
|
||||||
|
: EngineWindow.Normal,
|
||||||
|
OnTop = Settings.DigitalEngineOnTop,
|
||||||
|
PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null,
|
||||||
|
};
|
||||||
|
WineBridgeChannel channel = new(
|
||||||
|
Settings.DigitalBridgePath,
|
||||||
|
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
|
||||||
|
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
|
||||||
|
MmttyEngine started = new(channel, options);
|
||||||
|
await started.StartAsync();
|
||||||
|
digital = started;
|
||||||
|
digitalSender = new DigitalEngineSender(started);
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopDigital()
|
||||||
|
{
|
||||||
|
digitalSender?.Dispose();
|
||||||
|
digitalSender = null;
|
||||||
|
digital?.Dispose();
|
||||||
|
digital = null;
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens a connection per enabled radio. A second one makes the station
|
/// Opens a connection per enabled radio. A second one makes the station
|
||||||
/// SO2R: both are read, but only the one the operator is on drives the
|
/// SO2R: both are read, but only the one the operator is on drives the
|
||||||
/// entry window.
|
/// entry window.
|
||||||
@@ -534,6 +585,7 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
StopDigital();
|
||||||
spotFlush.Dispose();
|
spotFlush.Dispose();
|
||||||
DisposeRadios();
|
DisposeRadios();
|
||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
|
|||||||
@@ -186,6 +186,79 @@ public sealed record Settings
|
|||||||
|
|
||||||
public IReadOnlyList<string> PhoneMessages { get; init; } = [];
|
public IReadOnlyList<string> PhoneMessages { get; init; } = [];
|
||||||
|
|
||||||
|
/// The digital window's twenty-four macro buttons, in the same
|
||||||
|
/// `label,message` lines as a function key file. Empty means the built-in
|
||||||
|
/// set.
|
||||||
|
public string DigitalMessageFile { get; init; } = "";
|
||||||
|
|
||||||
|
public bool DigitalEnabled { get; init; }
|
||||||
|
|
||||||
|
/// The engine to run: MMTTY or 2Tone. The path is a Linux path; it is
|
||||||
|
/// handed to Wine in the form Wine expects. It starts at the copy shipped
|
||||||
|
/// beside the program.
|
||||||
|
public string DigitalEnginePath { get; init; } = ProgramFile("mmtty", "mmtty.exe");
|
||||||
|
|
||||||
|
public string DigitalBridgePath { get; init; } =
|
||||||
|
ProgramFile("bridge", "nonemm-mmtty-bridge.exe");
|
||||||
|
|
||||||
|
/// The XMMT.ocx the bridge hosts, as it sits beside the program. It is
|
||||||
|
/// only read when the control is registered in the Wine prefix; after that
|
||||||
|
/// the copy in the prefix is the one that is loaded.
|
||||||
|
public string DigitalControlPath { get; init; } = ProgramFile("bridge", "XMMT.ocx");
|
||||||
|
|
||||||
|
/// Empty means Wine's own default prefix.
|
||||||
|
public string DigitalWinePrefix { get; init; } = "";
|
||||||
|
|
||||||
|
public string DigitalWineCommand { get; init; } = "wine";
|
||||||
|
|
||||||
|
/// `Normal`, `Small`, `Medium1` or `Medium2`, which are MMTTY's four
|
||||||
|
/// window sizes.
|
||||||
|
public string DigitalEngineWindow { get; init; } = "Normal";
|
||||||
|
|
||||||
|
public bool DigitalEngineOnTop { get; init; } = true;
|
||||||
|
|
||||||
|
/// The port MMTTY keys FSK and PTT on. Empty reads it from Mmtty.INI, which
|
||||||
|
/// is where MMTTY itself keeps it.
|
||||||
|
public string DigitalPttPort { get; init; } = "";
|
||||||
|
|
||||||
|
/// The mark tone the Align button moves the engine to. MMTTY's own default.
|
||||||
|
public int DigitalMarkHertz { get; init; } = 2125;
|
||||||
|
|
||||||
|
/// Colour the callsign itself, or the ground behind it. N1MM offers both.
|
||||||
|
public bool DigitalHighlightBackground { get; init; }
|
||||||
|
|
||||||
|
/// `everything`, `notdupes` or `knowncalls`.
|
||||||
|
public string DigitalGrabFilter { get; init; } = "everything";
|
||||||
|
|
||||||
|
public bool DigitalGrabNewestFirst { get; init; } = true;
|
||||||
|
|
||||||
|
/// `multipliers`, `firstin`, `lastin` or `disabled`.
|
||||||
|
public string DigitalCallStacking { get; init; } = "disabled";
|
||||||
|
|
||||||
|
/// N1MM's right-click option: a right click in the receive pane presses
|
||||||
|
/// Enter in the entry window instead of opening the menu.
|
||||||
|
public bool DigitalRightClickSendsEnter { get; init; }
|
||||||
|
|
||||||
|
/// With the right click sending Enter, whether the call stays in the box
|
||||||
|
/// afterwards. N1MM's "don't drop call".
|
||||||
|
public bool DigitalRightClickKeepsCall { get; init; }
|
||||||
|
|
||||||
|
/// A grabbed call is followed by a space, which moves to the exchange and
|
||||||
|
/// fills the report in, the way typing one does.
|
||||||
|
public bool DigitalGrabSendsSpace { get; init; } = true;
|
||||||
|
|
||||||
|
/// N1MM caps the receive pane's font at 14 points.
|
||||||
|
public int DigitalFontSize { get; init; } = 12;
|
||||||
|
|
||||||
|
/// A file shipped beside the program. MMTTY and the bridge are distributed
|
||||||
|
/// with Nonemm, so the paths are filled in already; a file that is not
|
||||||
|
/// there leaves the setting empty and the operator browses for it.
|
||||||
|
private static string ProgramFile(string folder, string name)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(AppContext.BaseDirectory, folder, name);
|
||||||
|
return File.Exists(path) ? path : "";
|
||||||
|
}
|
||||||
|
|
||||||
/// Reflection rather than a generated serializer: the generated one hands
|
/// Reflection rather than a generated serializer: the generated one hands
|
||||||
/// back null for every property the file leaves out instead of the value
|
/// back null for every property the file leaves out instead of the value
|
||||||
/// the property is declared with.
|
/// the property is declared with.
|
||||||
@@ -322,6 +395,21 @@ public sealed record Settings
|
|||||||
PhoneMessages = [],
|
PhoneMessages = [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (settings.DigitalEnginePath.Length == 0)
|
||||||
|
{
|
||||||
|
settings = settings with { DigitalEnginePath = ProgramFile("mmtty", "mmtty.exe") };
|
||||||
|
}
|
||||||
|
if (settings.DigitalBridgePath.Length == 0)
|
||||||
|
{
|
||||||
|
settings = settings with
|
||||||
|
{
|
||||||
|
DigitalBridgePath = ProgramFile("bridge", "nonemm-mmtty-bridge.exe"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (settings.DigitalControlPath.Length == 0)
|
||||||
|
{
|
||||||
|
settings = settings with { DigitalControlPath = ProgramFile("bridge", "XMMT.ocx") };
|
||||||
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/Nonemm.App/Dialogs/DigitalDialog.axaml
Normal file
59
src/Nonemm.App/Dialogs/DigitalDialog.axaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Nonemm.App.Dialogs.DigitalDialog"
|
||||||
|
Title="Digital engine" Width="620" SizeToContent="Height"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="TextBlock.label">
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Opacity" Value="0.7" />
|
||||||
|
<Setter Property="Margin" Value="0,8,0,1" />
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
<DockPanel Margin="14">
|
||||||
|
<StackPanel DockPanel.Dock="Top">
|
||||||
|
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||||
|
Text="MMTTY and 2Tone are Windows programs. They run under Wine, driven through the same XMMT.ocx control N1MM uses, with a small bridge program in between. See docs/digital-bridge.md for setting the Wine prefix up." />
|
||||||
|
<TextBlock Classes="label" Text="Engine program (mmtty.exe or 2Tone.exe)" />
|
||||||
|
<Grid ColumnDefinitions="*,6,Auto">
|
||||||
|
<TextBox Name="EngineBox" />
|
||||||
|
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseEngine" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="label" Text="Bridge program (nonemm-mmtty-bridge.exe)" />
|
||||||
|
<Grid ColumnDefinitions="*,6,Auto">
|
||||||
|
<TextBox Name="BridgeBox" />
|
||||||
|
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseBridge" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="*,8,160" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Classes="label" Text="Wine prefix (empty for Wine's own)" />
|
||||||
|
<TextBox Name="PrefixBox" Grid.Row="1" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="Wine command" />
|
||||||
|
<TextBox Name="WineBox" Grid.Row="1" Grid.Column="2" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="label" Text="Control (XMMT.ocx), registered in the prefix above" />
|
||||||
|
<Grid ColumnDefinitions="*,6,Auto,6,Auto">
|
||||||
|
<TextBox Name="ControlBox" />
|
||||||
|
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseControl" />
|
||||||
|
<Button Grid.Column="4" Name="RegisterButton" Content="Register" Click="OnRegister" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Name="RegisterText" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||||
|
Margin="0,4,0,0" IsVisible="False" />
|
||||||
|
<Grid ColumnDefinitions="160,8,160,8,*" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Classes="label" Text="Engine window" />
|
||||||
|
<ComboBox Name="WindowBox" Grid.Row="1" HorizontalAlignment="Stretch" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="PTT port (empty reads Mmtty.INI)" />
|
||||||
|
<TextBox Name="PttBox" Grid.Row="1" Grid.Column="2" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="label" Text="Mark tone (Hz)" />
|
||||||
|
<TextBox Name="MarkBox" Grid.Row="1" Grid.Column="4" />
|
||||||
|
</Grid>
|
||||||
|
<CheckBox Name="OnTopBox" Content="Keep the engine window on top" Margin="0,10,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
|
||||||
|
Spacing="6" Margin="0,12,0,0">
|
||||||
|
<Button Content="Cancel" Click="OnCancel" />
|
||||||
|
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||||
|
</StackPanel>
|
||||||
|
<Panel />
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
105
src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs
Normal file
105
src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using Nonemm.App.Configuration;
|
||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Dialogs;
|
||||||
|
|
||||||
|
/// Where the digital engine is and how it is started. It closes with the
|
||||||
|
/// settings, or with null when nothing is to be changed.
|
||||||
|
public sealed partial class DigitalDialog : Window
|
||||||
|
{
|
||||||
|
private static readonly string[] WindowSizes = ["Normal", "Small", "Medium1", "Medium2"];
|
||||||
|
|
||||||
|
private readonly Settings settings;
|
||||||
|
|
||||||
|
public DigitalDialog(Settings settings)
|
||||||
|
{
|
||||||
|
this.settings = settings;
|
||||||
|
InitializeComponent();
|
||||||
|
EngineBox.Text = settings.DigitalEnginePath;
|
||||||
|
BridgeBox.Text = settings.DigitalBridgePath;
|
||||||
|
ControlBox.Text = settings.DigitalControlPath;
|
||||||
|
PrefixBox.Text = settings.DigitalWinePrefix;
|
||||||
|
WineBox.Text = settings.DigitalWineCommand;
|
||||||
|
PttBox.Text = settings.DigitalPttPort;
|
||||||
|
MarkBox.Text = settings.DigitalMarkHertz.ToString(CultureInfo.InvariantCulture);
|
||||||
|
WindowBox.ItemsSource = WindowSizes;
|
||||||
|
WindowBox.SelectedItem =
|
||||||
|
WindowSizes.FirstOrDefault(s => s == settings.DigitalEngineWindow) ?? WindowSizes[0];
|
||||||
|
OnTopBox.IsChecked = settings.DigitalEngineOnTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnBrowseEngine(object? sender, RoutedEventArgs e) =>
|
||||||
|
EngineBox.Text = await PickAsync("The MMTTY or 2Tone program") ?? EngineBox.Text;
|
||||||
|
|
||||||
|
private async void OnBrowseBridge(object? sender, RoutedEventArgs e) =>
|
||||||
|
BridgeBox.Text = await PickAsync("The bridge program") ?? BridgeBox.Text;
|
||||||
|
|
||||||
|
private async void OnBrowseControl(object? sender, RoutedEventArgs e) =>
|
||||||
|
ControlBox.Text = await PickAsync("The XMMT.ocx control", "ActiveX controls", "*.ocx") ?? ControlBox.Text;
|
||||||
|
|
||||||
|
/// Copies XMMT.ocx into the prefix and registers it. It takes a few seconds
|
||||||
|
/// on a prefix Wine has to make first, so the button is held down until it
|
||||||
|
/// is over and the result is written under it.
|
||||||
|
private async void OnRegister(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
RegisterButton.IsEnabled = false;
|
||||||
|
Say("Registering…");
|
||||||
|
WinePrefixSetup setup = new(
|
||||||
|
PrefixBox.Text?.Trim() is { Length: > 0 } prefix ? prefix : null,
|
||||||
|
WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Say(await setup.RegisterAsync(ControlBox.Text?.Trim() ?? ""));
|
||||||
|
}
|
||||||
|
catch (Exception failure) when (failure is IOException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
Say(failure.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RegisterButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Say(string text)
|
||||||
|
{
|
||||||
|
RegisterText.Text = text;
|
||||||
|
RegisterText.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> PickAsync(
|
||||||
|
string title,
|
||||||
|
string kind = "Windows programs",
|
||||||
|
string pattern = "*.exe")
|
||||||
|
{
|
||||||
|
IReadOnlyList<IStorageFile> picked = await StorageProvider.OpenFilePickerAsync(
|
||||||
|
new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = [new FilePickerFileType(kind) { Patterns = [pattern] }],
|
||||||
|
});
|
||||||
|
return picked.Count > 0 ? picked[0].Path.LocalPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
|
|
||||||
|
private void OnSave(object? sender, RoutedEventArgs e) =>
|
||||||
|
Close(settings with
|
||||||
|
{
|
||||||
|
DigitalEnginePath = EngineBox.Text?.Trim() ?? "",
|
||||||
|
DigitalBridgePath = BridgeBox.Text?.Trim() ?? "",
|
||||||
|
DigitalControlPath = ControlBox.Text?.Trim() ?? "",
|
||||||
|
DigitalWinePrefix = PrefixBox.Text?.Trim() ?? "",
|
||||||
|
DigitalWineCommand = WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine",
|
||||||
|
DigitalPttPort = PttBox.Text?.Trim() ?? "",
|
||||||
|
DigitalMarkHertz = int.TryParse(MarkBox.Text, out int mark) ? mark : settings.DigitalMarkHertz,
|
||||||
|
DigitalEngineWindow = WindowBox.SelectedItem as string ?? "Normal",
|
||||||
|
DigitalEngineOnTop = OnTopBox.IsChecked == true,
|
||||||
|
DigitalEnabled = (EngineBox.Text?.Trim().Length ?? 0) > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml
Normal file
20
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Nonemm.App.Dialogs.DigitalMacrosDialog"
|
||||||
|
Title="Digital macros" Width="620" Height="520"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
<DockPanel Margin="14">
|
||||||
|
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||||
|
Text="One line per button: the label, a comma, then the message. Twenty-four lines fill the three rows of eight. {TX} starts the transmission and {RX} ends it; {ENTER} sends a carriage return. The text macros are the same ones the function keys use." />
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="6" Margin="0,10,0,0">
|
||||||
|
<Button Content="Back to the defaults" Click="OnDefaults" />
|
||||||
|
<Panel Width="180" />
|
||||||
|
<Button Content="Cancel" Click="OnCancel" />
|
||||||
|
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||||
|
</StackPanel>
|
||||||
|
<Border BorderThickness="1" BorderBrush="#40808080" CornerRadius="3" Margin="0,8,0,0">
|
||||||
|
<TextBox Name="TextArea" AcceptsReturn="True" FontFamily="monospace" FontSize="12"
|
||||||
|
BorderThickness="0" />
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
22
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml.cs
Normal file
22
src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Dialogs;
|
||||||
|
|
||||||
|
/// The digital window's macro buttons, edited as their lines. It closes with
|
||||||
|
/// the text, or with null when nothing is to be changed.
|
||||||
|
public sealed partial class DigitalMacrosDialog : Window
|
||||||
|
{
|
||||||
|
public DigitalMacrosDialog(string text)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
TextArea.Text = text.Trim().Length > 0 ? text : Messages.DefaultDigital;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDefaults(object? sender, RoutedEventArgs e) =>
|
||||||
|
TextArea.Text = Messages.DefaultDigital;
|
||||||
|
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
|
|
||||||
|
private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? "");
|
||||||
|
}
|
||||||
@@ -46,6 +46,45 @@ public static class Messages
|
|||||||
F12 Wipe,
|
F12 Wipe,
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
/// The digital window's twenty-four buttons. N1MM keeps its own set in its
|
||||||
|
/// admin database, which is not a file this program reads, so these are
|
||||||
|
/// written from N1MM's published digital macro list: `{TX}` starts the
|
||||||
|
/// transmission, `{RX}` ends it, and the text macros are the ones every
|
||||||
|
/// other message uses.
|
||||||
|
public const string DefaultDigital =
|
||||||
|
"""
|
||||||
|
# The digital window's macro buttons, three rows of eight.
|
||||||
|
CQ,{TX}CQ CQ DE {MYCALL} {MYCALL} CQ K{ENTER}{RX}
|
||||||
|
Exch,{TX}{CALL} DE {MYCALL} {SENTRST} {EXCH} {SENTRST} {EXCH} K{ENTER}{RX}
|
||||||
|
TU,{TX}{CALL} TU DE {MYCALL} QRZ{ENTER}{RX}{LOG}
|
||||||
|
MyCall,{TX}{MYCALL} {MYCALL} K{ENTER}{RX}
|
||||||
|
HisCall,{TX}{CALL} DE {MYCALL} K{ENTER}{RX}
|
||||||
|
QSO B4,{TX}{CALL} QSO B4 QRZ DE {MYCALL}{ENTER}{RX}
|
||||||
|
Agn?,{TX}AGN AGN DE {MYCALL} K{ENTER}{RX}
|
||||||
|
Nr?,{TX}NR? NR? DE {MYCALL} K{ENTER}{RX}
|
||||||
|
Call?,{TX}CALL? CALL? DE {MYCALL} K{ENTER}{RX}
|
||||||
|
RST?,{TX}RST? RST? DE {MYCALL} K{ENTER}{RX}
|
||||||
|
QRZ,{TX}QRZ DE {MYCALL} {MYCALL} K{ENTER}{RX}
|
||||||
|
5NN,{TX}{SENTRST} {SENTRST} K{ENTER}{RX}
|
||||||
|
Test,{TX}RYRYRYRYRYRYRYRY{ENTER}{RX}
|
||||||
|
Grab,{TX}{CALL} DE {MYCALL} K{ENTER}{RX}
|
||||||
|
Wipe,{WIPE}
|
||||||
|
Log,{LOG}
|
||||||
|
Run,{RUN}
|
||||||
|
S&&P,{S&P}
|
||||||
|
Stack,{STACKANOTHER}
|
||||||
|
Pop,{LOGTHENPOP}
|
||||||
|
Spot,{SPOTME}
|
||||||
|
73,{TX}73 GL DE {MYCALL}{ENTER}{RX}
|
||||||
|
QRL?,{TX}QRL? DE {MYCALL}{ENTER}{RX}
|
||||||
|
Stop,{STOPTX}
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// The stored digital macros, or the built-in ones while the operator has
|
||||||
|
/// not written their own.
|
||||||
|
public static DigitalMacros Digital(string stored) =>
|
||||||
|
DigitalMacros.Parse(stored.Trim().Length > 0 ? stored : DefaultDigital);
|
||||||
|
|
||||||
public static string DefaultFor(ModeCategory mode) =>
|
public static string DefaultFor(ModeCategory mode) =>
|
||||||
mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;
|
mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- MMTTY and the bridge are distributed with the program and are not in the
|
||||||
|
repository. Copying them beside the build is what fills the digital
|
||||||
|
engine and bridge paths in. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\..\bridge\*.exe;..\..\bridge\*.ocx" LinkBase="bridge"
|
||||||
|
CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Include="..\..\mmtty\**\*" LinkBase="mmtty" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||||
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
|
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||||
@@ -28,5 +37,6 @@
|
|||||||
<ProjectReference Include="..\Nonemm.Session\Nonemm.Session.csproj" />
|
<ProjectReference Include="..\Nonemm.Session\Nonemm.Session.csproj" />
|
||||||
<ProjectReference Include="..\Nonemm.Network\Nonemm.Network.csproj" />
|
<ProjectReference Include="..\Nonemm.Network\Nonemm.Network.csproj" />
|
||||||
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
|
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
|
||||||
|
<ProjectReference Include="..\Nonemm.Digital\Nonemm.Digital.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public static class Verdicts
|
|||||||
/// The frequency we are calling CQ on.
|
/// The frequency we are calling CQ on.
|
||||||
public static IBrush Cq => Themes.Brush(Themes.Current.Cq);
|
public static IBrush Cq => Themes.Brush(Themes.Current.Cq);
|
||||||
|
|
||||||
|
/// Our own call, heard back from the other station. It is neither worth
|
||||||
|
/// working nor a dupe, so it gets the colour that means us.
|
||||||
|
public static IBrush MyCall => Cq;
|
||||||
|
|
||||||
/// Nothing typed yet is the same colour as a station worth working: that is
|
/// Nothing typed yet is the same colour as a station worth working: that is
|
||||||
/// what N1MM leaves the callsign box.
|
/// what N1MM leaves the callsign box.
|
||||||
public static IBrush Colour(Verdict? verdict) =>
|
public static IBrush Colour(Verdict? verdict) =>
|
||||||
|
|||||||
228
src/Nonemm.App/Windows/DigitalWindow.Grab.cs
Normal file
228
src/Nonemm.App/Windows/DigitalWindow.Grab.cs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// The calls heard and what the pointer does to them: the grab list beside the
|
||||||
|
/// transmit pane, and the clicking and hovering in the receive pane.
|
||||||
|
public sealed partial class DigitalWindow
|
||||||
|
{
|
||||||
|
/// A callsign the modem decoded goes in the grab list, unless it is ours,
|
||||||
|
/// the one already being typed, or the filter turns it away.
|
||||||
|
private void Heard(string call)
|
||||||
|
{
|
||||||
|
if (Radio is not { } position
|
||||||
|
|| !GrabList.Wanted(call, position.Me.Callsign, position.Entry.Call)
|
||||||
|
|| !PassesFilter(position, call))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grab.NewestFirst = session.Settings.DigitalGrabNewestFirst;
|
||||||
|
if (grab.Add(call))
|
||||||
|
{
|
||||||
|
ShowGrabList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool PassesFilter(OperatingPosition position, string call) =>
|
||||||
|
session.Settings.DigitalGrabFilter.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"notdupes" => position.JudgeCall(call)?.IsDupe != true,
|
||||||
|
"knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ShowGrabList()
|
||||||
|
{
|
||||||
|
GrabCalls.Children.Clear();
|
||||||
|
if (Radio is not { } position)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (string call in grab.Calls)
|
||||||
|
{
|
||||||
|
GrabCalls.Children.Add(GrabRow(position, call));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control GrabRow(OperatingPosition position, string call)
|
||||||
|
{
|
||||||
|
TextBlock text = new()
|
||||||
|
{
|
||||||
|
Text = call,
|
||||||
|
FontFamily = new FontFamily("monospace"),
|
||||||
|
FontSize = session.Settings.DigitalFontSize,
|
||||||
|
Foreground = Verdicts.Colour(position.JudgeCall(call)),
|
||||||
|
};
|
||||||
|
Border row = new()
|
||||||
|
{
|
||||||
|
Padding = new Thickness(4, 1),
|
||||||
|
Cursor = new Cursor(StandardCursorType.Hand),
|
||||||
|
// a border with no background of its own is not clickable away from
|
||||||
|
// the letters, and the whole row is the target
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Child = text,
|
||||||
|
};
|
||||||
|
row.PointerPressed += (_, e) => GrabFromList(call, e);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left click grabs the call, right click drops it from the list, which is
|
||||||
|
/// what N1MM's grab window does.
|
||||||
|
private void GrabFromList(string call, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||||
|
{
|
||||||
|
grab.Remove(call);
|
||||||
|
ShowGrabList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Grab(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a call in the entry window. With call stacking on and a call
|
||||||
|
/// already being typed, the one being typed goes on the stack first, so
|
||||||
|
/// neither is lost — N1MM's digital call stacking.
|
||||||
|
private void Grab(string call)
|
||||||
|
{
|
||||||
|
if (Radio is not { } position)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
position.Stack.Order = StackingOrder();
|
||||||
|
if (position.Stack.Order != StackOrder.Disabled
|
||||||
|
&& position.Entry.Call.Trim().Length > 0)
|
||||||
|
{
|
||||||
|
position.StackAnother();
|
||||||
|
}
|
||||||
|
grab.Remove(call);
|
||||||
|
ShowGrabList();
|
||||||
|
entry.GrabCall(call, session.Settings.DigitalGrabSendsSpace);
|
||||||
|
entry.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM's Grab button: the next call off the list.
|
||||||
|
private void OnGrabNext(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (grab.Count > 0)
|
||||||
|
{
|
||||||
|
Grab(grab.Calls[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReceivePointerMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Source is not TextBlock block || block.Text is not { } text || text.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
Unhighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ReferenceEquals(block, hovered))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Unhighlight();
|
||||||
|
hovered = block;
|
||||||
|
block.TextDecorations = TextDecorations.Underline;
|
||||||
|
MouseOverText.Text = DigitalReceiver.Scrub(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReceivePointerExited(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
Unhighlight();
|
||||||
|
Resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Unhighlight()
|
||||||
|
{
|
||||||
|
if (hovered is not null)
|
||||||
|
{
|
||||||
|
hovered.TextDecorations = null;
|
||||||
|
hovered = null;
|
||||||
|
}
|
||||||
|
MouseOverText.Text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One left click copies the word under the pointer, the way N1MM does: a
|
||||||
|
/// callsign goes in the callsign box, anything else in the exchange box the
|
||||||
|
/// contest keeps for it. A click on nothing pauses the pane instead, so
|
||||||
|
/// text can be read while more arrives. A right click presses Enter in the
|
||||||
|
/// entry window when that option is on.
|
||||||
|
private void OnReceivePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||||
|
{
|
||||||
|
RightClick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
|
||||||
|
if (clicked.Length == 0)
|
||||||
|
{
|
||||||
|
paused = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Resume();
|
||||||
|
Copy(clicked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a clicked word goes. N1MM's order: an empty callsign box takes a
|
||||||
|
/// call, a callsign box that already holds one takes another only onto the
|
||||||
|
/// call stack, and everything else is an exchange element.
|
||||||
|
private void Copy(string clicked)
|
||||||
|
{
|
||||||
|
if (Radio is not { } position)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string call = DigitalReceiver.TrimToCall(clicked);
|
||||||
|
bool isCall = call.Length is >= 3 and < 13
|
||||||
|
&& !call.All(char.IsAsciiDigit)
|
||||||
|
&& DigitalReceiver.IsCallsign(call)
|
||||||
|
&& !string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase);
|
||||||
|
bool waiting = position.Entry.Call.Trim().Length == 0;
|
||||||
|
if (isCall && (waiting || StackingOrder() != StackOrder.Disabled))
|
||||||
|
{
|
||||||
|
Grab(call);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!waiting)
|
||||||
|
{
|
||||||
|
entry.GrabExchange(DigitalElement.Trim(clicked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RightClick()
|
||||||
|
{
|
||||||
|
if (!session.Settings.DigitalRightClickSendsEnter)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.PressEnter();
|
||||||
|
if (!session.Settings.DigitalRightClickKeepsCall && Radio is { } position)
|
||||||
|
{
|
||||||
|
position.Wipe();
|
||||||
|
entry.Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets the pane scroll again and prints what arrived while it was stopped.
|
||||||
|
private void Resume()
|
||||||
|
{
|
||||||
|
if (!paused)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paused = false;
|
||||||
|
if (held.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string waiting = held.ToString();
|
||||||
|
held.Clear();
|
||||||
|
Print(waiting);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/Nonemm.App/Windows/DigitalWindow.Macros.cs
Normal file
180
src/Nonemm.App/Windows/DigitalWindow.Macros.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Nonemm.App.Dialogs;
|
||||||
|
using Nonemm.Digital;
|
||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// The macro buttons and the transmit pane: what goes out, and the engine
|
||||||
|
/// controls beside them.
|
||||||
|
public sealed partial class DigitalWindow
|
||||||
|
{
|
||||||
|
private const int MacroColumns = 8;
|
||||||
|
|
||||||
|
private readonly List<Button> macroButtons = [];
|
||||||
|
|
||||||
|
private DigitalMacros Macros => Messages.Digital(session.Settings.DigitalMessageFile);
|
||||||
|
|
||||||
|
/// Three rows of eight, which is how N1MM lays its digital macros out.
|
||||||
|
private void BuildMacros()
|
||||||
|
{
|
||||||
|
for (int row = 0; row < DigitalMacros.ButtonCount / MacroColumns; row++)
|
||||||
|
{
|
||||||
|
UniformGrid grid = new() { Columns = MacroColumns, Rows = 1 };
|
||||||
|
Grid.SetRow(grid, row);
|
||||||
|
for (int column = 0; column < MacroColumns; column++)
|
||||||
|
{
|
||||||
|
int index = (row * MacroColumns) + column;
|
||||||
|
Button button = new() { Classes = { "macro" }, Tag = index };
|
||||||
|
button.Click += (_, _) => SendMacro(index);
|
||||||
|
button.AddHandler(PointerPressedEvent, OnMacroPointerPressed, RoutingStrategies.Tunnel);
|
||||||
|
macroButtons.Add(button);
|
||||||
|
grid.Children.Add(button);
|
||||||
|
}
|
||||||
|
MacroRows.Children.Add(grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LabelMacros()
|
||||||
|
{
|
||||||
|
DigitalMacros macros = Macros;
|
||||||
|
for (int index = 0; index < macroButtons.Count; index++)
|
||||||
|
{
|
||||||
|
FunctionKey macro = macros[index];
|
||||||
|
macroButtons[index].Content = macro.Label.Length > 0 ? macro.Label : $"{index + 1}";
|
||||||
|
macroButtons[index].IsEnabled = macro.Label.Length > 0 || macro.Message.Length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-clicking a macro button opens the macros for editing, the way
|
||||||
|
/// right-clicking a function key does in the entry window.
|
||||||
|
private void OnMacroPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
_ = EditMacrosAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A macro goes out through the entry window, so the action macros in it
|
||||||
|
/// act on the contact being worked. The text goes to this window's own
|
||||||
|
/// engine whatever mode the entry window is on: these buttons are the
|
||||||
|
/// engine's, the way N1MM's digital window keys MMTTY from its own buttons.
|
||||||
|
private async void SendMacro(int index)
|
||||||
|
{
|
||||||
|
string message = Macros[index].Message;
|
||||||
|
if (message.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Running() is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.DigitalKeyer is not { IsReady: true } keyer)
|
||||||
|
{
|
||||||
|
StateText.Text = "the engine is not connected";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await entry.SendTextAsync(message, keyer))
|
||||||
|
{
|
||||||
|
StateText.Text = "nothing was sent — see the entry window";
|
||||||
|
}
|
||||||
|
entry.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EditMacrosAsync()
|
||||||
|
{
|
||||||
|
if (await new DigitalMacrosDialog(session.Settings.DigitalMessageFile)
|
||||||
|
.ShowDialog<string?>(this) is not { } edited)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.Save(session.Settings with { DigitalMessageFile = edited });
|
||||||
|
LabelMacros();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is typed in the transmit pane goes out as it is typed, which is how
|
||||||
|
/// N1MM's window works: the engine transmits each character as it arrives.
|
||||||
|
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (engine is not { IsConnected: true } running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
_ = running.TypeAsync('\r');
|
||||||
|
TransmitBox.Text += "\n";
|
||||||
|
TransmitBox.CaretIndex = TransmitBox.Text.Length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
_ = running.AbortAsync();
|
||||||
|
TransmitBox.Text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTransmit(object? sender, RoutedEventArgs e) => _ = Running()?.SetPttAsync(true);
|
||||||
|
|
||||||
|
private void OnReceive(object? sender, RoutedEventArgs e) => _ = Running()?.AbortAsync();
|
||||||
|
|
||||||
|
private void OnClearTransmit(object? sender, RoutedEventArgs e) => TransmitBox.Text = "";
|
||||||
|
|
||||||
|
private void OnClearReceive(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ReceiveLines.Children.Clear();
|
||||||
|
line = null;
|
||||||
|
word = null;
|
||||||
|
held.Clear();
|
||||||
|
paused = false;
|
||||||
|
receiver.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the demodulator back on the tone pair the operator works on.
|
||||||
|
private void OnAlign(object? sender, RoutedEventArgs e) =>
|
||||||
|
_ = Running()?.TuneAsync(session.Settings.DigitalMarkHertz);
|
||||||
|
|
||||||
|
private void OnHamDefault(object? sender, RoutedEventArgs e) =>
|
||||||
|
_ = Running()?.PostAsync(MmttyMessage.DefaultProfile, 0);
|
||||||
|
|
||||||
|
/// MMTTY's own setup dialog. The engine opens it beside its own window, so
|
||||||
|
/// it comes up wherever the engine window is.
|
||||||
|
private void OnEngineSetup(object? sender, RoutedEventArgs e) =>
|
||||||
|
_ = Running()?.PostAsync(MmttyMessage.Setup, 0);
|
||||||
|
|
||||||
|
private void OnToggleLock(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.AfcBit);
|
||||||
|
|
||||||
|
private void OnToggleReverse(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.ReverseBit);
|
||||||
|
|
||||||
|
/// The engine takes its switches as one word, so a toggle sends the whole
|
||||||
|
/// word back with one bit flipped.
|
||||||
|
private void Toggle(int bit)
|
||||||
|
{
|
||||||
|
if (Running() is not { } running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ = running.SetSwitchesAsync(running.Switches ^ bit);
|
||||||
|
ShowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine, or null with the reason in the status line. A menu item or
|
||||||
|
/// button that needs the engine says why nothing happened rather than
|
||||||
|
/// doing nothing at all.
|
||||||
|
private MmttyEngine? Running()
|
||||||
|
{
|
||||||
|
if (engine is null)
|
||||||
|
{
|
||||||
|
StateText.Text = "the engine is not running — Engine ▸ Start";
|
||||||
|
}
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/Nonemm.App/Windows/DigitalWindow.Menu.cs
Normal file
142
src/Nonemm.App/Windows/DigitalWindow.Menu.cs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Nonemm.App.Configuration;
|
||||||
|
using Nonemm.App.Dialogs;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// The window's menus: what the engine does, and N1MM's options for how the
|
||||||
|
/// receive pane and the grab list behave.
|
||||||
|
public sealed partial class DigitalWindow
|
||||||
|
{
|
||||||
|
/// N1MM caps the receive pane's font at fourteen points.
|
||||||
|
private const int LargestFont = 14;
|
||||||
|
|
||||||
|
private const int SmallestFont = 8;
|
||||||
|
|
||||||
|
private void ApplySettings()
|
||||||
|
{
|
||||||
|
Settings settings = session.Settings;
|
||||||
|
HighlightBackgroundItem.IsChecked = settings.DigitalHighlightBackground;
|
||||||
|
GrabSpaceItem.IsChecked = settings.DigitalGrabSendsSpace;
|
||||||
|
RightClickEnterItem.IsChecked = settings.DigitalRightClickSendsEnter;
|
||||||
|
RightClickKeepsCallItem.IsChecked = settings.DigitalRightClickKeepsCall;
|
||||||
|
GrabNewestFirstItem.IsChecked = settings.DigitalGrabNewestFirst;
|
||||||
|
string filter = settings.DigitalGrabFilter.ToLowerInvariant();
|
||||||
|
GrabEverythingItem.IsChecked = filter is not ("notdupes" or "knowncalls");
|
||||||
|
GrabNotDupesItem.IsChecked = filter == "notdupes";
|
||||||
|
GrabKnownItem.IsChecked = filter == "knowncalls";
|
||||||
|
string stacking = settings.DigitalCallStacking.ToLowerInvariant();
|
||||||
|
StackMultipliersItem.IsChecked = stacking == "multipliers";
|
||||||
|
StackFirstInItem.IsChecked = stacking == "firstin";
|
||||||
|
StackLastInItem.IsChecked = stacking == "lastin";
|
||||||
|
StackDisabledItem.IsChecked = stacking is not ("multipliers" or "firstin" or "lastin");
|
||||||
|
grab.NewestFirst = settings.DigitalGrabNewestFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Store(Settings settings)
|
||||||
|
{
|
||||||
|
session.Save(settings);
|
||||||
|
ApplySettings();
|
||||||
|
ShowGrabList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStartEngine(object? sender, RoutedEventArgs e) => _ = StartAsync();
|
||||||
|
|
||||||
|
private void OnStopEngine(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Detach();
|
||||||
|
session.StopDigital();
|
||||||
|
ShowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnEngineSettings(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (await new DigitalDialog(session.Settings).ShowDialog<Settings?>(this) is not { } edited)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Store(edited);
|
||||||
|
ShowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEditMacros(object? sender, RoutedEventArgs e) => _ = EditMacrosAsync();
|
||||||
|
|
||||||
|
private void OnLargerFont(object? sender, RoutedEventArgs e) => Resize(1);
|
||||||
|
|
||||||
|
private void OnSmallerFont(object? sender, RoutedEventArgs e) => Resize(-1);
|
||||||
|
|
||||||
|
private void Resize(int step)
|
||||||
|
{
|
||||||
|
int wanted = Math.Clamp(session.Settings.DigitalFontSize + step, SmallestFont, LargestFont);
|
||||||
|
Store(session.Settings with { DigitalFontSize = wanted });
|
||||||
|
foreach (var lineOfText in ReceiveLines.Children.OfType<Avalonia.Controls.WrapPanel>())
|
||||||
|
{
|
||||||
|
foreach (var block in lineOfText.Children.OfType<Avalonia.Controls.TextBlock>())
|
||||||
|
{
|
||||||
|
block.FontSize = wanted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToggleHighlight(object? sender, RoutedEventArgs e) =>
|
||||||
|
Store(session.Settings with
|
||||||
|
{
|
||||||
|
DigitalHighlightBackground = !session.Settings.DigitalHighlightBackground,
|
||||||
|
});
|
||||||
|
|
||||||
|
private void OnToggleGrabSpace(object? sender, RoutedEventArgs e) =>
|
||||||
|
Store(session.Settings with
|
||||||
|
{
|
||||||
|
DigitalGrabSendsSpace = !session.Settings.DigitalGrabSendsSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
private void OnToggleRightClick(object? sender, RoutedEventArgs e) =>
|
||||||
|
Store(session.Settings with
|
||||||
|
{
|
||||||
|
DigitalRightClickSendsEnter = !session.Settings.DigitalRightClickSendsEnter,
|
||||||
|
});
|
||||||
|
|
||||||
|
private void OnToggleRightClickKeepsCall(object? sender, RoutedEventArgs e) =>
|
||||||
|
Store(session.Settings with
|
||||||
|
{
|
||||||
|
DigitalRightClickKeepsCall = !session.Settings.DigitalRightClickKeepsCall,
|
||||||
|
});
|
||||||
|
|
||||||
|
private void OnToggleGrabOrder(object? sender, RoutedEventArgs e) =>
|
||||||
|
Store(session.Settings with
|
||||||
|
{
|
||||||
|
DigitalGrabNewestFirst = !session.Settings.DigitalGrabNewestFirst,
|
||||||
|
});
|
||||||
|
|
||||||
|
private void OnGrabEverything(object? sender, RoutedEventArgs e) => Filter("everything");
|
||||||
|
|
||||||
|
private void OnGrabNotDupes(object? sender, RoutedEventArgs e) => Filter("notdupes");
|
||||||
|
|
||||||
|
private void OnGrabKnown(object? sender, RoutedEventArgs e) => Filter("knowncalls");
|
||||||
|
|
||||||
|
private void Filter(string which) =>
|
||||||
|
Store(session.Settings with { DigitalGrabFilter = which });
|
||||||
|
|
||||||
|
private void OnClearGrabList(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
grab.Clear();
|
||||||
|
ShowGrabList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStackMultipliers(object? sender, RoutedEventArgs e) => Stacking("multipliers");
|
||||||
|
|
||||||
|
private void OnStackFirstIn(object? sender, RoutedEventArgs e) => Stacking("firstin");
|
||||||
|
|
||||||
|
private void OnStackLastIn(object? sender, RoutedEventArgs e) => Stacking("lastin");
|
||||||
|
|
||||||
|
private void OnStackDisabled(object? sender, RoutedEventArgs e) => Stacking("disabled");
|
||||||
|
|
||||||
|
private void Stacking(string which)
|
||||||
|
{
|
||||||
|
Store(session.Settings with { DigitalCallStacking = which });
|
||||||
|
if (Radio is { } position)
|
||||||
|
{
|
||||||
|
position.Stack.Order = StackingOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/Nonemm.App/Windows/DigitalWindow.axaml
Normal file
134
src/Nonemm.App/Windows/DigitalWindow.axaml
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:Nonemm.App.Windows"
|
||||||
|
x:Class="Nonemm.App.Windows.DigitalWindow"
|
||||||
|
Title="Digital Interface" Width="900" Height="640">
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="Button.macro">
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Padding" Value="2,3" />
|
||||||
|
<Setter Property="Margin" Value="1" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.control">
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Padding" Value="8,3" />
|
||||||
|
<Setter Property="Margin" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.status">
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Opacity" Value="0.7" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
|
<DockPanel>
|
||||||
|
<Menu DockPanel.Dock="Top" Name="MenuBar">
|
||||||
|
<MenuItem Header="_Engine">
|
||||||
|
<MenuItem Header="Start" Click="OnStartEngine" />
|
||||||
|
<MenuItem Header="Stop" Click="OnStopEngine" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="MMTTY setup window" Click="OnEngineSetup" />
|
||||||
|
<MenuItem Header="Ham default profile" Click="OnHamDefault" />
|
||||||
|
<MenuItem Header="Align to mark tone" Click="OnAlign" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Settings…" Click="OnEngineSettings" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Setup">
|
||||||
|
<MenuItem Header="Macros…" Click="OnEditMacros" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Larger font" Click="OnLargerFont" />
|
||||||
|
<MenuItem Header="Smaller font" Click="OnSmallerFont" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Clear receive pane" Click="OnClearReceive" />
|
||||||
|
<MenuItem Header="Clear transmit pane" Click="OnClearTransmit" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Options">
|
||||||
|
<MenuItem Name="HighlightBackgroundItem" Header="Highlight the background"
|
||||||
|
ToggleType="CheckBox" Click="OnToggleHighlight" />
|
||||||
|
<MenuItem Name="GrabSpaceItem" Header="A grabbed call is followed by a space"
|
||||||
|
ToggleType="CheckBox" Click="OnToggleGrabSpace" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Name="RightClickEnterItem" Header="Right click presses Enter"
|
||||||
|
ToggleType="CheckBox" Click="OnToggleRightClick" />
|
||||||
|
<MenuItem Name="RightClickKeepsCallItem" Header="Right click keeps the call"
|
||||||
|
ToggleType="CheckBox" Click="OnToggleRightClickKeepsCall" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Grab list">
|
||||||
|
<MenuItem Name="GrabEverythingItem" Header="Everything heard"
|
||||||
|
ToggleType="Radio" GroupName="grab" Click="OnGrabEverything" />
|
||||||
|
<MenuItem Name="GrabNotDupesItem" Header="Not dupes"
|
||||||
|
ToggleType="Radio" GroupName="grab" Click="OnGrabNotDupes" />
|
||||||
|
<MenuItem Name="GrabKnownItem" Header="Known calls only"
|
||||||
|
ToggleType="Radio" GroupName="grab" Click="OnGrabKnown" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Name="GrabNewestFirstItem" Header="Newest first"
|
||||||
|
ToggleType="CheckBox" Click="OnToggleGrabOrder" />
|
||||||
|
<MenuItem Header="Clear the list" Click="OnClearGrabList" />
|
||||||
|
</MenuItem>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Call _stacking">
|
||||||
|
<MenuItem Name="StackMultipliersItem" Header="Multipliers first"
|
||||||
|
ToggleType="Radio" GroupName="stack" Click="OnStackMultipliers" />
|
||||||
|
<MenuItem Name="StackFirstInItem" Header="First in, first out"
|
||||||
|
ToggleType="Radio" GroupName="stack" Click="OnStackFirstIn" />
|
||||||
|
<MenuItem Name="StackLastInItem" Header="Last in, first out"
|
||||||
|
ToggleType="Radio" GroupName="stack" Click="OnStackLastIn" />
|
||||||
|
<MenuItem Name="StackDisabledItem" Header="Off"
|
||||||
|
ToggleType="Radio" GroupName="stack" Click="OnStackDisabled" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="Auto,*,Auto,Auto" Margin="6,2,6,4">
|
||||||
|
<TextBlock Classes="status" Name="MouseOverText" MinWidth="120" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="status" Name="StateText" HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="status" Name="FigsText" Text="Letters" Margin="0,0,10,0" />
|
||||||
|
<Border Grid.Column="3" Name="TransmitDot" CornerRadius="7" Width="30" Height="16"
|
||||||
|
Background="Transparent" BorderThickness="1">
|
||||||
|
<TextBlock Text="TX" FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Margin="4,0,4,2">
|
||||||
|
<Grid Name="MacroRows" RowDefinitions="Auto,Auto,Auto" />
|
||||||
|
<WrapPanel Margin="0,3,0,0">
|
||||||
|
<Button Classes="control" Content="Clr RX" Click="OnClearReceive" />
|
||||||
|
<Button Classes="control" Content="Align" Click="OnAlign" />
|
||||||
|
<Button Classes="control" Name="TransmitButton" Content="TX" Click="OnTransmit" />
|
||||||
|
<Button Classes="control" Content="RX" Click="OnReceive" />
|
||||||
|
<Button Classes="control" Content="Ham" Click="OnHamDefault" />
|
||||||
|
<Button Classes="control" Name="LockButton" Content="Lock" Click="OnToggleLock" />
|
||||||
|
<Button Classes="control" Name="ReverseButton" Content="Rev" Click="OnToggleReverse" />
|
||||||
|
<Button Classes="control" Content="CLR" Click="OnClearTransmit" />
|
||||||
|
<Button Classes="control" Content="Grab" Click="OnGrabNext" />
|
||||||
|
</WrapPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="3*,Auto,*" Margin="4,2">
|
||||||
|
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||||
|
<ScrollViewer Name="ReceiveScroller" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Name="ReceiveLines" Margin="4,2"
|
||||||
|
PointerMoved="OnReceivePointerMoved"
|
||||||
|
PointerPressed="OnReceivePointerPressed"
|
||||||
|
PointerExited="OnReceivePointerExited" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<GridSplitter Grid.Row="1" Height="4" ResizeDirection="Rows" Background="Transparent" />
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
|
||||||
|
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||||
|
<TextBox Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
FontFamily="monospace" BorderThickness="0"
|
||||||
|
KeyDown="OnTransmitKeyDown" />
|
||||||
|
</Border>
|
||||||
|
<Border Grid.Column="2" BorderThickness="1" BorderBrush="#40808080">
|
||||||
|
<ScrollViewer Name="GrabScroller">
|
||||||
|
<StackPanel Name="GrabCalls" Margin="3,2" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</local:RefreshableWindow>
|
||||||
298
src/Nonemm.App/Windows/DigitalWindow.axaml.cs
Normal file
298
src/Nonemm.App/Windows/DigitalWindow.axaml.cs
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Nonemm.App.Theming;
|
||||||
|
using Nonemm.Digital;
|
||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// N1MM's digital interface window: what the modem decodes, what is about to go
|
||||||
|
/// out, the calls heard, and the macro buttons. Every callsign in the receive
|
||||||
|
/// pane is coloured by what working it would bring, and double-clicking one
|
||||||
|
/// puts it in the entry window.
|
||||||
|
public sealed partial class DigitalWindow : RefreshableWindow
|
||||||
|
{
|
||||||
|
/// How many lines of decoded text are kept in the pane. A contest weekend
|
||||||
|
/// of RTTY is more than any window needs to hold.
|
||||||
|
private const int KeptLines = 300;
|
||||||
|
|
||||||
|
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(45);
|
||||||
|
|
||||||
|
private readonly AppSession session;
|
||||||
|
private readonly EntryWindow entry;
|
||||||
|
private readonly int radioNumber;
|
||||||
|
private readonly DigitalReceiver receiver = new();
|
||||||
|
private readonly GrabList grab = new();
|
||||||
|
|
||||||
|
/// What arrived while the pane was paused, held until it starts again.
|
||||||
|
private readonly StringBuilder held = new();
|
||||||
|
|
||||||
|
private WrapPanel? line;
|
||||||
|
private TextBlock? word;
|
||||||
|
private TextBlock? hovered;
|
||||||
|
private MmttyEngine? engine;
|
||||||
|
private bool paused;
|
||||||
|
private bool lastWasReturn;
|
||||||
|
|
||||||
|
public DigitalWindow(AppSession session, EntryWindow entry, int radioNumber = 1)
|
||||||
|
{
|
||||||
|
this.session = session;
|
||||||
|
this.entry = entry;
|
||||||
|
this.radioNumber = radioNumber;
|
||||||
|
InitializeComponent();
|
||||||
|
receiver.CallHeard += (_, call) => Dispatcher.UIThread.Post(() => Heard(call));
|
||||||
|
BuildMacros();
|
||||||
|
ApplySettings();
|
||||||
|
Attach(session.Digital);
|
||||||
|
Refresh();
|
||||||
|
Closed += (_, _) => Detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
private OperatingPosition? Radio =>
|
||||||
|
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
|
||||||
|
|
||||||
|
public override void Refresh()
|
||||||
|
{
|
||||||
|
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
|
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
|
ShowState();
|
||||||
|
ShowGrabList();
|
||||||
|
LabelMacros();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the engine if it is not running, and connects this window to it.
|
||||||
|
private async Task StartAsync()
|
||||||
|
{
|
||||||
|
if (session.Settings.DigitalEnginePath.Trim().Length == 0
|
||||||
|
|| session.Settings.DigitalBridgePath.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
StateText.Text = "no engine — Engine ▸ Settings";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StateText.Text = "starting the engine…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Attach(await session.StartDigitalAsync().WaitAsync(Patience));
|
||||||
|
}
|
||||||
|
catch (Exception failure) when (failure is InvalidOperationException or TimeoutException)
|
||||||
|
{
|
||||||
|
StateText.Text = failure.Message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Attach(MmttyEngine? started)
|
||||||
|
{
|
||||||
|
if (started is null || ReferenceEquals(started, engine))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Detach();
|
||||||
|
engine = started;
|
||||||
|
started.Received += WhenReceived;
|
||||||
|
started.TransmitChanged += WhenTransmitChanged;
|
||||||
|
started.ConnectionChanged += WhenConnectionChanged;
|
||||||
|
started.Reported += WhenReported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Detach()
|
||||||
|
{
|
||||||
|
if (engine is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engine.Received -= WhenReceived;
|
||||||
|
engine.TransmitChanged -= WhenTransmitChanged;
|
||||||
|
engine.ConnectionChanged -= WhenConnectionChanged;
|
||||||
|
engine.Reported -= WhenReported;
|
||||||
|
engine = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WhenReceived(object? sender, string text) =>
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
receiver.Receive(text);
|
||||||
|
Print(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
TransmitDot.Background = transmitting
|
||||||
|
? Themes.Brush(Themes.Current.TransmitLight)
|
||||||
|
: Brushes.Transparent;
|
||||||
|
});
|
||||||
|
|
||||||
|
private void WhenConnectionChanged(object? sender, bool connected) =>
|
||||||
|
Dispatcher.UIThread.Post(ShowState);
|
||||||
|
|
||||||
|
private void WhenReported(object? sender, string text) =>
|
||||||
|
Dispatcher.UIThread.Post(() => StateText.Text = text);
|
||||||
|
|
||||||
|
/// N1MM titles the window with where the radio is and what is decoding,
|
||||||
|
/// which is how an operator with two of them tells them apart.
|
||||||
|
private void ShowTitle()
|
||||||
|
{
|
||||||
|
string radio = $"DI{radioNumber}";
|
||||||
|
string mode = Radio?.Mode.Name ?? "RTTY";
|
||||||
|
string frequency = Radio is { } position
|
||||||
|
? position.Frequency.Kilohertz.ToString("0.00", CultureInfo.InvariantCulture)
|
||||||
|
: "";
|
||||||
|
string what = session.Settings.DigitalEnginePath.Trim().Length > 0
|
||||||
|
? Path.GetFileNameWithoutExtension(session.Settings.DigitalEnginePath)
|
||||||
|
: "no engine";
|
||||||
|
Title = $"{frequency} {radio} {mode} Mode - {what}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowState()
|
||||||
|
{
|
||||||
|
ShowTitle();
|
||||||
|
StateText.Text = engine switch
|
||||||
|
{
|
||||||
|
{ IsConnected: true } running =>
|
||||||
|
$"{Path.GetFileName(session.Settings.DigitalEnginePath)} {running.Version}"
|
||||||
|
+ $" · mark {running.MarkHertz} Hz",
|
||||||
|
not null => "the engine is not connected",
|
||||||
|
_ => "the engine is not running",
|
||||||
|
};
|
||||||
|
LockButton.FontWeight = IsSwitchOn(MmttySettings.AfcBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||||
|
ReverseButton.FontWeight = IsSwitchOn(MmttySettings.ReverseBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsSwitchOn(int bit) => engine is { } running && (running.Switches & bit) != 0;
|
||||||
|
|
||||||
|
/// Prints what the modem decoded, one word at a time so each word is its
|
||||||
|
/// own control and can be coloured, pointed at and clicked.
|
||||||
|
private void Print(string text)
|
||||||
|
{
|
||||||
|
if (paused)
|
||||||
|
{
|
||||||
|
held.Append(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (char c in text)
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '\r':
|
||||||
|
NewLine();
|
||||||
|
lastWasReturn = true;
|
||||||
|
continue;
|
||||||
|
// a modem that sends both ends one line, not two
|
||||||
|
case '\n' when lastWasReturn:
|
||||||
|
lastWasReturn = false;
|
||||||
|
continue;
|
||||||
|
case '\n':
|
||||||
|
NewLine();
|
||||||
|
continue;
|
||||||
|
case ' ':
|
||||||
|
lastWasReturn = false;
|
||||||
|
EndWord();
|
||||||
|
Line().Children.Add(Space());
|
||||||
|
continue;
|
||||||
|
default:
|
||||||
|
lastWasReturn = false;
|
||||||
|
Word().Text += c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrimLines();
|
||||||
|
ReceiveScroller.ScrollToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NewLine()
|
||||||
|
{
|
||||||
|
EndWord();
|
||||||
|
line = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WrapPanel Line()
|
||||||
|
{
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
line = new WrapPanel();
|
||||||
|
ReceiveLines.Children.Add(line);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock Word()
|
||||||
|
{
|
||||||
|
if (word is null)
|
||||||
|
{
|
||||||
|
word = NewBlock("");
|
||||||
|
Line().Children.Add(word);
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock Space() => NewBlock(" ");
|
||||||
|
|
||||||
|
private TextBlock NewBlock(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontFamily = new FontFamily("monospace"),
|
||||||
|
FontSize = session.Settings.DigitalFontSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A finished word is judged: one shaped like a callsign is coloured the
|
||||||
|
/// way every other window colours a station.
|
||||||
|
private void EndWord()
|
||||||
|
{
|
||||||
|
if (word is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string call = DigitalReceiver.TrimToCall(DigitalReceiver.Scrub(word.Text ?? ""));
|
||||||
|
if (DigitalReceiver.IsCallsign(call))
|
||||||
|
{
|
||||||
|
Colour(word, call);
|
||||||
|
}
|
||||||
|
word = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Colour(TextBlock block, string call)
|
||||||
|
{
|
||||||
|
if (Radio is not { } position)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
block.Foreground = Verdicts.MyCall;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Contests.Verdict? verdict = position.JudgeCall(call);
|
||||||
|
if (session.Settings.DigitalHighlightBackground)
|
||||||
|
{
|
||||||
|
block.Background = Verdicts.Background(verdict);
|
||||||
|
block.Foreground = Themes.TextOn(Verdicts.BackgroundColour(verdict));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
block.Foreground = Verdicts.Colour(verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrimLines()
|
||||||
|
{
|
||||||
|
while (ReceiveLines.Children.Count > KeptLines)
|
||||||
|
{
|
||||||
|
ReceiveLines.Children.RemoveAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StackOrder StackingOrder() =>
|
||||||
|
session.Settings.DigitalCallStacking.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"multipliers" => StackOrder.MultipliersFirst,
|
||||||
|
"firstin" => StackOrder.FirstIn,
|
||||||
|
"lastin" => StackOrder.LastIn,
|
||||||
|
_ => StackOrder.Disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,23 @@ public sealed partial class EntryWindow
|
|||||||
/// actions after `{END}` still run.
|
/// actions after `{END}` still run.
|
||||||
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
|
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
/// What sends this radio's messages: the digital engine on a digital mode,
|
||||||
|
/// and the CW or voice keyer otherwise. A message therefore goes out the
|
||||||
|
/// way the mode needs without the caller choosing.
|
||||||
|
private MessageSender? Sender =>
|
||||||
|
Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine
|
||||||
|
? engine
|
||||||
|
: session.Keyer;
|
||||||
|
|
||||||
|
/// What to say when a message has nowhere to go. The mode decides which
|
||||||
|
/// keyer a message uses, so the mode is part of the answer: an operator on
|
||||||
|
/// a data mode who sees "no keyer for USB" knows to move the radio or the
|
||||||
|
/// band panel rather than to look at the keyer settings.
|
||||||
|
private string NoKeyer() =>
|
||||||
|
Logging is { } logging
|
||||||
|
? $"no keyer for {logging.Mode.Name} — Config ▸ Keyer"
|
||||||
|
: "no keyer — Config ▸ Keyer";
|
||||||
|
|
||||||
/// The twelve keys for this radio, which are a different twelve while
|
/// The twelve keys for this radio, which are a different twelve while
|
||||||
/// running and while searching, as N1MM's file holds them.
|
/// running and while searching, as N1MM's file holds them.
|
||||||
private IReadOnlyList<FunctionKey> Keys() =>
|
private IReadOnlyList<FunctionKey> Keys() =>
|
||||||
@@ -74,9 +91,9 @@ public sealed partial class EntryWindow
|
|||||||
// a key that only acts: {WIPE}, {RUN}, {TELNET sh/dx}
|
// a key that only acts: {WIPE}, {RUN}, {TELNET sh/dx}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (session.Keyer is null)
|
if (Sender is null)
|
||||||
{
|
{
|
||||||
Status("no keyer — Config ▸ Keyer");
|
Status(NoKeyer());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (index == Esm.Exchange)
|
if (index == Esm.Exchange)
|
||||||
@@ -97,18 +114,23 @@ public sealed partial class EntryWindow
|
|||||||
// the box has to point at this radio before the key does
|
// the box has to point at this radio before the key does
|
||||||
await session.PointTransmitAtAsync(radioNumber);
|
await session.PointTransmitAtAsync(radioNumber);
|
||||||
Status($"sending {text}");
|
Status($"sending {text}");
|
||||||
await SendThenAsync(session.Keyer, text, plan.After);
|
await SendThenAsync(Sender, text, plan.After);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a message that did not come from a function key. The QTC window
|
/// Sends a message that did not come from a function key. The QTC window
|
||||||
/// hands its traffic over this way, as N1MM's does through its own entry
|
/// hands its traffic over this way, as N1MM's does through its own entry
|
||||||
/// window, so it goes out through the same keyer with the same macros
|
/// window, so it goes out through the same keyer with the same macros
|
||||||
/// expanded.
|
/// expanded.
|
||||||
private async Task SendTextAsync(string template)
|
/// `through` is the keyer the caller insists on: the digital window keys
|
||||||
|
/// its own engine whatever mode the entry window is on, which is what
|
||||||
|
/// N1MM's digital window does. Everyone else passes null and gets the keyer
|
||||||
|
/// the mode calls for. False when nothing went out, with the reason already
|
||||||
|
/// in the status line.
|
||||||
|
internal async Task<bool> SendTextAsync(string template, MessageSender? through = null)
|
||||||
{
|
{
|
||||||
if (Logging is null || template.Trim().Length == 0)
|
if (Logging is null || template.Trim().Length == 0)
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
|
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
|
||||||
foreach (MessageAction action in plan.Before)
|
foreach (MessageAction action in plan.Before)
|
||||||
@@ -117,16 +139,18 @@ public sealed partial class EntryWindow
|
|||||||
}
|
}
|
||||||
if (plan.Text.Length == 0)
|
if (plan.Text.Length == 0)
|
||||||
{
|
{
|
||||||
return;
|
// a button that only acts: {WIPE}, {LOG}, {RUN}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
if (session.Keyer is null)
|
if ((through ?? Sender) is not { } keyer)
|
||||||
{
|
{
|
||||||
Status("no keyer — Config ▸ Keyer");
|
Status(NoKeyer());
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
await session.PointTransmitAtAsync(radioNumber);
|
await session.PointTransmitAtAsync(radioNumber);
|
||||||
Status($"sending {plan.Text}");
|
Status($"sending {plan.Text}");
|
||||||
await SendThenAsync(session.Keyer, plan.Text, plan.After);
|
await SendThenAsync(keyer, plan.Text, plan.After);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hands the text to the keyer, and leaves what follows `{END}` to run on
|
/// Hands the text to the keyer, and leaves what follows `{END}` to run on
|
||||||
@@ -236,7 +260,7 @@ public sealed partial class EntryWindow
|
|||||||
break;
|
break;
|
||||||
case MessageCommand.StopSending:
|
case MessageCommand.StopSending:
|
||||||
sending = false;
|
sending = false;
|
||||||
_ = session.Keyer?.AbortAsync();
|
_ = Sender?.AbortAsync();
|
||||||
break;
|
break;
|
||||||
case MessageCommand.Telnet:
|
case MessageCommand.Telnet:
|
||||||
SendToCluster(action.Argument);
|
SendToCluster(action.Argument);
|
||||||
@@ -267,6 +291,15 @@ public sealed partial class EntryWindow
|
|||||||
Logging.Stack.Clear();
|
Logging.Stack.Clear();
|
||||||
ShowCallStack();
|
ShowCallStack();
|
||||||
break;
|
break;
|
||||||
|
// the digital engine keys itself while it has text to send, so
|
||||||
|
// {TX} and {RX} only matter when a macro wants the transmitter
|
||||||
|
// held open around what it sends
|
||||||
|
case MessageCommand.StartTransmit:
|
||||||
|
_ = session.Digital?.SetPttAsync(true);
|
||||||
|
break;
|
||||||
|
case MessageCommand.ReturnToReceive:
|
||||||
|
_ = session.Digital?.ReturnToReceiveAsync();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +417,7 @@ public sealed partial class EntryWindow
|
|||||||
{
|
{
|
||||||
if (Logging is null
|
if (Logging is null
|
||||||
|| session.Other(Logging) is not { } other
|
|| session.Other(Logging) is not { } other
|
||||||
|| session.Keyer is null
|
|| Sender is null
|
||||||
|| !int.TryParse(key, out int number))
|
|| !int.TryParse(key, out int number))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -407,7 +440,7 @@ public sealed partial class EntryWindow
|
|||||||
await session.PointTransmitAtAsync(radio);
|
await session.PointTransmitAtAsync(radio);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await session.Keyer!.SendAsync(text);
|
await Sender!.SendAsync(text);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException e)
|
catch (InvalidOperationException e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -430,6 +430,10 @@ public sealed partial class EntryWindow
|
|||||||
|
|
||||||
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
|
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
|
||||||
|
|
||||||
|
/// N1MM's digital interface window, which is where RTTY is worked from.
|
||||||
|
private void OnShowDigital(object? sender, RoutedEventArgs e) =>
|
||||||
|
Show(() => new DigitalWindow(session, this, radioNumber));
|
||||||
|
|
||||||
private async void OnStationSettings(object? sender, RoutedEventArgs e)
|
private async void OnStationSettings(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
StationDialog dialog = new(session.Settings.Station);
|
StationDialog dialog = new(session.Settings.Station);
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
|
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
|
||||||
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
||||||
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
||||||
|
<MenuItem Header="Digital Interface" Click="OnShowDigital" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
// N1MM's Escape holds alternating CQ where it is rather than
|
// N1MM's Escape holds alternating CQ where it is rather than
|
||||||
// switching it off; the next CQ carries it on
|
// switching it off; the next CQ carries it on
|
||||||
session.Alternating?.Hold();
|
session.Alternating?.Hold();
|
||||||
_ = session.Keyer?.AbortAsync();
|
_ = Sender?.AbortAsync();
|
||||||
Logging.Wipe();
|
Logging.Wipe();
|
||||||
ResetEsm();
|
ResetEsm();
|
||||||
SyncBoxes();
|
SyncBoxes();
|
||||||
@@ -480,6 +480,18 @@ public sealed partial class EntryWindow : Window
|
|||||||
boxes[0].Focus();
|
boxes[0].Focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (Logging.PendingMode() is { } mode)
|
||||||
|
{
|
||||||
|
Logging.Tune(Logging.Frequency, mode);
|
||||||
|
// a radio that is connected is moved as well, or its next answer
|
||||||
|
// would put the mode back
|
||||||
|
_ = session.Radio?.SetModeAsync(mode);
|
||||||
|
Logging.Entry.Call = "";
|
||||||
|
SyncBoxes();
|
||||||
|
boxes[0].Focus();
|
||||||
|
Status($"mode {mode.Name}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (IsEsmOn)
|
if (IsEsmOn)
|
||||||
{
|
{
|
||||||
_ = RunEsmAsync();
|
_ = RunEsmAsync();
|
||||||
@@ -531,6 +543,49 @@ public sealed partial class EntryWindow : Window
|
|||||||
/// What space does: the callsign box and the exchange boxes, stepping over
|
/// What space does: the callsign box and the exchange boxes, stepping over
|
||||||
/// the reports and over a box this station is not asked for, and round to
|
/// the reports and over a box this station is not asked for, and round to
|
||||||
/// the callsign again. This is N1MM's NextTab.
|
/// the callsign again. This is N1MM's NextTab.
|
||||||
|
/// The digital window hands a callsign over: the boxes are wiped and the
|
||||||
|
/// call typed in, which is what double-clicking one in N1MM's receive pane
|
||||||
|
/// does. With `space` the focus moves on to the exchange as though the
|
||||||
|
/// operator had pressed space, so the reports and the call history fill in.
|
||||||
|
internal void GrabCall(string call, bool space)
|
||||||
|
{
|
||||||
|
if (Logging is null || call.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Logging.Wipe();
|
||||||
|
ResetEsm();
|
||||||
|
Logging.Entry.Call = call.Trim().ToUpperInvariant();
|
||||||
|
SyncBoxes();
|
||||||
|
if (space)
|
||||||
|
{
|
||||||
|
MoveFocus(forward: true);
|
||||||
|
}
|
||||||
|
boxes[Logging.Entry.Focus].Focus();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digital window's click on something that is not a callsign: it goes
|
||||||
|
/// in the exchange box the contest keeps for that kind of value, and the
|
||||||
|
/// cursor follows it. False when the contest has no box that takes it.
|
||||||
|
internal bool GrabExchange(string text)
|
||||||
|
{
|
||||||
|
if (Logging is null || DigitalElement.FieldFor(Logging.Entry, text) is not { } field)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Logging.Entry[field] = text;
|
||||||
|
Logging.Entry.FocusOn(field);
|
||||||
|
SyncBoxes();
|
||||||
|
boxes[field].Focus();
|
||||||
|
Refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digital window's right click, which N1MM makes the same as pressing
|
||||||
|
/// Enter here.
|
||||||
|
internal void PressEnter() => OnEnter();
|
||||||
|
|
||||||
private void MoveFocus(bool forward)
|
private void MoveFocus(bool forward)
|
||||||
{
|
{
|
||||||
if (Logging is null || boxes.Count == 0)
|
if (Logging is null || boxes.Count == 0)
|
||||||
@@ -621,7 +676,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
{
|
{
|
||||||
sending = false;
|
sending = false;
|
||||||
session.Alternating?.Hold();
|
session.Alternating?.Hold();
|
||||||
_ = session.Keyer?.AbortAsync();
|
_ = Sender?.AbortAsync();
|
||||||
Status("stopped sending");
|
Status("stopped sending");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,7 +763,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
updating = false;
|
updating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Refresh()
|
internal void Refresh()
|
||||||
{
|
{
|
||||||
ShowFunctionKeys();
|
ShowFunctionKeys();
|
||||||
ShowBandButtons();
|
ShowBandButtons();
|
||||||
@@ -1042,7 +1097,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
Status("nothing left to report to " + station.Text);
|
Status("nothing left to report to " + station.Text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
QtcWindow window = new(session, Logging, station, direction, SendTextAsync);
|
QtcWindow window = new(session, Logging, station, direction, text => SendTextAsync(text));
|
||||||
bool saved = await window.ShowDialog<bool>(this);
|
bool saved = await window.ShowDialog<bool>(this);
|
||||||
Logging.Wipe();
|
Logging.Wipe();
|
||||||
SyncBoxes();
|
SyncBoxes();
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ public static class Modes
|
|||||||
index["PKT"] = Digital;
|
index["PKT"] = Digital;
|
||||||
index["DATA"] = Digital;
|
index["DATA"] = Digital;
|
||||||
index["SSB"] = Usb;
|
index["SSB"] = Usb;
|
||||||
|
// hamlib's own names, which is what rigctld answers `m` with. A radio
|
||||||
|
// in one of its data modes is on a digital mode: that is how AFSK RTTY
|
||||||
|
// is run, and without these the mode reads as the one before it.
|
||||||
|
index["CWR"] = Cw;
|
||||||
|
index["RTTYR"] = Rtty;
|
||||||
|
index["PKTUSB"] = Digital;
|
||||||
|
index["PKTLSB"] = Digital;
|
||||||
|
index["PKTFM"] = Digital;
|
||||||
|
index["PKTAM"] = Digital;
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/Nonemm.Digital/BridgeChannel.cs
Normal file
20
src/Nonemm.Digital/BridgeChannel.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// The line connection to a bridge process. What is behind it is Wine in
|
||||||
|
/// production and a fake in the tests.
|
||||||
|
public interface BridgeChannel : IDisposable
|
||||||
|
{
|
||||||
|
/// A line the bridge wrote. Raised on the reader thread.
|
||||||
|
event EventHandler<string>? LineReceived;
|
||||||
|
|
||||||
|
/// The bridge stopped on its own. The text says what is known about why.
|
||||||
|
event EventHandler<string>? Failed;
|
||||||
|
|
||||||
|
Task StartAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
Task SendAsync(string line, CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// The path as the engine sees it. Wine has its own drive letters, and the
|
||||||
|
/// engine is started by name from a Windows command line.
|
||||||
|
Task<string> ToWindowsPathAsync(string path, CancellationToken cancellation = default);
|
||||||
|
}
|
||||||
58
src/Nonemm.Digital/BridgeLine.cs
Normal file
58
src/Nonemm.Digital/BridgeLine.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// One line of the bridge protocol: a verb, then its fields, separated by tabs
|
||||||
|
/// and ended by a newline. Tab, newline and backslash inside a field are
|
||||||
|
/// escaped, so a field never splits a line. The C++ bridge writes and reads the
|
||||||
|
/// same format; `docs/digital-bridge.md` states it for both sides.
|
||||||
|
public static class BridgeLine
|
||||||
|
{
|
||||||
|
public static string Write(string verb, params string[] fields) =>
|
||||||
|
fields.Length == 0 ? verb : verb + "\t" + string.Join("\t", fields.Select(Escape));
|
||||||
|
|
||||||
|
public static (string Verb, string[] Fields) Read(string line)
|
||||||
|
{
|
||||||
|
string[] parts = line.Split('\t');
|
||||||
|
return (parts[0], parts.Skip(1).Select(Unescape).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string field)
|
||||||
|
{
|
||||||
|
StringBuilder escaped = new(field.Length);
|
||||||
|
foreach (char c in field)
|
||||||
|
{
|
||||||
|
_ = c switch
|
||||||
|
{
|
||||||
|
'\\' => escaped.Append("\\\\"),
|
||||||
|
'\t' => escaped.Append("\\t"),
|
||||||
|
'\r' => escaped.Append("\\r"),
|
||||||
|
'\n' => escaped.Append("\\n"),
|
||||||
|
_ => escaped.Append(c),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return escaped.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Unescape(string field)
|
||||||
|
{
|
||||||
|
StringBuilder plain = new(field.Length);
|
||||||
|
for (int i = 0; i < field.Length; i++)
|
||||||
|
{
|
||||||
|
if (field[i] != '\\' || i + 1 == field.Length)
|
||||||
|
{
|
||||||
|
plain.Append(field[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
plain.Append(field[i] switch
|
||||||
|
{
|
||||||
|
't' => '\t',
|
||||||
|
'r' => '\r',
|
||||||
|
'n' => '\n',
|
||||||
|
_ => field[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return plain.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Nonemm.Digital/DigitalEngine.cs
Normal file
31
src/Nonemm.Digital/DigitalEngine.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// A digital modem that decodes what is on the air and sends what is typed:
|
||||||
|
/// MMTTY or 2Tone behind the Wine bridge today, fldigi later. The window above
|
||||||
|
/// it works through this and does not know which one is running.
|
||||||
|
public interface DigitalEngine : IDisposable
|
||||||
|
{
|
||||||
|
/// The modem is running and has told us its version.
|
||||||
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
bool IsTransmitting { get; }
|
||||||
|
|
||||||
|
/// Text the modem has decoded, in the pieces it arrived in: usually one
|
||||||
|
/// character. Raised on the reader thread, so a handler that touches the
|
||||||
|
/// screen has to post.
|
||||||
|
event EventHandler<string>? Received;
|
||||||
|
|
||||||
|
event EventHandler<bool>? TransmitChanged;
|
||||||
|
|
||||||
|
event EventHandler<bool>? ConnectionChanged;
|
||||||
|
|
||||||
|
/// Starts the modem and waits until it is connected. Throws if it does not
|
||||||
|
/// come up.
|
||||||
|
Task StartAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// Puts the text in the transmit buffer, which starts the transmission.
|
||||||
|
Task SendAsync(string text, CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// Drops whatever has not gone out yet, which is what Escape does.
|
||||||
|
Task AbortAsync(CancellationToken cancellation = default);
|
||||||
|
}
|
||||||
46
src/Nonemm.Digital/DigitalEngineSender.cs
Normal file
46
src/Nonemm.Digital/DigitalEngineSender.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using Nonemm.Keying;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// The digital engine as something that sends a message, so a macro goes out
|
||||||
|
/// through the same expander, the same `{END}` handling and the same ESM as a
|
||||||
|
/// CW message does.
|
||||||
|
public sealed class DigitalEngineSender : MessageSender
|
||||||
|
{
|
||||||
|
private readonly DigitalEngine engine;
|
||||||
|
|
||||||
|
public DigitalEngineSender(DigitalEngine engine)
|
||||||
|
{
|
||||||
|
this.engine = engine;
|
||||||
|
engine.TransmitChanged += WhenTransmitChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReady => engine.IsConnected;
|
||||||
|
|
||||||
|
/// The engine says when it has stopped transmitting, which is the same
|
||||||
|
/// thing a keyer reports.
|
||||||
|
public bool ReportsCompletion => true;
|
||||||
|
|
||||||
|
public event EventHandler? Finished;
|
||||||
|
|
||||||
|
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||||
|
engine.SendAsync(text, cancellation);
|
||||||
|
|
||||||
|
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||||
|
engine.AbortAsync(cancellation);
|
||||||
|
|
||||||
|
/// RTTY runs at the speed the engine is set to, so there is nothing to set
|
||||||
|
/// per message.
|
||||||
|
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public void Dispose() => engine.TransmitChanged -= WhenTransmitChanged;
|
||||||
|
|
||||||
|
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||||
|
{
|
||||||
|
if (!transmitting)
|
||||||
|
{
|
||||||
|
Finished?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
220
src/Nonemm.Digital/MmttyEngine.cs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// MMTTY or 2Tone, started and driven through the bridge. The startup is
|
||||||
|
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
||||||
|
/// command line, and an engine that fails to initialise is started again up to
|
||||||
|
/// ten times, which is what N1MM's retry does.
|
||||||
|
public sealed class MmttyEngine : DigitalEngine
|
||||||
|
{
|
||||||
|
private const int StartAttempts = 10;
|
||||||
|
|
||||||
|
private readonly BridgeChannel bridge;
|
||||||
|
private readonly MmttyOptions options;
|
||||||
|
private readonly Channel<string> lines = Channel.CreateUnbounded<string>();
|
||||||
|
private readonly TimeSpan startPatience;
|
||||||
|
private TaskCompletionSource<bool>? starting;
|
||||||
|
private MmttySettings settings = new();
|
||||||
|
private string command = "";
|
||||||
|
private int attempts;
|
||||||
|
private Task? reading;
|
||||||
|
|
||||||
|
public MmttyEngine(BridgeChannel bridge, MmttyOptions options, TimeSpan? startPatience = null)
|
||||||
|
{
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.options = options;
|
||||||
|
this.startPatience = startPatience ?? TimeSpan.FromSeconds(30);
|
||||||
|
bridge.LineReceived += (_, line) => lines.Writer.TryWrite(line);
|
||||||
|
bridge.Failed += (_, why) => Stopped(why);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConnected { get; private set; }
|
||||||
|
|
||||||
|
public bool IsTransmitting { get; private set; }
|
||||||
|
|
||||||
|
/// The version MMTTY reported when it connected.
|
||||||
|
public string Version { get; private set; } = "";
|
||||||
|
|
||||||
|
public int MarkHertz { get; private set; }
|
||||||
|
|
||||||
|
public int SpaceHertz { get; private set; }
|
||||||
|
|
||||||
|
/// MMTTY's AFC, net and reverse bits. Kept here because a toggle is sent as
|
||||||
|
/// the whole word, so the current one has to be known.
|
||||||
|
public int Switches { get; private set; }
|
||||||
|
|
||||||
|
public event EventHandler<string>? Received;
|
||||||
|
|
||||||
|
public event EventHandler<bool>? TransmitChanged;
|
||||||
|
|
||||||
|
public event EventHandler<bool>? ConnectionChanged;
|
||||||
|
|
||||||
|
/// Anything the bridge says about itself, for the status line.
|
||||||
|
public event EventHandler<string>? Reported;
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
settings = MmttySettings.Read(options.SettingsPath);
|
||||||
|
Switches = settings.Switches;
|
||||||
|
await bridge.StartAsync(cancellation).ConfigureAwait(false);
|
||||||
|
command = options.CommandLine(
|
||||||
|
await bridge.ToWindowsPathAsync(options.EnginePath, cancellation).ConfigureAwait(false));
|
||||||
|
starting = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
attempts = 1;
|
||||||
|
reading ??= Task.Run(ReadAsync, CancellationToken.None);
|
||||||
|
await OpenAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await starting.Task.WaitAsync(startPatience, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||||
|
bridge.SendAsync(BridgeLine.Write("send", text), cancellation);
|
||||||
|
|
||||||
|
/// One typed character, which MMTTY transmits as it arrives.
|
||||||
|
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
||||||
|
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
||||||
|
|
||||||
|
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
|
||||||
|
/// let the engine stop where it is.
|
||||||
|
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||||
|
SetPttAsync(false, cancellation);
|
||||||
|
|
||||||
|
/// N1MM's `{TX}` and `{RX}`. MMTTY sends what is in its buffer before it
|
||||||
|
/// drops PTT, so a macro that ends with `{RX}` still goes out in full.
|
||||||
|
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
||||||
|
bridge.SendAsync(BridgeLine.Write("ptt", on ? "1" : "0"), cancellation);
|
||||||
|
|
||||||
|
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
||||||
|
SetPttAsync(false, cancellation);
|
||||||
|
|
||||||
|
/// Moves the demodulator, keeping the shift the engine reported.
|
||||||
|
public async Task TuneAsync(int markHertz, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
int shift = SpaceHertz - MarkHertz;
|
||||||
|
await PostAsync(MmttyMessage.MarkFrequency, markHertz, cancellation).ConfigureAwait(false);
|
||||||
|
await PostAsync(MmttyMessage.SpaceFrequency, markHertz + shift, cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetSwitchesAsync(int switches, CancellationToken cancellation = default) =>
|
||||||
|
PostAsync(MmttyMessage.Switches, switches, cancellation);
|
||||||
|
|
||||||
|
public Task PostAsync(MmttyMessage message, int parameter, CancellationToken cancellation = default) =>
|
||||||
|
bridge.SendAsync(
|
||||||
|
BridgeLine.Write("post", ((int)message).ToString(), parameter.ToString()),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
|
/// Shuts the engine down and stops the bridge.
|
||||||
|
public async Task StopAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
await bridge.SendAsync(BridgeLine.Write("quit"), cancellation).ConfigureAwait(false);
|
||||||
|
if (reading is not null)
|
||||||
|
{
|
||||||
|
lines.Writer.TryComplete();
|
||||||
|
await reading.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lines.Writer.TryComplete();
|
||||||
|
bridge.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task OpenAsync(CancellationToken cancellation) =>
|
||||||
|
bridge.SendAsync(
|
||||||
|
BridgeLine.Write("open", options.Title, PttPort(), command),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
|
private string PttPort() => options.PttPort ?? settings.PttPort;
|
||||||
|
|
||||||
|
private async Task ReadAsync()
|
||||||
|
{
|
||||||
|
await foreach (string line in lines.Reader.ReadAllAsync().ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||||
|
await ActOnAsync(verb, fields).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ActOnAsync(string verb, string[] fields)
|
||||||
|
{
|
||||||
|
switch (verb)
|
||||||
|
{
|
||||||
|
case "connected":
|
||||||
|
Version = Field(fields, 0);
|
||||||
|
IsConnected = true;
|
||||||
|
starting?.TrySetResult(true);
|
||||||
|
ConnectionChanged?.Invoke(this, true);
|
||||||
|
break;
|
||||||
|
case "disconnected":
|
||||||
|
await DisconnectedAsync(Number(fields, 0)).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case "rx":
|
||||||
|
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
||||||
|
break;
|
||||||
|
case "tx":
|
||||||
|
IsTransmitting = Number(fields, 0) == 1;
|
||||||
|
TransmitChanged?.Invoke(this, IsTransmitting);
|
||||||
|
break;
|
||||||
|
case "mark":
|
||||||
|
MarkHertz = Number(fields, 0);
|
||||||
|
break;
|
||||||
|
case "space":
|
||||||
|
SpaceHertz = Number(fields, 0);
|
||||||
|
break;
|
||||||
|
case "switch":
|
||||||
|
Switches = Number(fields, 0);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
Stopped(Field(fields, 0));
|
||||||
|
break;
|
||||||
|
case "log":
|
||||||
|
Reported?.Invoke(this, Field(fields, 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status 2 is MMTTY failing to come up, which happens often enough that
|
||||||
|
/// N1MM starts it again rather than telling the operator.
|
||||||
|
private async Task DisconnectedAsync(int status)
|
||||||
|
{
|
||||||
|
if (status != 2)
|
||||||
|
{
|
||||||
|
IsConnected = false;
|
||||||
|
ConnectionChanged?.Invoke(this, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts >= StartAttempts)
|
||||||
|
{
|
||||||
|
Stopped($"{options.EnginePath} failed to start after {attempts} tries");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
Reported?.Invoke(this, $"the engine failed to start, trying again ({attempts})");
|
||||||
|
await OpenAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stopped(string why)
|
||||||
|
{
|
||||||
|
if (starting?.TrySetException(new InvalidOperationException(why)) == true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (IsConnected)
|
||||||
|
{
|
||||||
|
IsConnected = false;
|
||||||
|
ConnectionChanged?.Invoke(this, false);
|
||||||
|
}
|
||||||
|
Reported?.Invoke(this, why);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Field(string[] fields, int index) =>
|
||||||
|
index < fields.Length ? fields[index] : "";
|
||||||
|
|
||||||
|
private static int Number(string[] fields, int index) =>
|
||||||
|
int.TryParse(Field(fields, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||||
|
? value
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
17
src/Nonemm.Digital/MmttyMessage.cs
Normal file
17
src/Nonemm.Digital/MmttyMessage.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// What can be posted to MMTTY, from N1MM's use of the OCX. Message 0 hands the
|
||||||
|
/// engine the host window it reports back to, and the bridge posts that itself
|
||||||
|
/// as part of starting up.
|
||||||
|
public enum MmttyMessage
|
||||||
|
{
|
||||||
|
Shutdown = 2,
|
||||||
|
TypeCharacter = 4,
|
||||||
|
Rate = 8,
|
||||||
|
MarkFrequency = 9,
|
||||||
|
SpaceFrequency = 10,
|
||||||
|
Switches = 11,
|
||||||
|
DefaultProfile = 12,
|
||||||
|
Setup = 13,
|
||||||
|
Profile = 24,
|
||||||
|
}
|
||||||
66
src/Nonemm.Digital/MmttyOptions.cs
Normal file
66
src/Nonemm.Digital/MmttyOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// MMTTY's four window sizes. The letter is the command line switch the engine
|
||||||
|
/// is started with.
|
||||||
|
public enum EngineWindow
|
||||||
|
{
|
||||||
|
Normal,
|
||||||
|
Small,
|
||||||
|
Medium1,
|
||||||
|
Medium2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the engine needs to start: which program, which window, and which
|
||||||
|
/// serial port keys it. The path is a Linux path; the bridge is given the
|
||||||
|
/// Windows form of it.
|
||||||
|
public sealed record MmttyOptions
|
||||||
|
{
|
||||||
|
public required string EnginePath { get; init; }
|
||||||
|
|
||||||
|
/// 1 or 2. A two-radio station runs an engine for each, and the second one
|
||||||
|
/// must not answer to the first one's window title.
|
||||||
|
public int Number { get; init; } = 1;
|
||||||
|
|
||||||
|
public EngineWindow Window { get; init; } = EngineWindow.Normal;
|
||||||
|
|
||||||
|
public bool OnTop { get; init; } = true;
|
||||||
|
|
||||||
|
/// The port MMTTY keys FSK and PTT on. Left null, it is read from Mmtty.INI
|
||||||
|
/// beside the program, which is where MMTTY itself keeps it.
|
||||||
|
public string? PttPort { get; init; }
|
||||||
|
|
||||||
|
/// 2Tone names its own window and takes none of MMTTY's switches except the
|
||||||
|
/// window size.
|
||||||
|
public bool IsTwoTone =>
|
||||||
|
Path.GetFileName(EnginePath).Equals("2tone.exe", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// What the OCX is told to run. The engine has to be quoted: it usually
|
||||||
|
/// sits under Program Files.
|
||||||
|
public string CommandLine(string windowsEnginePath)
|
||||||
|
{
|
||||||
|
string line = $"\"{windowsEnginePath}\"{WindowSwitch()}";
|
||||||
|
if (!OnTop)
|
||||||
|
{
|
||||||
|
line += " -a";
|
||||||
|
}
|
||||||
|
return IsTwoTone ? line : line + " -Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title the OCX gives the engine window. 2Tone titles its own window
|
||||||
|
/// and ignores this.
|
||||||
|
public string Title => $"RTTY Engine {Number}";
|
||||||
|
|
||||||
|
/// The window to look for once the engine is up.
|
||||||
|
public string WindowTitle => IsTwoTone ? $"DI{Number} G3YYD 2Tone" : Title;
|
||||||
|
|
||||||
|
public string SettingsPath =>
|
||||||
|
Path.Combine(Path.GetDirectoryName(EnginePath) ?? ".", "Mmtty.INI");
|
||||||
|
|
||||||
|
private string WindowSwitch() => Window switch
|
||||||
|
{
|
||||||
|
EngineWindow.Small => " -t",
|
||||||
|
EngineWindow.Medium1 => " -s",
|
||||||
|
EngineWindow.Medium2 => " -u",
|
||||||
|
_ => " -r",
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/Nonemm.Digital/MmttySettings.cs
Normal file
63
src/Nonemm.Digital/MmttySettings.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// The part of Mmtty.INI that the logger has to agree with. MMTTY keeps AFC,
|
||||||
|
/// net and reverse in the file and reports them back as switch bits, so the
|
||||||
|
/// starting state has to be read from the same place or the first toggle moves
|
||||||
|
/// the wrong way.
|
||||||
|
public sealed record MmttySettings
|
||||||
|
{
|
||||||
|
public const int AfcBit = 4;
|
||||||
|
public const int NetBit = 8;
|
||||||
|
public const int ReverseBit = 256;
|
||||||
|
|
||||||
|
public string PttPort { get; init; } = "";
|
||||||
|
|
||||||
|
public bool IsAfcOn { get; init; }
|
||||||
|
|
||||||
|
public bool IsNetOn { get; init; }
|
||||||
|
|
||||||
|
public bool IsReversed { get; init; }
|
||||||
|
|
||||||
|
/// The switch word MMTTY starts with.
|
||||||
|
public int Switches =>
|
||||||
|
(IsAfcOn ? AfcBit : 0) | (IsNetOn ? NetBit : 0) | (IsReversed ? ReverseBit : 0);
|
||||||
|
|
||||||
|
/// Reads the `[Define]` section. A file that is not there is not an error:
|
||||||
|
/// MMTTY writes it when it first exits, so a new installation has none.
|
||||||
|
public static MmttySettings Read(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new MmttySettings();
|
||||||
|
}
|
||||||
|
Dictionary<string, string> define = ReadSection(path, "Define");
|
||||||
|
return new MmttySettings
|
||||||
|
{
|
||||||
|
PttPort = define.GetValueOrDefault("PTT", ""),
|
||||||
|
IsAfcOn = define.GetValueOrDefault("AFC") == "1",
|
||||||
|
IsNetOn = define.GetValueOrDefault("TxNet") == "1",
|
||||||
|
IsReversed = define.GetValueOrDefault("Rev") == "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ReadSection(string path, string section)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
bool inSection = false;
|
||||||
|
foreach (string line in File.ReadLines(path))
|
||||||
|
{
|
||||||
|
string text = line.Trim();
|
||||||
|
if (text.StartsWith('[') && text.EndsWith(']'))
|
||||||
|
{
|
||||||
|
inSection = text[1..^1].Equals(section, StringComparison.OrdinalIgnoreCase);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int equals = text.IndexOf('=');
|
||||||
|
if (inSection && equals > 0)
|
||||||
|
{
|
||||||
|
values[text[..equals].Trim()] = text[(equals + 1)..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Nonemm.Digital/Nonemm.Digital.csproj
Normal file
13
src/Nonemm.Digital/Nonemm.Digital.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
144
src/Nonemm.Digital/WineBridgeChannel.cs
Normal file
144
src/Nonemm.Digital/WineBridgeChannel.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// Runs the bridge under Wine and talks to it over its standard input and
|
||||||
|
/// output. Wine writes its own diagnostics to standard error, so the protocol
|
||||||
|
/// has standard output to itself.
|
||||||
|
public sealed class WineBridgeChannel : BridgeChannel
|
||||||
|
{
|
||||||
|
private const int KeptErrorLines = 20;
|
||||||
|
|
||||||
|
private readonly string wine;
|
||||||
|
private readonly string bridgePath;
|
||||||
|
private readonly string? prefix;
|
||||||
|
private readonly Queue<string> lastErrors = new();
|
||||||
|
private readonly SemaphoreSlim writing = new(1, 1);
|
||||||
|
private Process? bridge;
|
||||||
|
|
||||||
|
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
||||||
|
/// which is what a station with one prefix wants.
|
||||||
|
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
|
||||||
|
{
|
||||||
|
this.bridgePath = bridgePath;
|
||||||
|
this.prefix = prefix;
|
||||||
|
this.wine = wine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<string>? LineReceived;
|
||||||
|
|
||||||
|
public event EventHandler<string>? Failed;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
bridge = Start(bridgePath);
|
||||||
|
_ = ReadOutputAsync(bridge);
|
||||||
|
_ = ReadErrorsAsync(bridge);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendAsync(string line, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Process running = bridge ?? throw new InvalidOperationException("the bridge is not started");
|
||||||
|
await writing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
writing.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ToWindowsPathAsync(
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
using Process winepath = Start("winepath", "-w", path);
|
||||||
|
string converted = await winepath.StandardOutput.ReadToEndAsync(cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await winepath.WaitForExitAsync(cancellation).ConfigureAwait(false);
|
||||||
|
if (winepath.ExitCode != 0 || converted.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"winepath -w {path} failed with exit code {winepath.ExitCode}");
|
||||||
|
}
|
||||||
|
return converted.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (bridge is { HasExited: false })
|
||||||
|
{
|
||||||
|
bridge.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// the bridge stopped on its own, so there is nothing left to kill
|
||||||
|
}
|
||||||
|
bridge?.Dispose();
|
||||||
|
writing.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Process Start(string program, params string[] arguments)
|
||||||
|
{
|
||||||
|
ProcessStartInfo start = new()
|
||||||
|
{
|
||||||
|
FileName = wine,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
start.ArgumentList.Add(program);
|
||||||
|
foreach (string argument in arguments)
|
||||||
|
{
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
if (prefix is not null)
|
||||||
|
{
|
||||||
|
start.Environment["WINEPREFIX"] = prefix;
|
||||||
|
}
|
||||||
|
return Process.Start(start)
|
||||||
|
?? throw new InvalidOperationException($"{wine} {program} did not start");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadOutputAsync(Process running)
|
||||||
|
{
|
||||||
|
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||||
|
{
|
||||||
|
LineReceived?.Invoke(this, line);
|
||||||
|
}
|
||||||
|
await running.WaitForExitAsync().ConfigureAwait(false);
|
||||||
|
Failed?.Invoke(this, $"the bridge exited with code {running.ExitCode}: {ErrorTail()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadErrorsAsync(Process running)
|
||||||
|
{
|
||||||
|
while (await running.StandardError.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||||
|
{
|
||||||
|
lock (lastErrors)
|
||||||
|
{
|
||||||
|
lastErrors.Enqueue(line);
|
||||||
|
if (lastErrors.Count > KeptErrorLines)
|
||||||
|
{
|
||||||
|
lastErrors.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ErrorTail()
|
||||||
|
{
|
||||||
|
lock (lastErrors)
|
||||||
|
{
|
||||||
|
return string.Join(" / ", lastErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/Nonemm.Digital/WinePrefixSetup.cs
Normal file
106
src/Nonemm.Digital/WinePrefixSetup.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital;
|
||||||
|
|
||||||
|
/// Puts XMMT.ocx into a Wine prefix and registers it, which is what the bridge
|
||||||
|
/// needs before it can create the control. The steps are the ones in
|
||||||
|
/// docs/digital-bridge.md: make the prefix if it is not there, copy the control
|
||||||
|
/// into the Windows folder, run regsvr32.
|
||||||
|
public sealed class WinePrefixSetup
|
||||||
|
{
|
||||||
|
private readonly string? prefix;
|
||||||
|
private readonly string wine;
|
||||||
|
|
||||||
|
/// `prefix` is the WINEPREFIX to set up. Left null, Wine uses its default.
|
||||||
|
public WinePrefixSetup(string? prefix = null, string wine = "wine")
|
||||||
|
{
|
||||||
|
this.prefix = prefix;
|
||||||
|
this.wine = wine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a 32-bit control goes. A 64-bit prefix keeps 32-bit code in
|
||||||
|
/// syswow64 and has a second regsvr32 there to register it with; the
|
||||||
|
/// 64-bit one cannot load the control at all.
|
||||||
|
public static (string Folder, string Regsvr32) Places(bool isSixtyFourBit) =>
|
||||||
|
isSixtyFourBit
|
||||||
|
? (@"C:\windows\syswow64", @"C:\windows\syswow64\regsvr32.exe")
|
||||||
|
: (@"C:\windows\system32", @"C:\windows\system32\regsvr32.exe");
|
||||||
|
|
||||||
|
/// Copies the control in and registers it. It returns what to show the
|
||||||
|
/// operator, and throws when a step fails.
|
||||||
|
public async Task<string> RegisterAsync(
|
||||||
|
string controlPath,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(controlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"{controlPath} is not there", controlPath);
|
||||||
|
}
|
||||||
|
if (prefix is not null && !Directory.Exists(prefix))
|
||||||
|
{
|
||||||
|
await RunAsync("wineboot", ["-u"], cancellation, creating: true).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
string windows = await ToLinuxPathAsync(@"C:\windows", cancellation).ConfigureAwait(false);
|
||||||
|
(string folder, string regsvr32) = Places(Directory.Exists(Path.Combine(windows, "syswow64")));
|
||||||
|
string target = Path.Combine(
|
||||||
|
await ToLinuxPathAsync(folder, cancellation).ConfigureAwait(false),
|
||||||
|
"XMMT.ocx");
|
||||||
|
File.Copy(controlPath, target, overwrite: true);
|
||||||
|
await RunAsync(regsvr32, [$@"{folder}\XMMT.ocx"], cancellation).ConfigureAwait(false);
|
||||||
|
return $"XMMT.ocx registered in {folder}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ToLinuxPathAsync(string path, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
string converted = await RunAsync("winepath", ["-u", path], cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return converted.Trim().Length > 0
|
||||||
|
? converted.Trim()
|
||||||
|
: throw new InvalidOperationException($"winepath -u {path} said nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wine writes its own diagnostics to standard error and says nothing on
|
||||||
|
/// standard output unless the program does, so a failure is reported with
|
||||||
|
/// the error text.
|
||||||
|
private async Task<string> RunAsync(
|
||||||
|
string program,
|
||||||
|
string[] arguments,
|
||||||
|
CancellationToken cancellation,
|
||||||
|
bool creating = false)
|
||||||
|
{
|
||||||
|
ProcessStartInfo start = new()
|
||||||
|
{
|
||||||
|
FileName = wine,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
start.ArgumentList.Add(program);
|
||||||
|
foreach (string argument in arguments)
|
||||||
|
{
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
if (prefix is not null)
|
||||||
|
{
|
||||||
|
start.Environment["WINEPREFIX"] = prefix;
|
||||||
|
}
|
||||||
|
if (creating)
|
||||||
|
{
|
||||||
|
// only on a prefix that is being made: Wine stops if it is set on
|
||||||
|
// a 64-bit prefix that is already there
|
||||||
|
start.Environment["WINEARCH"] = "win32";
|
||||||
|
}
|
||||||
|
using Process running = Process.Start(start)
|
||||||
|
?? throw new InvalidOperationException($"{wine} {program} did not start");
|
||||||
|
Task<string> output = running.StandardOutput.ReadToEndAsync(cancellation);
|
||||||
|
Task<string> errors = running.StandardError.ReadToEndAsync(cancellation);
|
||||||
|
await running.WaitForExitAsync(cancellation).ConfigureAwait(false);
|
||||||
|
if (running.ExitCode != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{program} {string.Join(' ', arguments)} failed with exit code "
|
||||||
|
+ $"{running.ExitCode}: {(await errors.ConfigureAwait(false)).Trim()}");
|
||||||
|
}
|
||||||
|
return await output.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
namespace Nonemm.Session;
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// The order stacked calls come off, which is what N1MM's digital window calls
|
||||||
|
/// its call stacking modes.
|
||||||
|
public enum StackOrder
|
||||||
|
{
|
||||||
|
/// Multipliers first, then the order they arrived in.
|
||||||
|
MultipliersFirst,
|
||||||
|
|
||||||
|
/// The order they arrived in.
|
||||||
|
FirstIn,
|
||||||
|
|
||||||
|
/// Newest first.
|
||||||
|
LastIn,
|
||||||
|
|
||||||
|
/// Nothing is stacked.
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
/// The calls waiting to be worked while running. A station calling CQ hears
|
/// The calls waiting to be worked while running. A station calling CQ hears
|
||||||
/// more than one answer, and this holds the ones it has not got to yet, so the
|
/// more than one answer, and this holds the ones it has not got to yet, so the
|
||||||
/// operator takes them one at a time instead of asking each to call again.
|
/// operator takes them one at a time instead of asking each to call again.
|
||||||
/// N1MM's single-operator call stacking.
|
/// N1MM's single-operator call stacking.
|
||||||
///
|
///
|
||||||
/// A call that would bring a multiplier goes to the front and the rest to the
|
/// The order calls come off is `Order`: multipliers first is what N1MM uses on
|
||||||
/// back, which is the order N1MM uses on CW and phone. N1MM's digital window
|
/// CW and phone, and the digital window offers the other three.
|
||||||
/// offers plain first-in-first-out and last-in-first-out as well; there is no
|
|
||||||
/// digital window here, so every mode gets that one order.
|
|
||||||
public sealed class CallStack
|
public sealed class CallStack
|
||||||
{
|
{
|
||||||
/// N1MM stacks nothing shorter than this.
|
/// N1MM stacks nothing shorter than this.
|
||||||
@@ -16,6 +31,10 @@ public sealed class CallStack
|
|||||||
|
|
||||||
private readonly List<string> calls = [];
|
private readonly List<string> calls = [];
|
||||||
|
|
||||||
|
/// How calls come off. The digital window sets this from its call stacking
|
||||||
|
/// menu; everything else leaves it alone.
|
||||||
|
public StackOrder Order { get; set; } = StackOrder.MultipliersFirst;
|
||||||
|
|
||||||
/// The calls in the order they come off, so the front is next.
|
/// The calls in the order they come off, so the front is next.
|
||||||
public IReadOnlyList<string> Calls => calls;
|
public IReadOnlyList<string> Calls => calls;
|
||||||
|
|
||||||
@@ -27,16 +46,22 @@ public sealed class CallStack
|
|||||||
public string Top => calls.Count > 0 ? calls[0] : "";
|
public string Top => calls.Count > 0 ? calls[0] : "";
|
||||||
|
|
||||||
/// Puts a call on the stack. A call already there is moved rather than
|
/// Puts a call on the stack. A call already there is moved rather than
|
||||||
/// repeated, and a multiplier goes to the front.
|
/// repeated, and where it goes is up to `Order`.
|
||||||
public void Add(string call, bool isMultiplier)
|
public void Add(string call, bool isMultiplier)
|
||||||
{
|
{
|
||||||
string wanted = call.Trim().ToUpperInvariant();
|
string wanted = call.Trim().ToUpperInvariant();
|
||||||
if (wanted.Length < ShortestCall)
|
if (wanted.Length < ShortestCall || Order == StackOrder.Disabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Remove(wanted);
|
Remove(wanted);
|
||||||
if (isMultiplier)
|
bool toFront = Order switch
|
||||||
|
{
|
||||||
|
StackOrder.LastIn => true,
|
||||||
|
StackOrder.MultipliersFirst => isMultiplier,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if (toFront)
|
||||||
{
|
{
|
||||||
calls.Insert(0, wanted);
|
calls.Insert(0, wanted);
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/Nonemm.Session/DigitalElement.cs
Normal file
115
src/Nonemm.Session/DigitalElement.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using Nonemm.Contests;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// What a word clicked in the receive pane is worth. N1MM drops a clicked word
|
||||||
|
/// into whichever exchange box has the cursor. That box still comes first here
|
||||||
|
/// when it takes the word, but a word the cursor's box does not take goes to
|
||||||
|
/// the box of the contest's exchange that holds that kind of value, so a serial
|
||||||
|
/// number and a state land where they belong.
|
||||||
|
public static class DigitalElement
|
||||||
|
{
|
||||||
|
private const string Precedences = "QABUMS";
|
||||||
|
|
||||||
|
/// Strips what RTTY carries around a value: the report, a colon, and the Z
|
||||||
|
/// on a four-figure time. N1MM cuts the same three before it takes an
|
||||||
|
/// element from a click.
|
||||||
|
public static string Trim(string word)
|
||||||
|
{
|
||||||
|
string text = DigitalReceiver.Scrub(word).Replace(":", "");
|
||||||
|
if (text.Length > 3 && text.Contains("599"))
|
||||||
|
{
|
||||||
|
text = text.Replace("599", "");
|
||||||
|
}
|
||||||
|
if (text.Length == 5 && text[^1] == 'Z' && text[..4].All(char.IsAsciiDigit))
|
||||||
|
{
|
||||||
|
text = text[..4];
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the box holds this kind of value. A report box is never a
|
||||||
|
/// target: nobody clicks 599 in, and the program fills it in itself.
|
||||||
|
public static bool Fits(ExchangeField field, string word) => field.Kind switch
|
||||||
|
{
|
||||||
|
ExchangeFieldKind.Report => false,
|
||||||
|
ExchangeFieldKind.Number => IsNumber(word, 1, 6),
|
||||||
|
ExchangeFieldKind.CqZone => IsNumber(word, 1, 2) && InRange(word, 1, 40),
|
||||||
|
ExchangeFieldKind.ItuZone => IsNumber(word, 1, 2) && InRange(word, 1, 90),
|
||||||
|
ExchangeFieldKind.Check => IsNumber(word, 2, 2),
|
||||||
|
ExchangeFieldKind.Power => IsPower(word),
|
||||||
|
ExchangeFieldKind.Precedence => word.Length == 1 && Precedences.Contains(word[0]),
|
||||||
|
ExchangeFieldKind.Grid => IsGrid(word),
|
||||||
|
ExchangeFieldKind.Text => word.Length > 0,
|
||||||
|
_ => ExchangeValues.For(field.Kind).Contains(word, StringComparer.OrdinalIgnoreCase),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The exchange box the word belongs in, counted the way `EntryFields`
|
||||||
|
/// counts: 0 is the callsign box, 1 and up are the exchange boxes. Null
|
||||||
|
/// when the contest has no box that takes it.
|
||||||
|
public static int? FieldFor(EntryFields fields, string word)
|
||||||
|
{
|
||||||
|
if (word.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// the box the cursor is in wins when it takes the word: that is where
|
||||||
|
// N1MM puts it, and the operator has already said which box it is
|
||||||
|
if (fields.Focus > 0 && Fits(fields.Exchange[fields.Focus - 1], word))
|
||||||
|
{
|
||||||
|
return fields.Focus;
|
||||||
|
}
|
||||||
|
// then the box that only holds this kind of value, then a number box,
|
||||||
|
// then plain text, so a check does not land in the serial number box
|
||||||
|
// and a section does not land in the name box
|
||||||
|
return Pick(fields, word, ListedOrShaped)
|
||||||
|
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Number)
|
||||||
|
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ListedOrShaped(ExchangeFieldKind kind) =>
|
||||||
|
kind is not (ExchangeFieldKind.Number or ExchangeFieldKind.Text or ExchangeFieldKind.Report);
|
||||||
|
|
||||||
|
private static int? Pick(EntryFields fields, string word, Func<ExchangeFieldKind, bool> wanted)
|
||||||
|
{
|
||||||
|
int? filled = null;
|
||||||
|
for (int at = 0; at < fields.Exchange.Count; at++)
|
||||||
|
{
|
||||||
|
ExchangeField field = fields.Exchange[at];
|
||||||
|
if (!wanted(field.Kind) || !Fits(field, word))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fields[at + 1].Trim().Length == 0)
|
||||||
|
{
|
||||||
|
return at + 1;
|
||||||
|
}
|
||||||
|
// every box that takes it is full, so the first one is written over
|
||||||
|
filled ??= at + 1;
|
||||||
|
}
|
||||||
|
return filled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNumber(string word, int shortest, int longest) =>
|
||||||
|
word.Length >= shortest && word.Length <= longest && word.All(char.IsAsciiDigit);
|
||||||
|
|
||||||
|
private static bool InRange(string word, int lowest, int highest) =>
|
||||||
|
int.TryParse(word, out int value) && value >= lowest && value <= highest;
|
||||||
|
|
||||||
|
/// A power is the figures, with or without the W or KW after them.
|
||||||
|
private static bool IsPower(string word)
|
||||||
|
{
|
||||||
|
string figures = word.EndsWith("KW", StringComparison.Ordinal)
|
||||||
|
? word[..^2]
|
||||||
|
: word.EndsWith('W') ? word[..^1] : word;
|
||||||
|
return IsNumber(figures, 1, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Four or six characters: two letters, two figures, and a letter pair on a
|
||||||
|
/// six-character locator.
|
||||||
|
private static bool IsGrid(string word) =>
|
||||||
|
word.Length is 4 or 6
|
||||||
|
&& char.IsAsciiLetter(word[0]) && char.IsAsciiLetter(word[1])
|
||||||
|
&& char.IsAsciiDigit(word[2]) && char.IsAsciiDigit(word[3])
|
||||||
|
&& (word.Length == 4 || (char.IsAsciiLetter(word[4]) && char.IsAsciiLetter(word[5])));
|
||||||
|
}
|
||||||
44
src/Nonemm.Session/DigitalMacros.cs
Normal file
44
src/Nonemm.Session/DigitalMacros.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// The digital window's macro buttons: three rows of eight, each a label and a
|
||||||
|
/// message, held as the same `label,message` lines as an N1MM function key
|
||||||
|
/// file. N1MM keeps its digital macros in its admin database rather than in a
|
||||||
|
/// file; the lines here say the same thing in the form the rest of this program
|
||||||
|
/// already reads.
|
||||||
|
public sealed class DigitalMacros
|
||||||
|
{
|
||||||
|
public const int ButtonCount = 24;
|
||||||
|
|
||||||
|
private readonly IReadOnlyList<FunctionKey> buttons;
|
||||||
|
|
||||||
|
private DigitalMacros(IReadOnlyList<FunctionKey> buttons)
|
||||||
|
{
|
||||||
|
this.buttons = buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<FunctionKey> Buttons => buttons;
|
||||||
|
|
||||||
|
public FunctionKey this[int index] =>
|
||||||
|
index >= 0 && index < buttons.Count ? buttons[index] : FunctionKey.Empty;
|
||||||
|
|
||||||
|
public static DigitalMacros Parse(string text)
|
||||||
|
{
|
||||||
|
List<FunctionKey> found = [];
|
||||||
|
foreach (string line in text.Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
if (line.StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int comma = line.IndexOf(',');
|
||||||
|
found.Add(comma < 0
|
||||||
|
? new FunctionKey("", line.Trim())
|
||||||
|
: new FunctionKey(line[..comma].Replace("&&", "&").Trim(), line[(comma + 1)..].Trim()));
|
||||||
|
}
|
||||||
|
while (found.Count < ButtonCount)
|
||||||
|
{
|
||||||
|
found.Add(FunctionKey.Empty);
|
||||||
|
}
|
||||||
|
return new DigitalMacros(found.Take(ButtonCount).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/Nonemm.Session/DigitalReceiver.cs
Normal file
125
src/Nonemm.Session/DigitalReceiver.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// The decoded text as it arrives, and the callsigns in it. Characters come
|
||||||
|
/// from the modem one at a time; a word ends at a space or a line break, and a
|
||||||
|
/// word shaped like a callsign is reported so the window can colour it and put
|
||||||
|
/// it in the grab list. N1MM's `DigitalProcessing.RxComData` does the same.
|
||||||
|
public sealed class DigitalReceiver
|
||||||
|
{
|
||||||
|
/// How much decoded text is kept. N1MM keeps a scrollback of its own; this
|
||||||
|
/// is enough for a long run and keeps the pane from growing without end.
|
||||||
|
public const int Scrollback = 20_000;
|
||||||
|
|
||||||
|
private readonly StringBuilder text = new();
|
||||||
|
private readonly StringBuilder word = new();
|
||||||
|
|
||||||
|
public string Text => text.ToString();
|
||||||
|
|
||||||
|
/// Text that has just arrived, for a window that appends rather than
|
||||||
|
/// redraws.
|
||||||
|
public event EventHandler<string>? Added;
|
||||||
|
|
||||||
|
/// A word shaped like a callsign, uppercased and cut back to the call.
|
||||||
|
public event EventHandler<string>? CallHeard;
|
||||||
|
|
||||||
|
public void Receive(string arrived)
|
||||||
|
{
|
||||||
|
if (arrived.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
text.Append(arrived);
|
||||||
|
if (text.Length > Scrollback)
|
||||||
|
{
|
||||||
|
text.Remove(0, text.Length - Scrollback);
|
||||||
|
}
|
||||||
|
Added?.Invoke(this, arrived);
|
||||||
|
foreach (char c in arrived)
|
||||||
|
{
|
||||||
|
if (c is ' ' or '\r' or '\n')
|
||||||
|
{
|
||||||
|
Finish();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
word.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
text.Clear();
|
||||||
|
word.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The word around a position in the text, which is what the mouse points
|
||||||
|
/// at. Empty when the position is on a space.
|
||||||
|
public static string WordAt(string text, int position)
|
||||||
|
{
|
||||||
|
if (position < 0 || position >= text.Length || IsBreak(text[position]))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int start = position;
|
||||||
|
while (start > 0 && !IsBreak(text[start - 1]))
|
||||||
|
{
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
int end = position;
|
||||||
|
while (end + 1 < text.Length && !IsBreak(text[end + 1]))
|
||||||
|
{
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return Scrub(text[start..(end + 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM's `ScrubSelectedData`: what the operator clicked, without the line
|
||||||
|
/// breaks and in upper case.
|
||||||
|
public static string Scrub(string word) =>
|
||||||
|
word.Replace("\r", "").Replace("\n", "").Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
/// True when the word could be a callsign. RTTY prints noise, so a word is
|
||||||
|
/// only offered as a call when it is shaped like one.
|
||||||
|
public static bool IsCallsign(string word) =>
|
||||||
|
word.Length > 0 && Callsign.Parse(word).IsPlausible;
|
||||||
|
|
||||||
|
/// N1MM's `Trimcall`. RTTY runs words together, so a long word with a digit
|
||||||
|
/// in it is cut to three characters past the last digit, which is as long
|
||||||
|
/// as a callsign gets after its number. A word with a `/` is left alone: a
|
||||||
|
/// portable call is long for a reason.
|
||||||
|
public static string TrimToCall(string word)
|
||||||
|
{
|
||||||
|
if (word.Contains('/') || word.Length < 7)
|
||||||
|
{
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
int lastDigit = -1;
|
||||||
|
for (int at = 0; at < word.Length; at++)
|
||||||
|
{
|
||||||
|
if (char.IsAsciiDigit(word[at]))
|
||||||
|
{
|
||||||
|
lastDigit = at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastDigit < 0 ? word : word[..Math.Min(word.Length, lastDigit + 4)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBreak(char c) => c is ' ' or '\r' or '\n' or '\t';
|
||||||
|
|
||||||
|
private void Finish()
|
||||||
|
{
|
||||||
|
string found = Scrub(word.ToString());
|
||||||
|
word.Clear();
|
||||||
|
if (found.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string call = TrimToCall(found);
|
||||||
|
if (IsCallsign(call))
|
||||||
|
{
|
||||||
|
CallHeard?.Invoke(this, call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Nonemm.Session/GrabList.cs
Normal file
72
src/Nonemm.Session/GrabList.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// Which calls the grab list keeps.
|
||||||
|
public enum GrabFilter
|
||||||
|
{
|
||||||
|
/// Every call heard.
|
||||||
|
Everything,
|
||||||
|
|
||||||
|
/// Nothing already in the log on this band and mode. N1MM's "no dupes".
|
||||||
|
NotDupes,
|
||||||
|
|
||||||
|
/// Only calls the callsign database knows or the log has seen before,
|
||||||
|
/// which throws away most of what noise decodes as. N1MM's "master and
|
||||||
|
/// previous calls".
|
||||||
|
KnownCalls,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The callsigns heard on the air, newest first, waiting to be grabbed into
|
||||||
|
/// the entry window. N1MM's `Calltext` list beside the digital window's macro
|
||||||
|
/// buttons.
|
||||||
|
///
|
||||||
|
/// What is left out is N1MM's: our own call, the call already being typed, a
|
||||||
|
/// call that is only part of our own, and — depending on the filter — dupes and
|
||||||
|
/// calls nothing has ever heard of.
|
||||||
|
public sealed class GrabList
|
||||||
|
{
|
||||||
|
private readonly List<string> calls = [];
|
||||||
|
|
||||||
|
public GrabList(int capacity = 30)
|
||||||
|
{
|
||||||
|
Capacity = capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many calls are kept. The oldest falls off the end.
|
||||||
|
public int Capacity { get; set; }
|
||||||
|
|
||||||
|
public bool NewestFirst { get; set; } = true;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Calls => calls;
|
||||||
|
|
||||||
|
public int Count => calls.Count;
|
||||||
|
|
||||||
|
/// Puts a call in the list, or moves it if it is already there. False when
|
||||||
|
/// the call was not wanted.
|
||||||
|
public bool Add(string call)
|
||||||
|
{
|
||||||
|
string wanted = call.Trim().ToUpperInvariant();
|
||||||
|
if (wanted.Length < 3)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
calls.Remove(wanted);
|
||||||
|
calls.Insert(NewestFirst ? 0 : calls.Count, wanted);
|
||||||
|
while (calls.Count > Capacity)
|
||||||
|
{
|
||||||
|
calls.RemoveAt(NewestFirst ? calls.Count - 1 : 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Remove(string call) =>
|
||||||
|
calls.RemoveAll(c => string.Equals(c, call.Trim(), StringComparison.OrdinalIgnoreCase)) > 0;
|
||||||
|
|
||||||
|
public void Clear() => calls.Clear();
|
||||||
|
|
||||||
|
/// Whether a heard call belongs in the list at all.
|
||||||
|
public static bool Wanted(string call, string myCall, string beingTyped) =>
|
||||||
|
call.Length >= 3
|
||||||
|
&& !string.Equals(call, myCall, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(call, beingTyped.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !(myCall.Length > 0 && myCall.Contains(call, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
@@ -66,4 +66,11 @@ public enum MessageCommand
|
|||||||
|
|
||||||
/// Empties the call stack.
|
/// Empties the call stack.
|
||||||
ClearStack,
|
ClearStack,
|
||||||
|
|
||||||
|
/// N1MM's `{TX}`: the digital engine starts transmitting.
|
||||||
|
StartTransmit,
|
||||||
|
|
||||||
|
/// N1MM's `{RX}`: the engine goes back to receive once the buffer is
|
||||||
|
/// empty.
|
||||||
|
ReturnToReceive,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ public static class MessageExpander
|
|||||||
"RRMHZ" => Megahertz(session, Radio(session, other, 2)),
|
"RRMHZ" => Megahertz(session, Radio(session, other, 2)),
|
||||||
"TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
"TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||||
"TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture),
|
"TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture),
|
||||||
|
// the digital window's carriage return, which starts a new line on
|
||||||
|
// the other station's screen
|
||||||
|
"ENTER" => "\r",
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ public sealed record MessagePlan(
|
|||||||
// N1MM runs the same code for both
|
// N1MM runs the same code for both
|
||||||
"LOGTHENPOP" or "LOGTHENNEXT" => new MessageAction(MessageCommand.LogThenPop),
|
"LOGTHENPOP" or "LOGTHENNEXT" => new MessageAction(MessageCommand.LogThenPop),
|
||||||
"CLRSTACK" => new MessageAction(MessageCommand.ClearStack),
|
"CLRSTACK" => new MessageAction(MessageCommand.ClearStack),
|
||||||
|
"TX" => new MessageAction(MessageCommand.StartTransmit),
|
||||||
|
"RX" => new MessageAction(MessageCommand.ReturnToReceive),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/Nonemm.Session/ModeEntry.cs
Normal file
22
src/Nonemm.Session/ModeEntry.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// Reads a mode typed into the callsign box, which is how an operator changes
|
||||||
|
/// mode without touching a radio. N1MM takes a mode there the same way it takes
|
||||||
|
/// a frequency, and it is the only way to reach a mode with no radio connected.
|
||||||
|
public static class ModeEntry
|
||||||
|
{
|
||||||
|
/// Null when the text names no mode. `SSB`, `PH` and `PHONE` take the
|
||||||
|
/// sideband the frequency calls for, which is what the band panel does.
|
||||||
|
public static Mode? Parse(string text, Frequency where)
|
||||||
|
{
|
||||||
|
string wanted = text.Trim().ToUpperInvariant();
|
||||||
|
return wanted switch
|
||||||
|
{
|
||||||
|
"" => null,
|
||||||
|
"SSB" or "PH" or "PHONE" => Modes.ForSideband(where),
|
||||||
|
_ => Modes.Parse(wanted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,9 @@ public sealed class OperatingPosition
|
|||||||
/// The frequency typed into the callsign box, if that is what it holds.
|
/// The frequency typed into the callsign box, if that is what it holds.
|
||||||
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
|
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
|
||||||
|
|
||||||
|
/// The mode typed into the callsign box, if that is what it holds.
|
||||||
|
public Mode? PendingMode() => ModeEntry.Parse(Entry.Call, Frequency);
|
||||||
|
|
||||||
/// What working this call on the band and mode we are on would bring,
|
/// What working this call on the band and mode we are on would bring,
|
||||||
/// judged from the call alone. A call that has not been worked has sent no
|
/// judged from the call alone. A call that has not been worked has sent no
|
||||||
/// exchange, so a contest scored on what the other station sends can be
|
/// exchange, so a contest scored on what the other station sends can be
|
||||||
|
|||||||
30
tests/Nonemm.Core.Tests/ModesTests.cs
Normal file
30
tests/Nonemm.Core.Tests/ModesTests.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Core.Tests;
|
||||||
|
|
||||||
|
/// The mode names that come back from a radio.
|
||||||
|
public class ModesTests
|
||||||
|
{
|
||||||
|
/// hamlib answers `m` with its own names. A radio in a data mode is on a
|
||||||
|
/// digital mode, which is how AFSK RTTY is run, and the program sends
|
||||||
|
/// `PKTUSB` itself when it puts a radio on one.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("PKTUSB")]
|
||||||
|
[InlineData("PKTLSB")]
|
||||||
|
[InlineData("PKTFM")]
|
||||||
|
[InlineData("RTTY")]
|
||||||
|
[InlineData("RTTYR")]
|
||||||
|
public void AHamlibDataModeIsADigitalMode(string reported) =>
|
||||||
|
Assert.Equal(ModeCategory.Digital, Modes.Parse(reported)?.Category);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("CW", "CW")]
|
||||||
|
[InlineData("CWR", "CW")]
|
||||||
|
[InlineData("USB", "USB")]
|
||||||
|
[InlineData("SSB", "USB")]
|
||||||
|
public void TheOtherNamesStillReadTheWayTheyDid(string reported, string expected) =>
|
||||||
|
Assert.Equal(expected, Modes.Parse(reported)?.Name);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AModeNobodyKnowsIsNull() => Assert.Null(Modes.Parse("SPARK"));
|
||||||
|
}
|
||||||
36
tests/Nonemm.Digital.Tests/BridgeLineTests.cs
Normal file
36
tests/Nonemm.Digital.Tests/BridgeLineTests.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
public class BridgeLineTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AFieldWithATabInItStaysOneField()
|
||||||
|
{
|
||||||
|
string line = BridgeLine.Write("send", "CQ\tTEST");
|
||||||
|
|
||||||
|
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||||
|
|
||||||
|
Assert.Equal("send", verb);
|
||||||
|
Assert.Equal(["CQ\tTEST"], fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABackslashSurvivesTheTrip()
|
||||||
|
{
|
||||||
|
string line = BridgeLine.Write("open", "RTTY Engine 1", "", @"""C:\mmtty\mmtty.exe"" -r -Z");
|
||||||
|
|
||||||
|
(_, string[] fields) = BridgeLine.Read(line);
|
||||||
|
|
||||||
|
Assert.Equal(@"""C:\mmtty\mmtty.exe"" -r -Z", fields[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AShortLineIsNotAnError()
|
||||||
|
{
|
||||||
|
(string verb, string[] fields) = BridgeLine.Read("ready");
|
||||||
|
|
||||||
|
Assert.Equal("ready", verb);
|
||||||
|
Assert.Empty(fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/Nonemm.Digital.Tests/FakeBridge.cs
Normal file
54
tests/Nonemm.Digital.Tests/FakeBridge.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
/// A bridge that goes nowhere: the test reads what was sent to it and says what
|
||||||
|
/// comes back.
|
||||||
|
public sealed class FakeBridge : BridgeChannel
|
||||||
|
{
|
||||||
|
private readonly List<string> sent = [];
|
||||||
|
|
||||||
|
public bool IsStarted { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Sent
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (sent)
|
||||||
|
{
|
||||||
|
return sent.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<string>? LineReceived;
|
||||||
|
|
||||||
|
public event EventHandler<string>? Failed;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
IsStarted = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendAsync(string line, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
lock (sent)
|
||||||
|
{
|
||||||
|
sent.Add(line);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string> ToWindowsPathAsync(string path, CancellationToken cancellation = default) =>
|
||||||
|
Task.FromResult("Z:" + path.Replace('/', '\\'));
|
||||||
|
|
||||||
|
public void Say(string verb, params string[] fields) =>
|
||||||
|
LineReceived?.Invoke(this, BridgeLine.Write(verb, fields));
|
||||||
|
|
||||||
|
public void Stop(string why) => Failed?.Invoke(this, why);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
153
tests/Nonemm.Digital.Tests/MmttyEngineTests.cs
Normal file
153
tests/Nonemm.Digital.Tests/MmttyEngineTests.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
/// The engine driven through a bridge that goes nowhere.
|
||||||
|
public class MmttyEngineTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private static MmttyEngine Engine(FakeBridge bridge) =>
|
||||||
|
new(bridge, new MmttyOptions { EnginePath = "/opt/mmtty/mmtty.exe" }, Patience);
|
||||||
|
|
||||||
|
private static async Task StartAsync(MmttyEngine engine, FakeBridge bridge)
|
||||||
|
{
|
||||||
|
Task starting = engine.StartAsync();
|
||||||
|
await WaitForAsync(() => bridge.Sent.Count > 0);
|
||||||
|
bridge.Say("connected", "Ver1.70");
|
||||||
|
await starting.WaitAsync(Patience);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> ready)
|
||||||
|
{
|
||||||
|
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||||
|
while (!ready() && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEngineIsOpenedWithTitlePortAndCommandLine()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
|
||||||
|
await StartAsync(engine, bridge);
|
||||||
|
|
||||||
|
(string verb, string[] fields) = BridgeLine.Read(bridge.Sent[0]);
|
||||||
|
Assert.Equal("open", verb);
|
||||||
|
Assert.Equal("RTTY Engine 1", fields[0]);
|
||||||
|
Assert.Equal(@"""Z:\opt\mmtty\mmtty.exe"" -r -Z", fields[2]);
|
||||||
|
Assert.True(engine.IsConnected);
|
||||||
|
Assert.Equal("Ver1.70", engine.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEngineThatFailsToStartIsStartedAgain()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
Task starting = engine.StartAsync();
|
||||||
|
await WaitForAsync(() => bridge.Sent.Count > 0);
|
||||||
|
|
||||||
|
bridge.Say("disconnected", "2");
|
||||||
|
await WaitForAsync(() => bridge.Sent.Count > 1);
|
||||||
|
bridge.Say("connected", "Ver1.70");
|
||||||
|
await starting.WaitAsync(Patience);
|
||||||
|
|
||||||
|
Assert.Equal(2, bridge.Sent.Count);
|
||||||
|
Assert.All(bridge.Sent, line => Assert.StartsWith("open\t", line));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEngineThatNeverStartsGivesUpRatherThanHanging()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
Task starting = engine.StartAsync();
|
||||||
|
|
||||||
|
for (int tries = 0; tries < 12; tries++)
|
||||||
|
{
|
||||||
|
await WaitForAsync(() => bridge.Sent.Count > tries);
|
||||||
|
bridge.Say("disconnected", "2");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => starting.WaitAsync(Patience));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DecodedCharactersArriveAsText()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
List<string> heard = [];
|
||||||
|
engine.Received += (_, text) => heard.Add(text);
|
||||||
|
await StartAsync(engine, bridge);
|
||||||
|
|
||||||
|
bridge.Say("rx", "79");
|
||||||
|
bridge.Say("rx", "77");
|
||||||
|
await WaitForAsync(() => heard.Count == 2);
|
||||||
|
|
||||||
|
Assert.Equal("OM", string.Concat(heard));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransmitIsReportedBothWays()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
List<bool> changes = [];
|
||||||
|
engine.TransmitChanged += (_, sending) => changes.Add(sending);
|
||||||
|
await StartAsync(engine, bridge);
|
||||||
|
|
||||||
|
bridge.Say("tx", "1");
|
||||||
|
await WaitForAsync(() => changes.Count == 1);
|
||||||
|
Assert.True(engine.IsTransmitting);
|
||||||
|
|
||||||
|
bridge.Say("tx", "0");
|
||||||
|
await WaitForAsync(() => changes.Count == 2);
|
||||||
|
Assert.False(engine.IsTransmitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TuningKeepsTheShiftTheEngineReported()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
await StartAsync(engine, bridge);
|
||||||
|
bridge.Say("mark", "2125");
|
||||||
|
bridge.Say("space", "2295");
|
||||||
|
await WaitForAsync(() => engine.SpaceHertz == 2295);
|
||||||
|
|
||||||
|
await engine.TuneAsync(1500);
|
||||||
|
|
||||||
|
Assert.Equal("post\t9\t1500", bridge.Sent[^2]);
|
||||||
|
Assert.Equal("post\t10\t1670", bridge.Sent[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortDropsPtt()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
await StartAsync(engine, bridge);
|
||||||
|
|
||||||
|
await engine.AbortAsync();
|
||||||
|
|
||||||
|
Assert.Equal("ptt\t0", bridge.Sent[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ABridgeThatDiesWhileStartingFailsTheStart()
|
||||||
|
{
|
||||||
|
using FakeBridge bridge = new();
|
||||||
|
using MmttyEngine engine = Engine(bridge);
|
||||||
|
Task starting = engine.StartAsync();
|
||||||
|
await WaitForAsync(() => bridge.Sent.Count > 0);
|
||||||
|
|
||||||
|
bridge.Stop("the bridge exited with code 1");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => starting.WaitAsync(Patience));
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/Nonemm.Digital.Tests/MmttyOptionsTests.cs
Normal file
40
tests/Nonemm.Digital.Tests/MmttyOptionsTests.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
/// The command line N1MM builds, checked switch by switch.
|
||||||
|
public class MmttyOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MmttyIsStartedTheWayN1mmStartsIt()
|
||||||
|
{
|
||||||
|
MmttyOptions options = new() { EnginePath = "/opt/mmtty/mmtty.exe" };
|
||||||
|
|
||||||
|
Assert.Equal(@"""C:\mmtty\mmtty.exe"" -r -Z", options.CommandLine(@"C:\mmtty\mmtty.exe"));
|
||||||
|
Assert.Equal("RTTY Engine 1", options.Title);
|
||||||
|
Assert.False(options.IsTwoTone);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AWindowThatIsNotOnTopAsksForIt()
|
||||||
|
{
|
||||||
|
MmttyOptions options = new()
|
||||||
|
{
|
||||||
|
EnginePath = "/opt/mmtty/mmtty.exe",
|
||||||
|
Window = EngineWindow.Small,
|
||||||
|
OnTop = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(@"""C:\m\mmtty.exe"" -t -a -Z", options.CommandLine(@"C:\m\mmtty.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoToneTakesNoneOfMmttysSwitchesAndNamesItsOwnWindow()
|
||||||
|
{
|
||||||
|
MmttyOptions options = new() { EnginePath = "/opt/2tone/2Tone.exe", Number = 2 };
|
||||||
|
|
||||||
|
Assert.True(options.IsTwoTone);
|
||||||
|
Assert.Equal(@"""C:\2Tone.exe"" -r", options.CommandLine(@"C:\2Tone.exe"));
|
||||||
|
Assert.Equal("DI2 G3YYD 2Tone", options.WindowTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/Nonemm.Digital.Tests/MmttySettingsTests.cs
Normal file
30
tests/Nonemm.Digital.Tests/MmttySettingsTests.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
public class MmttySettingsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TheSwitchWordIsBuiltFromWhatMmttyWroteDown()
|
||||||
|
{
|
||||||
|
string path = Path.GetTempFileName();
|
||||||
|
File.WriteAllText(path, "[Sound]\nRev=1\n[Define]\nPTT=COM3\nAFC=1\nTxNet=0\nRev=1\n");
|
||||||
|
|
||||||
|
MmttySettings settings = MmttySettings.Read(path);
|
||||||
|
File.Delete(path);
|
||||||
|
|
||||||
|
Assert.Equal("COM3", settings.PttPort);
|
||||||
|
Assert.True(settings.IsAfcOn);
|
||||||
|
Assert.False(settings.IsNetOn);
|
||||||
|
Assert.Equal(MmttySettings.AfcBit | MmttySettings.ReverseBit, settings.Switches);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnInstallationThatHasNotRunYetHasNoFile()
|
||||||
|
{
|
||||||
|
MmttySettings settings = MmttySettings.Read("/nowhere/Mmtty.INI");
|
||||||
|
|
||||||
|
Assert.Equal("", settings.PttPort);
|
||||||
|
Assert.Equal(0, settings.Switches);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/Nonemm.Digital.Tests/Nonemm.Digital.Tests.csproj
Normal file
25
tests/Nonemm.Digital.Tests/Nonemm.Digital.Tests.csproj
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Nonemm.Digital\Nonemm.Digital.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
36
tests/Nonemm.Digital.Tests/WinePrefixSetupTests.cs
Normal file
36
tests/Nonemm.Digital.Tests/WinePrefixSetupTests.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
/// Where the control is put in each kind of prefix.
|
||||||
|
public class WinePrefixSetupTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AThirtyTwoBitPrefixTakesTheControlInSystem32()
|
||||||
|
{
|
||||||
|
(string folder, string regsvr32) = WinePrefixSetup.Places(isSixtyFourBit: false);
|
||||||
|
|
||||||
|
Assert.Equal(@"C:\windows\system32", folder);
|
||||||
|
Assert.Equal(@"C:\windows\system32\regsvr32.exe", regsvr32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 64-bit regsvr32 cannot load a 32-bit control, so a 64-bit prefix is
|
||||||
|
/// registered with the one in syswow64.
|
||||||
|
[Fact]
|
||||||
|
public void ASixtyFourBitPrefixTakesItInSyswow64()
|
||||||
|
{
|
||||||
|
(string folder, string regsvr32) = WinePrefixSetup.Places(isSixtyFourBit: true);
|
||||||
|
|
||||||
|
Assert.Equal(@"C:\windows\syswow64", folder);
|
||||||
|
Assert.Equal(@"C:\windows\syswow64\regsvr32.exe", regsvr32);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AControlThatIsNotThereIsSaidSo()
|
||||||
|
{
|
||||||
|
WinePrefixSetup setup = new("/nowhere");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||||
|
() => setup.RegisterAsync("/nowhere/XMMT.ocx"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,4 +81,37 @@ public class CallStackTests
|
|||||||
|
|
||||||
Assert.Equal(["DL1ABC"], stack.Calls);
|
Assert.Equal(["DL1ABC"], stack.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The digital window's call stacking modes.
|
||||||
|
[Fact]
|
||||||
|
public void FirstInFirstOutKeepsTheOrderTheyArrivedIn()
|
||||||
|
{
|
||||||
|
CallStack stack = new() { Order = StackOrder.FirstIn };
|
||||||
|
|
||||||
|
stack.Add("OM5M", isMultiplier: false);
|
||||||
|
stack.Add("S53M", isMultiplier: true);
|
||||||
|
|
||||||
|
Assert.Equal(["OM5M", "S53M"], stack.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LastInFirstOutTakesTheNewestFirst()
|
||||||
|
{
|
||||||
|
CallStack stack = new() { Order = StackOrder.LastIn };
|
||||||
|
|
||||||
|
stack.Add("OM5M", isMultiplier: false);
|
||||||
|
stack.Add("S53M", isMultiplier: false);
|
||||||
|
|
||||||
|
Assert.Equal(["S53M", "OM5M"], stack.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NothingIsStackedWhenStackingIsOff()
|
||||||
|
{
|
||||||
|
CallStack stack = new() { Order = StackOrder.Disabled };
|
||||||
|
|
||||||
|
stack.Add("OM5M", isMultiplier: true);
|
||||||
|
|
||||||
|
Assert.True(stack.IsEmpty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
124
tests/Nonemm.Session.Tests/DigitalElementTests.cs
Normal file
124
tests/Nonemm.Session.Tests/DigitalElementTests.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using Nonemm.Contests;
|
||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
/// Where a word clicked in the receive pane goes.
|
||||||
|
public class DigitalElementTests
|
||||||
|
{
|
||||||
|
private static readonly ExchangeField[] SweepstakesLike =
|
||||||
|
[
|
||||||
|
new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
|
||||||
|
new ExchangeField("Prec", ExchangeSlot.Precedence, ExchangeFieldKind.Precedence),
|
||||||
|
new ExchangeField("Ck", ExchangeSlot.Check, ExchangeFieldKind.Check),
|
||||||
|
new ExchangeField("Sect", ExchangeSlot.Section, ExchangeFieldKind.ArrlSection),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly ExchangeField[] NameAndState =
|
||||||
|
[
|
||||||
|
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||||
|
new ExchangeField("Name", ExchangeSlot.Name, ExchangeFieldKind.Text),
|
||||||
|
new ExchangeField("S/P", ExchangeSlot.Exchange1, ExchangeFieldKind.UsStateOrCanadianProvince),
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EachKindOfValueFindsItsOwnBox()
|
||||||
|
{
|
||||||
|
EntryFields fields = new(SweepstakesLike);
|
||||||
|
|
||||||
|
Assert.Equal(1, DigitalElement.FieldFor(fields, "123"));
|
||||||
|
Assert.Equal(2, DigitalElement.FieldFor(fields, "A"));
|
||||||
|
Assert.Equal(3, DigitalElement.FieldFor(fields, "72"));
|
||||||
|
Assert.Equal(4, DigitalElement.FieldFor(fields, "STX"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A state is not put in the name box just because that box comes first.
|
||||||
|
[Fact]
|
||||||
|
public void ABoxWithAListBehindItComesBeforeAPlainTextBox()
|
||||||
|
{
|
||||||
|
EntryFields fields = new(NameAndState);
|
||||||
|
|
||||||
|
Assert.Equal(3, DigitalElement.FieldFor(fields, "OH"));
|
||||||
|
Assert.Equal(2, DigitalElement.FieldFor(fields, "PETER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheReportBoxIsNeverClickedInto()
|
||||||
|
{
|
||||||
|
EntryFields fields = new(NameAndState);
|
||||||
|
|
||||||
|
Assert.False(DigitalElement.Fits(NameAndState[0], "599"));
|
||||||
|
Assert.NotEqual(1, DigitalElement.FieldFor(fields, "599"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnEmptyBoxIsFilledBeforeOneThatAlreadyHasAValue()
|
||||||
|
{
|
||||||
|
EntryFields fields = new(NameAndState);
|
||||||
|
fields[2] = "JOE";
|
||||||
|
|
||||||
|
Assert.Equal(2, DigitalElement.FieldFor(fields, "MIKE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every box that takes the word is full, so the first one is written over
|
||||||
|
/// rather than the click doing nothing.
|
||||||
|
[Fact]
|
||||||
|
public void AFullBoxIsWrittenOverWhenNoOtherBoxTakesIt()
|
||||||
|
{
|
||||||
|
EntryFields fields = new(SweepstakesLike);
|
||||||
|
fields[4] = "EMA";
|
||||||
|
|
||||||
|
Assert.Equal(4, DigitalElement.FieldFor(fields, "STX"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM puts a clicked word in the box the cursor is in, and so does this
|
||||||
|
/// where that box takes the word: a two-figure serial number typed into the
|
||||||
|
/// serial box is not moved to the check box behind the operator's back.
|
||||||
|
[Fact]
|
||||||
|
public void TheBoxWithTheCursorComesFirst()
|
||||||
|
{
|
||||||
|
EntryFields fields = new(SweepstakesLike);
|
||||||
|
fields.FocusOn(1);
|
||||||
|
|
||||||
|
Assert.Equal(1, DigitalElement.FieldFor(fields, "72"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NothingTakesAWordTheContestDoesNotExchange()
|
||||||
|
{
|
||||||
|
EntryFields fields = new(SweepstakesLike);
|
||||||
|
|
||||||
|
Assert.Null(DigitalElement.FieldFor(fields, "TU"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("599123", "123")]
|
||||||
|
[InlineData("599", "599")]
|
||||||
|
[InlineData("OH:", "OH")]
|
||||||
|
[InlineData("1234Z", "1234")]
|
||||||
|
[InlineData(" om5m ", "OM5M")]
|
||||||
|
public void WhatRttyCarriesAroundAValueIsCutOff(string clicked, string expected) =>
|
||||||
|
Assert.Equal(expected, DigitalElement.Trim(clicked));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APowerIsTakenWithOrWithoutItsUnit()
|
||||||
|
{
|
||||||
|
ExchangeField power = new("Pwr", ExchangeSlot.Section, ExchangeFieldKind.Power);
|
||||||
|
|
||||||
|
Assert.True(DigitalElement.Fits(power, "100"));
|
||||||
|
Assert.True(DigitalElement.Fits(power, "5W"));
|
||||||
|
Assert.True(DigitalElement.Fits(power, "1KW"));
|
||||||
|
Assert.False(DigitalElement.Fits(power, "KILOWATT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AGridIsFourOrSixCharacters()
|
||||||
|
{
|
||||||
|
ExchangeField grid = new("Grid", ExchangeSlot.GridSquare, ExchangeFieldKind.Grid);
|
||||||
|
|
||||||
|
Assert.True(DigitalElement.Fits(grid, "JN88"));
|
||||||
|
Assert.True(DigitalElement.Fits(grid, "JN88DS"));
|
||||||
|
Assert.False(DigitalElement.Fits(grid, "JN8"));
|
||||||
|
Assert.False(DigitalElement.Fits(grid, "8888"));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/Nonemm.Session.Tests/DigitalMacrosTests.cs
Normal file
30
tests/Nonemm.Session.Tests/DigitalMacrosTests.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
public class DigitalMacrosTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryButtonHasAPlaceEvenWhenTheFileIsShort()
|
||||||
|
{
|
||||||
|
DigitalMacros macros = DigitalMacros.Parse("CQ,{TX}CQ DE {MYCALL} K{RX}");
|
||||||
|
|
||||||
|
Assert.Equal(DigitalMacros.ButtonCount, macros.Buttons.Count);
|
||||||
|
Assert.Equal("CQ", macros[0].Label);
|
||||||
|
Assert.Equal("", macros[23].Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACommentLineIsNotAButton()
|
||||||
|
{
|
||||||
|
DigitalMacros macros = DigitalMacros.Parse("# the first row\nCQ,CQ DE {MYCALL}");
|
||||||
|
|
||||||
|
Assert.Equal("CQ", macros[0].Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoAmpersandsInALabelStandForOne()
|
||||||
|
{
|
||||||
|
Assert.Equal("S&P", DigitalMacros.Parse("S&&P,{S&P}")[0].Label);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/Nonemm.Session.Tests/DigitalReceiverTests.cs
Normal file
76
tests/Nonemm.Session.Tests/DigitalReceiverTests.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
/// The decoded text as the modem sends it, one character at a time.
|
||||||
|
public class DigitalReceiverTests
|
||||||
|
{
|
||||||
|
private static List<string> CallsIn(string text)
|
||||||
|
{
|
||||||
|
DigitalReceiver receiver = new();
|
||||||
|
List<string> heard = [];
|
||||||
|
receiver.CallHeard += (_, call) => heard.Add(call);
|
||||||
|
foreach (char c in text)
|
||||||
|
{
|
||||||
|
receiver.Receive(c.ToString());
|
||||||
|
}
|
||||||
|
return heard;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACallsignIsReportedWhenTheWordEnds()
|
||||||
|
{
|
||||||
|
Assert.Equal(["OM5M"], CallsIn("CQ DE OM5M "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AWordThatIsStillBeingSentIsNotReportedYet()
|
||||||
|
{
|
||||||
|
Assert.Empty(CallsIn("CQ DE OM5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ALineBreakEndsAWordTheSameWayASpaceDoes()
|
||||||
|
{
|
||||||
|
Assert.Equal(["OM5M"], CallsIn("DE OM5M\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoiseThatIsNotShapedLikeACallIsPassedOver()
|
||||||
|
{
|
||||||
|
Assert.Empty(CallsIn("RYRY ????? +.+. \r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheTextIsKeptAsItArrived()
|
||||||
|
{
|
||||||
|
DigitalReceiver receiver = new();
|
||||||
|
|
||||||
|
receiver.Receive("CQ TEST ");
|
||||||
|
receiver.Receive("OM5M");
|
||||||
|
|
||||||
|
Assert.Equal("CQ TEST OM5M", receiver.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A callsign has at most three characters after its number, so that is
|
||||||
|
/// where a word that ran into the next one is cut.
|
||||||
|
[Fact]
|
||||||
|
public void AWordRunTogetherWithTheNextIsCutBackToTheCall()
|
||||||
|
{
|
||||||
|
Assert.Equal("OM5MTE", DigitalReceiver.TrimToCall("OM5MTESTTEST"));
|
||||||
|
Assert.Equal("OM5M", DigitalReceiver.TrimToCall("OM5M"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APortableCallIsLeftAlone()
|
||||||
|
{
|
||||||
|
Assert.Equal("OM5M/P", DigitalReceiver.TrimToCall("OM5M/P"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheWordUnderThePointerIsTheOneAroundIt()
|
||||||
|
{
|
||||||
|
Assert.Equal("OM5M", DigitalReceiver.WordAt("CQ DE OM5M K", 7));
|
||||||
|
Assert.Equal("", DigitalReceiver.WordAt("CQ DE OM5M K", 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
tests/Nonemm.Session.Tests/GrabListTests.cs
Normal file
50
tests/Nonemm.Session.Tests/GrabListTests.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
public class GrabListTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TheNewestCallIsAtTheTop()
|
||||||
|
{
|
||||||
|
GrabList list = new();
|
||||||
|
|
||||||
|
list.Add("OM5M");
|
||||||
|
list.Add("S53M");
|
||||||
|
|
||||||
|
Assert.Equal(["S53M", "OM5M"], list.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACallHeardAgainMovesRatherThanRepeating()
|
||||||
|
{
|
||||||
|
GrabList list = new();
|
||||||
|
list.Add("OM5M");
|
||||||
|
list.Add("S53M");
|
||||||
|
|
||||||
|
list.Add("OM5M");
|
||||||
|
|
||||||
|
Assert.Equal(["OM5M", "S53M"], list.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheOldestFallsOffTheEnd()
|
||||||
|
{
|
||||||
|
GrabList list = new(capacity: 2);
|
||||||
|
|
||||||
|
list.Add("OM5M");
|
||||||
|
list.Add("S53M");
|
||||||
|
list.Add("9A1A");
|
||||||
|
|
||||||
|
Assert.Equal(["9A1A", "S53M"], list.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OurOwnCallAndTheOneBeingTypedAreNotWanted()
|
||||||
|
{
|
||||||
|
Assert.False(GrabList.Wanted("OM5M", "OM5M", ""));
|
||||||
|
Assert.False(GrabList.Wanted("S53M", "OM5M", "S53M"));
|
||||||
|
Assert.False(GrabList.Wanted("OM5", "OM5M", ""));
|
||||||
|
Assert.True(GrabList.Wanted("S53M", "OM5M", "9A1A"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,4 +109,16 @@ public class MessagePlanTests
|
|||||||
Assert.Equal("CQ TEST", plan.Text);
|
Assert.Equal("CQ TEST", plan.Text);
|
||||||
Assert.Empty(plan.Before);
|
Assert.Empty(plan.Before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The digital window's transmit macros, and the carriage return that
|
||||||
|
/// starts a new line on the other station's screen.
|
||||||
|
[Fact]
|
||||||
|
public void TheDigitalMacrosAreReadAsActionsAndText()
|
||||||
|
{
|
||||||
|
MessagePlan plan = MessagePlan.Read("{TX}CQ DE {MYCALL}{ENTER}{RX}", Session());
|
||||||
|
|
||||||
|
Assert.Equal([MessageCommand.StartTransmit, MessageCommand.ReturnToReceive],
|
||||||
|
plan.Before.Select(a => a.Command));
|
||||||
|
Assert.Equal("CQ DE DL1ABC\r", plan.Text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
tests/Nonemm.Session.Tests/ModeEntryTests.cs
Normal file
38
tests/Nonemm.Session.Tests/ModeEntryTests.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Session;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
/// A mode typed into the callsign box.
|
||||||
|
public class ModeEntryTests
|
||||||
|
{
|
||||||
|
private static readonly Frequency Twenty = Frequency.FromKilohertz(14_080);
|
||||||
|
private static readonly Frequency Forty = Frequency.FromKilohertz(7_040);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("CW", "CW")]
|
||||||
|
[InlineData("rtty", "RTTY")]
|
||||||
|
[InlineData("FT8", "FT8")]
|
||||||
|
[InlineData("USB", "USB")]
|
||||||
|
public void AModeNameIsTakenWhateverItsCase(string typed, string expected) =>
|
||||||
|
Assert.Equal(expected, ModeEntry.Parse(typed, Twenty)?.Name);
|
||||||
|
|
||||||
|
/// SSB names no sideband, so the band decides, the way the band panel does.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SSB")]
|
||||||
|
[InlineData("PH")]
|
||||||
|
[InlineData("PHONE")]
|
||||||
|
public void PhoneTakesTheSidebandTheBandCallsFor(string typed)
|
||||||
|
{
|
||||||
|
Assert.Equal("USB", ModeEntry.Parse(typed, Twenty)?.Name);
|
||||||
|
Assert.Equal("LSB", ModeEntry.Parse(typed, Forty)?.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("OM5M")]
|
||||||
|
[InlineData("14080")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void AnythingThatIsNotAModeIsLeftAlone(string typed) =>
|
||||||
|
Assert.Null(ModeEntry.Parse(typed, Twenty));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user