From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 22:53:09 +0000 (-0300) Subject: Define independent M31 module address ranges X-Git-Url: https://git.giacomolli.uk/onehouse/commitdiff_plain/9b7392e6965fe1b2e858f63c52113899e4bf3fb4?ds=inline;hp=1f9ad21ee18e1952cdcf3cec07314309e5c7d3d8 Define independent M31 module address ranges 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 --- diff --git a/doc/m31.rst b/doc/m31.rst new file mode 100644 index 0000000..b9b714f --- /dev/null +++ b/doc/m31.rst @@ -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 index 0000000..173aebd --- /dev/null +++ b/driver/m31/module.go @@ -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 index 0000000..8eec21c --- /dev/null +++ b/driver/m31/point.go @@ -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 index 0000000..5c04ed0 --- /dev/null +++ b/driver/m31/topology.go @@ -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 index 0000000..98445fa --- /dev/null +++ b/driver/m31/topology_test.go @@ -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) + }) + } +}