--- /dev/null
+###############
+ 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.
--- /dev/null
+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
+}
--- /dev/null
+package m31
+
+type point struct {
+ id string
+ address uint16
+ pulse uint16
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+ })
+ }
+}