]> git.giacomolli.uk - onehouse/commitdiff
Define independent M31 module address ranges
authorAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 22:53:09 +0000 (19:53 -0300)
committerAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 23:29:35 +0000 (20:29 -0300)
M31 topology validates the host, expansion models, and hardware pulse
settings. Each I/O family advances its own address range, so mixed
assemblies keep stable capability names without coupling analog and
digital offsets. Descriptors expose physical access and omit transient
pulse feedback.

Issue: 8fdc39a

doc/m31.rst [new file with mode: 0644]
driver/m31/module.go [new file with mode: 0644]
driver/m31/point.go [new file with mode: 0644]
driver/m31/topology.go [new file with mode: 0644]
driver/m31/topology_test.go [new file with mode: 0644]

diff --git a/doc/m31.rst b/doc/m31.rst
new file mode 100644 (file)
index 0000000..b9b714f
--- /dev/null
@@ -0,0 +1,29 @@
+###############
+ Ebyte M31 I/O
+###############
+
+The ``driver/m31`` package defines one host and up to fifteen expansion
+modules. A module's slice index is its slot. The host has eight digital
+inputs and eight digital outputs. Mixed expansions add eight of each,
+input expansions add sixteen inputs, output expansions add sixteen
+outputs, and analog expansions add eight voltage outputs.
+
+Each point uses ``m{slot}.di{channel}``, ``m{slot}.do{channel}``, or
+``m{slot}.ao{channel}``. Inputs, coils, and analog registers have
+independent zero-based address ranges that continue across modules.
+Configure every physical analog output channel for zero through ten
+volts before operation. The driver does not configure the module's
+output range. Analog registers represent zero through ten volts as zero
+through 10000. Hardware pulse widths are whole milliseconds from 50
+through 65535. Their width registers start at ``0x30d4`` and their
+output-mode registers start at ``0x32c8``, both indexed by coil address.
+Mode zero is level control and mode one is pulse control.
+
+Descriptors use manufacturer ``ebyte`` and firmware ``unknown``. The
+package does not read the host identity registers. Pulse capabilities
+accept commands but do not report transient coil state. Input
+capabilities are read-only.
+
+Tests verify topology and descriptor values without a physical module.
+They do not confirm physical expansion limits or relay timing under
+load. The conservative sixteen-module limit includes the host.
diff --git a/driver/m31/module.go b/driver/m31/module.go
new file mode 100644 (file)
index 0000000..173aebd
--- /dev/null
@@ -0,0 +1,30 @@
+package m31
+
+import "time"
+
+const (
+       // HostModel has eight inputs and eight relay outputs and occupies slot
+       // zero.
+       HostModel = "m31-axax8080g-u"
+
+       // MixedModel adds eight digital inputs and eight digital outputs.
+       MixedModel = "m31-gaxax8080-u"
+
+       // InputModel adds sixteen digital inputs.
+       InputModel = "m31-gaxxxa000-u"
+
+       // OutputModel adds sixteen digital outputs.
+       OutputModel = "m31-gxxax00a0-u"
+
+       // AnalogModel adds eight voltage outputs from zero through ten volts.
+       AnalogModel = "m31-gxxxb0008-u"
+)
+
+// Module occupies its slice index in Settings.Modules. PulseWidths maps digital
+// output channels to hardware pulse widths. Widths must be whole milliseconds
+// from 50 through 65535 milliseconds. Other digital outputs use level mode.
+// The constructor copies the topology and does not retain these maps.
+type Module struct {
+       Model       string
+       PulseWidths map[int]time.Duration
+}
diff --git a/driver/m31/point.go b/driver/m31/point.go
new file mode 100644 (file)
index 0000000..8eec21c
--- /dev/null
@@ -0,0 +1,7 @@
+package m31
+
+type point struct {
+       id      string
+       address uint16
+       pulse   uint16
+}
diff --git a/driver/m31/topology.go b/driver/m31/topology.go
new file mode 100644 (file)
index 0000000..5c04ed0
--- /dev/null
@@ -0,0 +1,114 @@
+package m31
+
+import (
+       "fmt"
+       "time"
+
+       "onehouse/protocol"
+)
+
+type topology struct {
+       inputs  []point
+       outputs []point
+       analog  []point
+}
+
+func newTopology(modules []Module) (topology, error) {
+       var result topology
+
+       if len(modules) == 0 || len(modules) > 16 {
+               return result, fmt.Errorf("topology requires one through sixteen modules")
+       }
+
+       for slot, module := range modules {
+               inputs, outputs, analog := 0, 0, 0
+
+               switch module.Model {
+               case HostModel, MixedModel:
+                       inputs, outputs = 8, 8
+               case InputModel:
+                       inputs = 16
+               case OutputModel:
+                       outputs = 16
+               case AnalogModel:
+                       analog = 8
+               default:
+                       return topology{}, fmt.Errorf("unsupported module model in slot %d", slot)
+               }
+
+               if (slot == 0) != (module.Model == HostModel) {
+                       return topology{}, fmt.Errorf("only slot zero must contain the host")
+               }
+
+               for channel, width := range module.PulseWidths {
+                       if channel < 0 || channel >= outputs || width < 50*time.Millisecond || width > 65535*time.Millisecond || width%time.Millisecond != 0 {
+                               return topology{}, fmt.Errorf("invalid pulse channel or width in slot %d", slot)
+                       }
+               }
+
+               for channel := range inputs {
+                       result.inputs = append(result.inputs, point{
+                               id:      fmt.Sprintf("m%d.di%d", slot, channel),
+                               address: uint16(len(result.inputs)),
+                       })
+               }
+
+               for channel := range outputs {
+                       result.outputs = append(result.outputs, point{
+                               id:      fmt.Sprintf("m%d.do%d", slot, channel),
+                               address: uint16(len(result.outputs)),
+                               pulse:   uint16(module.PulseWidths[channel] / time.Millisecond),
+                       })
+               }
+
+               for channel := range analog {
+                       result.analog = append(result.analog, point{
+                               id:      fmt.Sprintf("m%d.ao%d", slot, channel),
+                               address: uint16(len(result.analog)),
+                       })
+               }
+       }
+
+       return result, nil
+}
+
+func (t topology) descriptor() protocol.DescriptorPayload {
+       result := protocol.DescriptorPayload{
+               Manufacturer: "ebyte",
+               Model:        HostModel,
+               Firmware:     "unknown",
+       }
+
+       for _, input := range t.inputs {
+               result.Capabilities = append(result.Capabilities, protocol.Capability{
+                       ID:           input.id,
+                       Type:         "switch",
+                       ReportsState: true,
+               })
+       }
+
+       for _, output := range t.outputs {
+               kind := "switch"
+               if output.pulse != 0 {
+                       kind = "pulse"
+               }
+
+               result.Capabilities = append(result.Capabilities, protocol.Capability{
+                       ID:              output.id,
+                       Type:            kind,
+                       ReportsState:    output.pulse == 0,
+                       AcceptsCommands: true,
+               })
+       }
+
+       for _, output := range t.analog {
+               result.Capabilities = append(result.Capabilities, protocol.Capability{
+                       ID:              output.id,
+                       Type:            "voltage",
+                       ReportsState:    true,
+                       AcceptsCommands: true,
+               })
+       }
+
+       return result
+}
diff --git a/driver/m31/topology_test.go b/driver/m31/topology_test.go
new file mode 100644 (file)
index 0000000..98445fa
--- /dev/null
@@ -0,0 +1,74 @@
+package m31
+
+import (
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+
+       "onehouse/protocol"
+)
+
+func TestTopologyKeepsIndependentAddressRanges(t *testing.T) {
+       value, err := newTopology([]Module{
+               {Model: HostModel},
+               {Model: OutputModel, PulseWidths: map[int]time.Duration{15: 200 * time.Millisecond}},
+               {Model: AnalogModel},
+               {Model: InputModel},
+               {Model: MixedModel},
+               {Model: AnalogModel},
+       })
+       require.NoError(t, err)
+       require.Len(t, value.inputs, 32)
+       require.Len(t, value.outputs, 32)
+       require.Len(t, value.analog, 16)
+       require.Equal(t, "m4.di0", value.inputs[24].id)
+       require.Equal(t, "m4.do0", value.outputs[24].id)
+       require.EqualValues(t, 24, value.outputs[24].address)
+       require.Equal(t, "m5.ao0", value.analog[8].id)
+       require.EqualValues(t, 8, value.analog[8].address)
+       require.EqualValues(t, 200, value.outputs[23].pulse)
+
+       descriptor := value.descriptor()
+       require.Len(t, descriptor.Capabilities, 80)
+
+       for _, capability := range descriptor.Capabilities {
+               if capability.ID == "m1.do15" {
+                       require.Equal(t, "pulse", capability.Type)
+                       require.False(t, capability.ReportsState)
+                       require.True(t, capability.AcceptsCommands)
+               }
+       }
+
+       id, err := protocol.NewID(time.Now())
+       require.NoError(t, err)
+
+       _, err = protocol.BuildDescriptor(id, protocol.Metadata{MessageID: id}, descriptor)
+       require.NoError(t, err)
+}
+
+func TestTopologyRejectsUnsafeModulesAndPulseSettings(t *testing.T) {
+       testCases := []struct {
+               name    string
+               modules []Module
+       }{
+               {name: "requires a host"},
+               {name: "limits total modules", modules: make([]Module, 17)},
+               {name: "rejects unknown models", modules: []Module{{Model: "unknown"}}},
+               {name: "rejects expansion at slot zero", modules: []Module{{Model: MixedModel}}},
+               {name: "rejects another host", modules: []Module{{Model: HostModel}, {Model: HostModel}}},
+               {name: "rejects negative pulse channel", modules: []Module{{Model: HostModel, PulseWidths: map[int]time.Duration{-1: time.Second}}}},
+               {name: "rejects excess pulse channel", modules: []Module{{Model: HostModel, PulseWidths: map[int]time.Duration{8: time.Second}}}},
+               {name: "rejects pulse on input module", modules: []Module{{Model: HostModel}, {Model: InputModel, PulseWidths: map[int]time.Duration{0: time.Second}}}},
+               {name: "rejects short pulse", modules: []Module{{Model: HostModel, PulseWidths: map[int]time.Duration{0: time.Millisecond}}}},
+               {name: "rejects long pulse", modules: []Module{{Model: HostModel, PulseWidths: map[int]time.Duration{0: 65536 * time.Millisecond}}}},
+               {name: "rejects fractional millisecond", modules: []Module{{Model: HostModel, PulseWidths: map[int]time.Duration{0: 50*time.Millisecond + time.Nanosecond}}}},
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       _, err := newTopology(tc.modules)
+                       require.Error(t, err)
+               })
+       }
+}