@@ -486,6 +505,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
onChange({ ...device, name: e.target.value })} />
+ {(isDenkovi || isUsbRelay) && (
+
+
+
+
+ )}
{isDenkovi ? (
@@ -503,6 +535,23 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
{t('station.ftdiHint')}
+ ) : isUsbRelay ? (
+
+
+
+
+
+
+
{t('station.usbRelayHint')}
+
) : (
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 272991d..2438574 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -131,7 +131,7 @@ const en: Dict = {
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
- 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
+ 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.relayauto': 'Relay auto-control',
@@ -444,7 +444,7 @@ const fr: Dict = {
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
- 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
+ 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.relayauto': 'Relais automatiques',
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index e652eb1..0b1075d 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -2600,6 +2600,7 @@ export namespace main {
host: string;
user?: string;
pass?: string;
+ channels?: number;
labels: string[];
static createFrom(source: any = {}) {
@@ -2614,6 +2615,7 @@ export namespace main {
this.host = source["host"];
this.user = source["user"];
this.pass = source["pass"];
+ this.channels = source["channels"];
this.labels = source["labels"];
}
}
diff --git a/internal/relaydev/denkovi_other.go b/internal/relaydev/denkovi_other.go
index ae6048d..1260a11 100644
--- a/internal/relaydev/denkovi_other.go
+++ b/internal/relaydev/denkovi_other.go
@@ -10,12 +10,17 @@ import (
"fmt"
)
-type denkoviStub struct{}
+type denkoviStub struct{ count int }
// NewDenkovi returns a stub on non-Windows builds.
-func NewDenkovi(serial string) Device { return denkoviStub{} }
+func NewDenkovi(serial string, count int) Device {
+ if count != 4 {
+ count = 8
+ }
+ return denkoviStub{count: count}
+}
-func (denkoviStub) Count() int { return 8 }
+func (s denkoviStub) Count() int { return s.count }
func (denkoviStub) Status(context.Context) ([]bool, error) {
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
}
diff --git a/internal/relaydev/denkovi_windows.go b/internal/relaydev/denkovi_windows.go
index 2fe9868..66eda1b 100644
--- a/internal/relaydev/denkovi_windows.go
+++ b/internal/relaydev/denkovi_windows.go
@@ -46,10 +46,14 @@ type denkovi struct {
opened bool
}
-// NewDenkovi builds a driver for a Denkovi USB 8-relay board identified by its
-// FTDI serial number (shown by the vendor tool / FT_PROG, e.g. "DAE0006K").
-func NewDenkovi(serial string) Device {
- return &denkovi{serial: strings.TrimSpace(serial), count: 8}
+// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
+// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
+// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
+func NewDenkovi(serial string, count int) Device {
+ if count != 4 && count != 8 {
+ count = 8
+ }
+ return &denkovi{serial: strings.TrimSpace(serial), count: count}
}
func (d *denkovi) Count() int { return d.count }
diff --git a/internal/relaydev/serialrelay.go b/internal/relaydev/serialrelay.go
new file mode 100644
index 0000000..6ec3b46
--- /dev/null
+++ b/internal/relaydev/serialrelay.go
@@ -0,0 +1,89 @@
+package relaydev
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+
+ "go.bug.st/serial"
+)
+
+// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
+// style) that use the "A0" command protocol over a virtual COM port:
+//
+// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
+//
+// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
+// write-only (no state readback), so Status reflects a shadow of what we sent.
+// Channel count varies by board (1/2/4/8/16), so it is configurable.
+type serialRelay struct {
+ portName string
+ count int
+ baud int
+ mu sync.Mutex
+ port serial.Port
+ shadow []bool
+}
+
+// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
+// the given COM port with `count` channels (defaults to 8).
+func NewSerialRelay(port string, count int) Device {
+ if count < 1 {
+ count = 8
+ }
+ return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, count)}
+}
+
+func (s *serialRelay) Count() int { return s.count }
+
+func (s *serialRelay) ensureOpen() error {
+ if s.port != nil {
+ return nil
+ }
+ if s.portName == "" {
+ return fmt.Errorf("no COM port set for the USB relay board")
+ }
+ p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
+ if err != nil {
+ return fmt.Errorf("open %s: %w", s.portName, err)
+ }
+ s.port = p
+ return nil
+}
+
+func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
+ if relay < 1 || relay > s.count {
+ return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.ensureOpen(); err != nil {
+ return err
+ }
+ st := byte(0)
+ if on {
+ st = 1
+ }
+ r := byte(relay)
+ frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
+ if _, err := s.port.Write(frame); err != nil {
+ // Drop the handle so the next call reopens (USB unplugged / port reset).
+ s.port.Close()
+ s.port = nil
+ return fmt.Errorf("write to %s: %w", s.portName, err)
+ }
+ s.shadow[relay-1] = on
+ return nil
+}
+
+func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.ensureOpen(); err != nil {
+ return nil, err
+ }
+ out := make([]bool, s.count)
+ copy(out, s.shadow)
+ return out, nil
+}