chore: release v0.23.0
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
|
||||
|
||||
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
|
||||
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
|
||||
cannot be started over the serial link, and the attempt to work around that was
|
||||
abandoned.**
|
||||
|
||||
## The tuner is PA state 8
|
||||
|
||||
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
|
||||
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
|
||||
twice, front-panel TUNE, with the byte-level trace running:
|
||||
|
||||
```
|
||||
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
|
||||
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
|
||||
14:47:24.006 state 8 step 2
|
||||
14:47:25.201 state 8 step 4
|
||||
14:47:27.457 state 8 step 5
|
||||
14:47:30.092 state 5 STANDBY ← accord finished
|
||||
```
|
||||
|
||||
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
|
||||
Step 0 means *armed, waiting for RF* — in the first of the two runs the
|
||||
amplifier sat there **30 seconds** untouched before the operator transmitted.
|
||||
|
||||
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
|
||||
|
||||
## The command does not exist in the state-command family
|
||||
|
||||
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
|
||||
**verified** STANDBY (an earlier sweep was worthless because its second code
|
||||
parked the amp in SERVICE, where it silently refuses everything — 17 false
|
||||
negatives). Result:
|
||||
|
||||
| byte | effect |
|
||||
|------|--------|
|
||||
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
|
||||
| 0x04 | → SERVICE (state 4) |
|
||||
| 0x05 | → STANDBY (state 5) |
|
||||
| 0x06 | → OPERATE (state 6) |
|
||||
| 0x0A | → OFF (state 10) |
|
||||
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
|
||||
|
||||
The action byte **is** the target PA state — four independent confirmations.
|
||||
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
|
||||
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
|
||||
|
||||
Going further would mean varying the other bytes of the frame. That is a large
|
||||
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
|
||||
it.
|
||||
|
||||
## The workaround that was built and removed
|
||||
|
||||
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
|
||||
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
|
||||
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
|
||||
Settings → Amplifier) and **removed** after the first hardware test:
|
||||
|
||||
- the power change did not take effect, and
|
||||
- **the PTT was not released when the tune finished.**
|
||||
|
||||
A logger that can leave a transceiver keyed is not acceptable, and the whole
|
||||
thing was scaffolding around a protocol we do not actually control. Removed at
|
||||
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
|
||||
bidouille."*
|
||||
|
||||
If it is ever revisited, the unexplained part is why the tune-finished detection
|
||||
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
|
||||
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
|
||||
|
||||
## Loose ends worth knowing
|
||||
|
||||
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
|
||||
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
|
||||
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
|
||||
tuner flag for a while purely because of this — it toggles on its own several
|
||||
times a minute. Do not read tail bytes without checking byte 6.
|
||||
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
|
||||
not a command acknowledgement.
|
||||
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
|
||||
tune and during transmission.** `errText` renders those as "ERROR — see
|
||||
display" and "Remove drive power", so an ACOM operator probably sees a false
|
||||
alarm. This was never confirmed with the amplifier in OPERATE (it was in
|
||||
STANDBY throughout, where drive genuinely is an error) — worth a look with a
|
||||
clean log before touching the table.
|
||||
@@ -77,9 +77,14 @@ var models = map[string]model{
|
||||
}
|
||||
|
||||
// paStatusNames maps the PAstatus nibble to a display string.
|
||||
// 8 is absent from the state list the ACOM-Controller project recovered; it is
|
||||
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
|
||||
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
|
||||
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
|
||||
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
|
||||
var paStatusNames = map[int]string{
|
||||
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
|
||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
|
||||
}
|
||||
|
||||
// acomBands maps the band nibble to a band label.
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
||||
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
||||
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
||||
// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
|
||||
// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
|
||||
// status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
|
||||
// (IOT Relay Programming Manual V1.9.8.1, §3.)
|
||||
//
|
||||
// A Device presents the same surface to the app regardless of wire protocol.
|
||||
package relaydev
|
||||
@@ -173,3 +177,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
|
||||
//
|
||||
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
|
||||
// use the HTTP GET CGI, which needs no connection state and matches how the
|
||||
// other network boards here are driven.
|
||||
//
|
||||
// Both endpoints answer &-separated fields, first one 0 on success:
|
||||
//
|
||||
// /relay_cgi_load.cgi → &0&4&1&0&1&0&
|
||||
// result, count, r1…rN
|
||||
// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0&
|
||||
// result, type, relay, on, time
|
||||
//
|
||||
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
|
||||
// Device interface is 1-based like every other board here.
|
||||
type dingtian struct {
|
||||
host string
|
||||
session string // optional: the board can require "Cookie: session=<id>"
|
||||
pwd string // CGI password, 0–9999; "0" (or blank) when none is set
|
||||
count int
|
||||
}
|
||||
|
||||
// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
|
||||
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
|
||||
// password (blank or "0" when none).
|
||||
func NewDingtian(host, session, pwd string, count int) Device {
|
||||
if count <= 0 {
|
||||
count = 2
|
||||
}
|
||||
if strings.TrimSpace(pwd) == "" {
|
||||
pwd = "0"
|
||||
}
|
||||
return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
|
||||
}
|
||||
|
||||
func (d *dingtian) Count() int { return d.count }
|
||||
func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
// getCGI issues the GET with the session cookie the board may require.
|
||||
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.session != "" {
|
||||
req.Header.Set("Cookie", "session="+d.session)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// dtFields splits an &-separated CGI reply into its fields, dropping the empty
|
||||
// ones the leading and trailing "&" produce.
|
||||
func dtFields(body []byte) []string {
|
||||
var out []string
|
||||
for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > d.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||
}
|
||||
state := 0
|
||||
if on {
|
||||
state = 1
|
||||
}
|
||||
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
|
||||
// and time is then unused.
|
||||
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
|
||||
d.host, relay-1, state, d.pwd))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A wrong password or session answers 200 with a non-zero result (e.g.
|
||||
// "&302&/&"), so the HTTP status alone does not tell us it worked.
|
||||
if f := dtFields(body); len(f) == 0 || f[0] != "0" {
|
||||
return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
|
||||
strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
|
||||
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := dtFields(body)
|
||||
if len(f) < 2 || f[0] != "0" {
|
||||
return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
|
||||
}
|
||||
// The board reports its own relay count; trust it over the configured one so
|
||||
// a mis-set channel count doesn't silently hide relays.
|
||||
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
|
||||
d.count = n
|
||||
}
|
||||
out := make([]bool, d.count)
|
||||
for i := 0; i < d.count && i+2 < len(f); i++ {
|
||||
out[i] = f[i+2] == "1"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -99,3 +99,76 @@ func TestKMTronicSetURL(t *testing.T) {
|
||||
}
|
||||
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
||||
}
|
||||
|
||||
// Dingtian — the wire examples come straight from the IOT Relay Programming
|
||||
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
|
||||
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
|
||||
// or session) answers HTTP 200 with a non-zero first field.
|
||||
|
||||
func TestDingtianStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/relay_cgi_load.cgi" {
|
||||
t.Errorf("unexpected status path %q", r.URL.Path)
|
||||
}
|
||||
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
|
||||
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The board said 4 relays even though 2 were configured — its count wins.
|
||||
want := []bool{true, false, true, false}
|
||||
if len(st) != len(want) {
|
||||
t.Fatalf("got %d relays, want %d", len(st), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if st[i] != want[i] {
|
||||
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
|
||||
}
|
||||
}
|
||||
if d.Count() != 4 {
|
||||
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
|
||||
var gotQuery, gotCookie string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
gotCookie = r.Header.Get("Cookie")
|
||||
_, _ = w.Write([]byte("&0&0&2&1&0&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
|
||||
if err := d.Set(context.Background(), 3, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "relay=2") {
|
||||
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
|
||||
!strings.Contains(gotQuery, "pwd=4660") {
|
||||
t.Errorf("unexpected query %q", gotQuery)
|
||||
}
|
||||
if gotCookie != "session=12345678" {
|
||||
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingtianSetRefusalIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Manual §3.4.4: a bad session still answers 200 OK.
|
||||
_, _ = w.Write([]byte("&302&/&"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||
if err := d.Set(context.Background(), 1, true); err == nil {
|
||||
t.Fatal("a refused command answered HTTP 200 and was reported as success")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user