From 880ecdbbb52372b16f08883e6d04d4296fc8befa Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 21 Jul 2026 09:31:08 +0200 Subject: [PATCH] fix: cache relay-board drivers so Denkovi/USB-serial boards stay connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The station-control code rebuilt a fresh driver on every status poll and every relay set. Stateful boards (Denkovi FTDI D2XX, USB-serial) hold an OS handle only one opener can own, so the first poll opened the board and leaked the handle, and every poll after failed with 'device in use' — the relays greyed out a second after Save and auto-control never switched. Drivers are now cached per device and reused, closed on config change. Adds a Test-connection button + detect feedback in the device editor (reported by VK4MA). --- app.go | 82 ++++++++++++++++++- .../src/components/StationControlPanel.tsx | 40 +++++++-- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 16 ++++ internal/relaydev/denkovi_other.go | 3 +- internal/relaydev/denkovi_windows.go | 13 +++ internal/relaydev/relaydev.go | 11 ++- internal/relaydev/serialrelay.go | 13 +++ 10 files changed, 174 insertions(+), 14 deletions(-) diff --git a/app.go b/app.go index 0548bec..c42b879 100644 --- a/app.go +++ b/app.go @@ -490,6 +490,8 @@ type App struct { relayAutoMu sync.Mutex // serialises relay auto-control evaluation relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off + relayDrvMu sync.Mutex // guards the cached relay drivers below + relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call) pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) @@ -11433,8 +11435,15 @@ func relayCountFor(typ string) int { } } -// deviceDriver builds the wire driver for a configured device. -func deviceDriver(d StationDevice) relaydev.Device { +// cachedRelay is a live driver plus the config signature it was built from, so a +// changed device (new COM port, serial or channel count) rebuilds it. +type cachedRelay struct { + key string + dev relaydev.Device +} + +// buildDeviceDriver builds a fresh wire driver for a configured device. +func buildDeviceDriver(d StationDevice) relaydev.Device { switch d.Type { case "kmtronic": return relaydev.NewKMTronic(d.Host, d.User, d.Pass) @@ -11449,12 +11458,74 @@ func deviceDriver(d StationDevice) relaydev.Device { } } +// deviceKey is the config signature that, when unchanged, lets us reuse a device's +// open driver (and its OS handle) instead of rebuilding it every poll. +func deviceKey(d StationDevice) string { + return fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d)) +} + +// driverFor returns the cached, still-open driver for a device, building it once +// and reusing it thereafter. Stateful boards (Denkovi FTDI, USB-serial) hold an OS +// handle that only one opener can own, so rebuilding a driver every call — as the +// old code did — leaked the handle and made every poll after the first fail with +// "device in use", greying the relays out. Reuse fixes that. +func (a *App) driverFor(d StationDevice) relaydev.Device { + key := deviceKey(d) + a.relayDrvMu.Lock() + defer a.relayDrvMu.Unlock() + if a.relayDrv == nil { + a.relayDrv = map[string]cachedRelay{} + } + if c, ok := a.relayDrv[d.ID]; ok { + if c.key == key { + return c.dev + } + _ = c.dev.Close() // config changed → release the old handle before rebuilding + delete(a.relayDrv, d.ID) + } + dev := buildDeviceDriver(d) + a.relayDrv[d.ID] = cachedRelay{key: key, dev: dev} + return dev +} + +// closeRelayDrivers closes and drops every cached driver (e.g. after the device +// list changes) so stale handles are released and rebuilt fresh on next use. +func (a *App) closeRelayDrivers() { + a.relayDrvMu.Lock() + defer a.relayDrvMu.Unlock() + for id, c := range a.relayDrv { + _ = c.dev.Close() + delete(a.relayDrv, id) + } +} + // ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI // boards, for the settings picker. Windows-only (FTDI D2XX). func (a *App) ListDenkoviDevices() ([]string, error) { return relaydev.ListDenkovi() } +// StationTestResult is the outcome of probing one relay board, for the settings +// dialog's Connect/Test button so the user gets a clear connected/failed message. +type StationTestResult struct { + OK bool `json:"ok"` + Relays int `json:"relays"` + Error string `json:"error,omitempty"` +} + +// TestStationDevice opens the given board and reads its state, returning a clear +// success/failure so the settings UI can tell the user whether it's reachable. +// Reuses the cached driver (no double-open conflict with the live poll). +func (a *App) TestStationDevice(d StationDevice) StationTestResult { + ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) + defer cancel() + states, err := a.driverFor(d).Status(ctx) + if err != nil { + return StationTestResult{OK: false, Error: err.Error()} + } + return StationTestResult{OK: true, Relays: len(states)} +} + // GetStationDevices returns the configured relay boards (without live state). func (a *App) GetStationDevices() []StationDevice { out := []StationDevice{} @@ -11497,6 +11568,9 @@ func (a *App) SaveStationDevices(devs []StationDevice) error { if err != nil { return err } + // Release any open board handles so the new config reopens cleanly (a changed + // COM port / FTDI serial must not stay held by a stale driver). + a.closeRelayDrivers() return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b)) } @@ -11532,7 +11606,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus { ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type} ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) defer cancel() - states, err := deviceDriver(d).Status(ctx) + states, err := a.driverFor(d).Status(ctx) if err != nil { ds.Error = err.Error() } else { @@ -11563,7 +11637,7 @@ func (a *App) StationSetRelay(id string, relay int, on bool) error { if d.ID == id { ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) defer cancel() - return deviceDriver(d).Set(ctx, relay, on) + return a.driverFor(d).Set(ctx, relay, on) } } return fmt.Errorf("station device %q not found", id) diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 3d6710d..7956b40 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -12,7 +12,7 @@ import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, - ListDenkoviDevices, ListSerialPorts, + ListDenkoviDevices, ListSerialPorts, TestStationDevice, } from '../../wailsjs/go/main/App'; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; @@ -473,16 +473,33 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { // Detected FTDI serials for the Denkovi picker. const [denkoviSerials, setDenkoviSerials] = useState([]); const [detecting, setDetecting] = useState(false); + const [detectMsg, setDetectMsg] = useState(''); const detectDenkovi = async () => { setDetecting(true); + setDetectMsg(''); try { const list = ((await ListDenkoviDevices()) ?? []) as string[]; setDenkoviSerials(list); // Auto-fill when there's exactly one and nothing chosen yet. if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] }); - } catch { setDenkoviSerials([]); } + setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length })); + } catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); } finally { setDetecting(false); } }; + // Connection test result for the current device config. + const [testing, setTesting] = useState(false); + const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null); + const testDevice = async () => { + setTesting(true); + setTestMsg(null); + try { + const r: any = await TestStationDevice(device as any); + setTestMsg(r?.ok + ? { ok: true, text: t('station.testOk', { n: r.relays }) } + : { ok: false, text: r?.error || t('station.testFail') }); + } catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); } + finally { setTesting(false); } + }; useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]); return (
@@ -534,6 +551,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {

{t('station.ftdiHint')}

+ {detectMsg &&

{detectMsg}

} ) : isUsbRelay ? (
@@ -581,9 +599,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { ))}
-
- - +
+ {testMsg && ( + + + {testMsg.text} + + )} +
+ + + +
); diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 8e80fa7..27f3792 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.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', + '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', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer', 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', 'sec.relayauto': 'Relay auto-control', @@ -445,7 +445,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.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', + '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', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).', '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': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', 'sec.relayauto': 'Relais automatiques', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 99854e4..8c05637 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -849,6 +849,8 @@ export function TestQRZUpload():Promise; export function TestRotator(arg1:main.RotatorSettings):Promise; +export function TestStationDevice(arg1:main.StationDevice):Promise; + export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise; export function ULSStatus():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3d080c4..088ddba 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1654,6 +1654,10 @@ export function TestRotator(arg1) { return window['go']['main']['App']['TestRotator'](arg1); } +export function TestStationDevice(arg1) { + return window['go']['main']['App']['TestStationDevice'](arg1); +} + export function TestUltrabeam(arg1) { return window['go']['main']['App']['TestUltrabeam'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1af2b98..c4c8ca9 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2746,6 +2746,22 @@ export namespace main { this.my_pota_ref = source["my_pota_ref"]; } } + export class StationTestResult { + ok: boolean; + relays: number; + error?: string; + + static createFrom(source: any = {}) { + return new StationTestResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ok = source["ok"]; + this.relays = source["relays"]; + this.error = source["error"]; + } + } export class ULSStatusResult { count: number; updated_at: string; diff --git a/internal/relaydev/denkovi_other.go b/internal/relaydev/denkovi_other.go index 1260a11..ea1b58d 100644 --- a/internal/relaydev/denkovi_other.go +++ b/internal/relaydev/denkovi_other.go @@ -20,7 +20,8 @@ func NewDenkovi(serial string, count int) Device { return denkoviStub{count: count} } -func (s denkoviStub) Count() int { return s.count } +func (s denkoviStub) Count() int { return s.count } +func (denkoviStub) Close() error { return nil } 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 66eda1b..b679898 100644 --- a/internal/relaydev/denkovi_windows.go +++ b/internal/relaydev/denkovi_windows.go @@ -121,6 +121,19 @@ func (d *denkovi) Set(ctx context.Context, relay int, on bool) error { return d.writeLocked() } +// Close releases the FTDI handle so the board can be reopened later (only one +// handle may hold a D2XX device at a time). +func (d *denkovi) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + if d.opened { + procClose.Call(d.h) + d.opened = false + d.h = 0 + } + return nil +} + func (d *denkovi) Status(ctx context.Context) ([]bool, error) { d.mu.Lock() defer d.mu.Unlock() diff --git a/internal/relaydev/relaydev.go b/internal/relaydev/relaydev.go index 5d8fd92..105fe8d 100644 --- a/internal/relaydev/relaydev.go +++ b/internal/relaydev/relaydev.go @@ -27,6 +27,11 @@ type Device interface { Count() int // number of user-controllable relays Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1) Set(ctx context.Context, relay int, on bool) error // relay is 1-based + // Close releases any OS handle the driver holds (serial port, FTDI handle). + // Network boards hold nothing and no-op. MUST be called when a cached driver is + // discarded so the port/handle is freed for the next open — stateful boards + // (Denkovi, USB-serial) can only be opened by one handle at a time. + Close() error } func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} } @@ -62,7 +67,8 @@ type webswitch struct { // NewWebswitch builds a WebSwitch 1216H client (5 relays). func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} } -func (w *webswitch) Count() int { return w.count } +func (w *webswitch) Count() int { return w.count } +func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release func (w *webswitch) Set(ctx context.Context, relay int, on bool) error { if relay < 1 || relay > w.count { @@ -118,7 +124,8 @@ func NewKMTronic(host, user, pass string) Device { return &kmtronic{host: host, user: user, pass: pass, count: 8} } -func (k *kmtronic) Count() int { return k.count } +func (k *kmtronic) Count() int { return k.count } +func (k *kmtronic) Close() error { return nil } // stateless HTTP, nothing to release func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error { if relay < 1 || relay > k.count { diff --git a/internal/relaydev/serialrelay.go b/internal/relaydev/serialrelay.go index 6ec3b46..6aadf26 100644 --- a/internal/relaydev/serialrelay.go +++ b/internal/relaydev/serialrelay.go @@ -37,6 +37,19 @@ func NewSerialRelay(port string, count int) Device { func (s *serialRelay) Count() int { return s.count } +// Close releases the COM port so it can be reopened later (only one handle may +// hold a serial port at a time). +func (s *serialRelay) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.port != nil { + err := s.port.Close() + s.port = nil + return err + } + return nil +} + func (s *serialRelay) ensureOpen() error { if s.port != nil { return nil