diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..276c87e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Use the `README.md` file as your main reference. \ No newline at end of file diff --git a/tasmota/http.go b/tasmota/http.go index ef2ae50..e4d6dfe 100644 --- a/tasmota/http.go +++ b/tasmota/http.go @@ -7,15 +7,114 @@ import ( "github.com/reef-pi/hal" "io" "net/http" + "sort" "strconv" + "strings" "sync" "time" ) +// parseOutputs parses an output configuration string and returns a sorted slice of output numbers. +// Supported formats: +// - "1" -> [1] +// - "1,2,3" -> [1, 2, 3] +// - "1-3" -> [1, 2, 3] +// - "1-3,5,7-9" -> [1, 2, 3, 5, 7, 8, 9] +// Returns error for invalid formats, negative numbers, duplicates, or reversed ranges. +func parseOutputs(config string) ([]int, error) { + if config == "" { + return nil, errors.New("output configuration cannot be empty") + } + + outputMap := make(map[int]bool) + var outputs []int + + // Split by comma + parts := strings.Split(config, ",") + + for _, part := range parts { + part = strings.TrimSpace(part) + + if strings.Contains(part, "-") { + // Handle range format (e.g., "1-3") + rangeParts := strings.Split(part, "-") + if len(rangeParts) != 2 { + return nil, fmt.Errorf("invalid range format: %s", part) + } + + start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0])) + if err != nil { + return nil, fmt.Errorf("invalid start number in range '%s': %v", part, err) + } + + end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1])) + if err != nil { + return nil, fmt.Errorf("invalid end number in range '%s': %v", part, err) + } + + if start < 0 || end < 0 { + return nil, fmt.Errorf("output numbers must be non-negative, got range '%s'", part) + } + + if start > end { + return nil, fmt.Errorf("invalid range '%s': start (%d) is greater than end (%d)", part, start, end) + } + + for i := start; i <= end; i++ { + if outputMap[i] { + return nil, fmt.Errorf("duplicate output number: %d", i) + } + outputMap[i] = true + outputs = append(outputs, i) + } + } else { + // Handle single number format + num, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid output number '%s': %v", part, err) + } + + if num < 0 { + return nil, fmt.Errorf("output numbers must be non-negative, got %d", num) + } + + if outputMap[num] { + return nil, fmt.Errorf("duplicate output number: %d", num) + } + + outputMap[num] = true + outputs = append(outputs, num) + } + } + + if len(outputs) == 0 { + return nil, errors.New("no valid output numbers found") + } + + // Sort outputs for consistent ordering + sort.Ints(outputs) + + return outputs, nil +} + type httpDriver struct { - meta hal.Metadata - address string - output int + meta hal.Metadata + address string + outputs []int + pins []hal.DigitalOutputPin + channels []hal.PWMChannel +} + +// pinDriver represents a digital output pin on a Tasmota device +type pinDriver struct { + driver *httpDriver + number int +} + +// channelDriver represents a PWM channel on a Tasmota device +type channelDriver struct { + driver *httpDriver + number int } func (m *httpDriver) Close() error { @@ -46,11 +145,14 @@ func (m *httpDriver) Pins(capability hal.Capability) ([]hal.Pin, error) { } func (m *httpDriver) PWMChannels() []hal.PWMChannel { - return []hal.PWMChannel{m} + return m.channels } -func (m *httpDriver) PWMChannel(_ int) (hal.PWMChannel, error) { - return m, nil +func (m *httpDriver) PWMChannel(index int) (hal.PWMChannel, error) { + if index < 0 || index >= len(m.channels) { + return nil, fmt.Errorf("PWM channel index %d out of range (0-%d)", index, len(m.channels)-1) + } + return m.channels[index], nil } func (m *httpDriver) doRequest(url string) (*http.Response, error) { @@ -73,17 +175,60 @@ func (m *httpDriver) readBody(body io.ReadCloser) ([]byte, error) { return msg, nil } -func (m *httpDriver) LastState() bool { + +func (m *httpDriver) DigitalOutputPins() []hal.DigitalOutputPin { + return m.pins +} + +func (m *httpDriver) DigitalOutputPin(index int) (hal.DigitalOutputPin, error) { + if index < 0 || index >= len(m.pins) { + return nil, fmt.Errorf("digital output pin index %d out of range (0-%d)", index, len(m.pins)-1) + } + return m.pins[index], nil +} + +// pinDriver methods + +func (p *pinDriver) Close() error { + return nil +} + +func (p *pinDriver) Name() string { + return "Tasmota" +} + +func (p *pinDriver) Number() int { + return 0 +} + +func (p *pinDriver) Write(b bool) error { + const baseUri = "http://%s/cm?cmnd=Power%d%%20%t" + uri := fmt.Sprintf(baseUri, p.driver.address, p.number, b) + resp, err := p.driver.doRequest(uri) + if err != nil { + return err + } + if resp.StatusCode == 200 { + return nil + } + body, err := p.driver.readBody(resp.Body) + if err != nil { + return err + } + return fmt.Errorf("HTTP Code:%d. Body:%v", resp.StatusCode, string(body)) +} + +func (p *pinDriver) LastState() bool { const urlBase = "http://%s/cm?cmnd=Power%d" - uri := fmt.Sprintf(urlBase, m.address, m.output) - resp, err := m.doRequest(uri) + uri := fmt.Sprintf(urlBase, p.driver.address, p.number) + resp, err := p.driver.doRequest(uri) if err != nil { return false } if resp.StatusCode != 200 { return false } - body, err := m.readBody(resp.Body) + body, err := p.driver.readBody(resp.Body) if err != nil { return false } @@ -93,7 +238,7 @@ func (m *httpDriver) LastState() bool { return false } - if result[fmt.Sprintf("POWER%d", m.output)] == "ON" { + if result[fmt.Sprintf("POWER%d", p.number)] == "ON" { return true } @@ -104,46 +249,83 @@ func (m *httpDriver) LastState() bool { return false } -func (m *httpDriver) Set(value float64) error { +// channelDriver methods + +func (c *channelDriver) Close() error { + return nil +} + +func (c *channelDriver) Name() string { + return "Tasmota" +} + +func (c *channelDriver) Number() int { + return 0 +} + +func (c *channelDriver) Set(value float64) error { const urlBase = "http://%s/cm?cmnd=Dimmer%%20%.0f" - uri := fmt.Sprintf(urlBase, m.address, value) - resp, err := m.doRequest(uri) + uri := fmt.Sprintf(urlBase, c.driver.address, value) + resp, err := c.driver.doRequest(uri) if err != nil { return err } if resp.StatusCode == 200 { return nil } - body, err := m.readBody(resp.Body) + body, err := c.driver.readBody(resp.Body) if err != nil { return err } return fmt.Errorf("HTTP Code:%d. Body:%v", resp.StatusCode, string(body)) } -func (m *httpDriver) Write(b bool) error { +func (c *channelDriver) Write(b bool) error { const baseUri = "http://%s/cm?cmnd=Power%d%%20%t" - uri := fmt.Sprintf(baseUri, m.address, m.output, b) - resp, err := m.doRequest(uri) + uri := fmt.Sprintf(baseUri, c.driver.address, c.number, b) + resp, err := c.driver.doRequest(uri) if err != nil { return err } if resp.StatusCode == 200 { return nil } - body, err := m.readBody(resp.Body) + body, err := c.driver.readBody(resp.Body) if err != nil { return err } return fmt.Errorf("HTTP Code:%d. Body:%v", resp.StatusCode, string(body)) } -func (m *httpDriver) DigitalOutputPins() []hal.DigitalOutputPin { - return []hal.DigitalOutputPin{m} -} +func (c *channelDriver) LastState() bool { + const urlBase = "http://%s/cm?cmnd=Power%d" + uri := fmt.Sprintf(urlBase, c.driver.address, c.number) + resp, err := c.driver.doRequest(uri) + if err != nil { + return false + } + if resp.StatusCode != 200 { + return false + } + body, err := c.driver.readBody(resp.Body) + if err != nil { + return false + } + var result map[string]interface{} + err = json.Unmarshal(body, &result) + if err != nil { + return false + } + + if result[fmt.Sprintf("POWER%d", c.number)] == "ON" { + return true + } -func (m *httpDriver) DigitalOutputPin(_ int) (hal.DigitalOutputPin, error) { - return m, nil + if result["POWER"] == "ON" { + return true + } + + return false } type factory struct { @@ -211,14 +393,25 @@ func (f *factory) ValidateParameters(parameters map[string]interface{}) (bool, m } if v, ok := parameters[output]; ok { - val, ok := v.(int) - if !ok { - failure := fmt.Sprint(output, " is not an integer. ", v, " was received.") + // Accept both string and integer for backward compatibility + var outputConfig string + switch val := v.(type) { + case string: + outputConfig = val + case int: + outputConfig = strconv.Itoa(val) + default: + failure := fmt.Sprint(output, " must be string or integer. ", v, " was received.") failures[output] = append(failures[output], failure) + } - } else if val < 0 { - failure := fmt.Sprint(output, " value should be greater than 0. ", val, " was received.") - failures[output] = append(failures[output], failure) + if len(outputConfig) > 0 { + // Validate the output configuration + _, err := parseOutputs(outputConfig) + if err != nil { + failure := fmt.Sprint(output, " configuration is invalid: ", err.Error()) + failures[output] = append(failures[output], failure) + } } } else { failure := fmt.Sprint(output, " is a required parameter, but was not received.") @@ -234,22 +427,50 @@ func (f *factory) Metadata() hal.Metadata { func (f *factory) NewDriver(parameters map[string]interface{}, hardwareResources interface{}) (hal.Driver, error) { if parameters[output] == nil { - parameters[output] = "0" + parameters[output] = "1" } - if outputStr, ok := parameters[output].(string); ok { - if outputInt, err := strconv.Atoi(outputStr); err == nil { - parameters[output] = outputInt - } + // Convert output to string for consistent processing + var outputStr string + if outputInt, ok := parameters[output].(int); ok { + outputStr = strconv.Itoa(outputInt) + } else if str, ok := parameters[output].(string); ok { + outputStr = str + } else { + return nil, fmt.Errorf("output must be int or string, got %T", parameters[output]) } + parameters[output] = outputStr if valid, failures := f.ValidateParameters(parameters); !valid { return nil, errors.New(hal.ToErrorString(failures)) } + + // Parse output configuration + outputs, err := parseOutputs(outputStr) + if err != nil { + return nil, fmt.Errorf("invalid output configuration: %v", err) + } + + // Create driver driver := &httpDriver{ - meta: f.meta, - address: parameters[address].(string), - output: parameters[output].(int), + meta: f.meta, + address: parameters[address].(string), + outputs: outputs, + pins: []hal.DigitalOutputPin{}, + channels: []hal.PWMChannel{}, } + + // Create pin and channel objects for each output + for _, outNum := range outputs { + driver.pins = append(driver.pins, &pinDriver{ + driver: driver, + number: outNum, + }) + driver.channels = append(driver.channels, &channelDriver{ + driver: driver, + number: outNum, + }) + } + return driver, nil } diff --git a/tasmota/http_test.go b/tasmota/http_test.go index 5fdfe9a..4298e1f 100644 --- a/tasmota/http_test.go +++ b/tasmota/http_test.go @@ -1,18 +1,132 @@ package tasmota import ( + "encoding/json" + "fmt" "github.com/reef-pi/hal" - "os" + "net/http" + "net/http/httptest" "testing" ) +// mockTasmotaServer creates a mock Tasmota device server for testing +func mockTasmotaServer(t *testing.T) *httptest.Server { + // In-memory store for device state + powerStates := make(map[int]bool) + dimmerStates := make(map[int]float64) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cmnd := r.URL.Query().Get("cmnd") + if cmnd == "" { + http.Error(w, "Missing cmnd parameter", http.StatusBadRequest) + return + } + + // Parse command - format: "Command value" or "Command value" + response := make(map[string]interface{}) + + // Handle Power commands + if len(cmnd) >= 5 && cmnd[:5] == "Power" { + // Extract output number and value + var outputNum int + var value string + + // Check if it's Power or just Power + if len(cmnd) > 5 && cmnd[5] >= '0' && cmnd[5] <= '9' { + // Power format + fmt.Sscanf(cmnd[5:], "%d", &outputNum) + // Find where the number ends + i := 5 + for i < len(cmnd) && cmnd[i] >= '0' && cmnd[i] <= '9' { + i++ + } + if i < len(cmnd) && cmnd[i] == ' ' { + value = cmnd[i+1:] + } + } else if len(cmnd) > 6 && cmnd[5] == ' ' { + // Power value format + outputNum = 1 + value = cmnd[6:] + } else { + // Query format + outputNum = 1 + value = "" + } + + // Handle Power command + if value == "" { + // Query + state, ok := powerStates[outputNum] + if !ok { + state = false + } + stateStr := "OFF" + if state { + stateStr = "ON" + } + if outputNum == 0 || outputNum == 1 { + response["POWER"] = stateStr + } + if outputNum != 1 { + response[fmt.Sprintf("POWER%d", outputNum)] = stateStr + } + } else if value == "1" || value == "ON" || value == "on" || value == "true" || value == "True" { + powerStates[outputNum] = true + stateStr := "ON" + if outputNum == 0 || outputNum == 1 { + response["POWER"] = stateStr + } + if outputNum != 1 { + response[fmt.Sprintf("POWER%d", outputNum)] = stateStr + } + } else if value == "0" || value == "OFF" || value == "off" || value == "false" || value == "False" { + powerStates[outputNum] = false + stateStr := "OFF" + if outputNum == 0 || outputNum == 1 { + response["POWER"] = stateStr + } + if outputNum != 1 { + response[fmt.Sprintf("POWER%d", outputNum)] = stateStr + } + } + } else if len(cmnd) >= 6 && cmnd[:6] == "Dimmer" { + // Handle Dimmer command + var value float64 + if len(cmnd) > 7 && cmnd[6] == ' ' { + fmt.Sscanf(cmnd[7:], "%f", &value) + dimmerStates[0] = value + if value > 0 { + powerStates[0] = true + response["POWER"] = "ON" + } else { + powerStates[0] = false + response["POWER"] = "OFF" + } + response["Dimmer"] = int(value) + } else { + // Query + value, ok := dimmerStates[0] + if !ok { + value = 0 + } + response["Dimmer"] = int(value) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + + return server +} + func TestHttpDriver_AsDigitalOut(t *testing.T) { - address := os.Getenv("TASMOTA_TEST_ADDRESS") + server := mockTasmotaServer(t) + defer server.Close() - if len(address) == 0 { - address = "192.168.1.46" - } + // Extract host:port from server URL + address := server.URL[7:] // Remove "http://" f := HttpDriverFactory() @@ -28,7 +142,7 @@ func TestHttpDriver_AsDigitalOut(t *testing.T) { meta := d.Metadata() if len(meta.Capabilities) != 2 { - t.Error("Expected 1 capabilities, found:", len(meta.Capabilities)) + t.Error("Expected 2 capabilities, found:", len(meta.Capabilities)) } o, ok := d.(hal.DigitalOutputDriver) @@ -37,7 +151,7 @@ func TestHttpDriver_AsDigitalOut(t *testing.T) { } if len(o.DigitalOutputPins()) != 1 { - t.Error("Expected a single digital output pwm pin, found:", len(o.DigitalOutputPins())) + t.Error("Expected a single digital output pin, found:", len(o.DigitalOutputPins())) } p, err := o.DigitalOutputPin(0) @@ -53,38 +167,34 @@ func TestHttpDriver_AsDigitalOut(t *testing.T) { t.Error("Expected number 0, found: ", p.Number()) } - testRealDevice := os.Getenv("TASMOTA_TEST_REAL_DEVICE") - - if testRealDevice == "True" { - - err = p.Write(true) - if err != nil { - t.Error("Expected write true inn the digital output, error: ", err.Error()) - } + // Test with mock server + err = p.Write(true) + if err != nil { + t.Error("Expected write true in the digital output, error: ", err.Error()) + } - if !p.LastState() { - t.Error("Expected last state is true") - } + if !p.LastState() { + t.Error("Expected last state is true") + } - err = p.Write(false) - if err != nil { - t.Error("Expected write false inn the digital output, error: ", err.Error()) - } + err = p.Write(false) + if err != nil { + t.Error("Expected write false in the digital output, error: ", err.Error()) + } - if p.LastState() { - t.Error("Expected last state is false") - } + if p.LastState() { + t.Error("Expected last state is false") } } func TestHttpDriver_AsPWMDriver(t *testing.T) { - address := os.Getenv("TASMOTA_TEST_ADDRESS") + server := mockTasmotaServer(t) + defer server.Close() - if len(address) == 0 { - address = "192.168.1.46" - } + // Extract host:port from server URL + address := server.URL[7:] // Remove "http://" f := HttpDriverFactory() @@ -100,7 +210,7 @@ func TestHttpDriver_AsPWMDriver(t *testing.T) { meta := d.Metadata() if len(meta.Capabilities) != 2 { - t.Error("Expected 1 capabilities, found:", len(meta.Capabilities)) + t.Error("Expected 2 capabilities, found:", len(meta.Capabilities)) } pwm, ok := d.(hal.PWMDriver) @@ -125,29 +235,123 @@ func TestHttpDriver_AsPWMDriver(t *testing.T) { t.Error("Expected number 0, found: ", p.Number()) } - testRealDevice := os.Getenv("TASMOTA_TEST_REAL_DEVICE") + // Test with mock server + err = p.Set(100) + if err != nil { + t.Error("Expected to set 100 in the pwm output, error: ", err.Error()) + } - if testRealDevice == "True" { + if !p.LastState() { + t.Error("Expected last state is true") + } - err = p.Set(100) - if err != nil { - t.Error("Expected to set 100 in the pwm output, error: ", err.Error()) - } + err = p.Set(0) + if err != nil { + t.Error("Expected to set 0 in the pwm output, error: ", err.Error()) + } - if !p.LastState() { - t.Error("Expected last state is true") - } + if p.LastState() { + t.Error("Expected last state is false") + } - err = p.Set(0) - if err != nil { - t.Error("Expected to set 0 in the pwm output, error: ", err.Error()) - } +} + +func TestParseOutputs_SingleOutput(t *testing.T) { + outputs, err := parseOutputs("1") + if err != nil { + t.Fatal("Unexpected error:", err) + } + if len(outputs) != 1 || outputs[0] != 1 { + t.Errorf("Expected [1], got %v", outputs) + } +} + +func TestParseOutputs_DiscreteOutputs(t *testing.T) { + outputs, err := parseOutputs("1,2,3") + if err != nil { + t.Fatal("Unexpected error:", err) + } + if len(outputs) != 3 || outputs[0] != 1 || outputs[1] != 2 || outputs[2] != 3 { + t.Errorf("Expected [1, 2, 3], got %v", outputs) + } +} - if p.LastState() { - t.Error("Expected last state is false") +func TestParseOutputs_Range(t *testing.T) { + outputs, err := parseOutputs("1-3") + if err != nil { + t.Fatal("Unexpected error:", err) + } + if len(outputs) != 3 || outputs[0] != 1 || outputs[1] != 2 || outputs[2] != 3 { + t.Errorf("Expected [1, 2, 3], got %v", outputs) + } +} + +func TestParseOutputs_Mixed(t *testing.T) { + outputs, err := parseOutputs("1-3,5,7-9") + if err != nil { + t.Fatal("Unexpected error:", err) + } + expected := []int{1, 2, 3, 5, 7, 8, 9} + if len(outputs) != len(expected) { + t.Errorf("Expected length %d, got %d", len(expected), len(outputs)) + } + for i, v := range expected { + if outputs[i] != v { + t.Errorf("Expected outputs[%d]=%d, got %d", i, v, outputs[i]) } } +} +func TestParseOutputs_EmptyString(t *testing.T) { + _, err := parseOutputs("") + if err == nil { + t.Error("Expected error for empty string") + } +} + +func TestParseOutputs_Duplicates(t *testing.T) { + _, err := parseOutputs("1,1") + if err == nil { + t.Error("Expected error for duplicate output") + } +} + +func TestParseOutputs_DuplicatesInRange(t *testing.T) { + _, err := parseOutputs("1-3,2") + if err == nil { + t.Error("Expected error for duplicate output in range") + } +} + +func TestParseOutputs_ReversedRange(t *testing.T) { + _, err := parseOutputs("3-1") + if err == nil { + t.Error("Expected error for reversed range") + } +} + +func TestParseOutputs_NegativeNumbers(t *testing.T) { + _, err := parseOutputs("-1") + if err == nil { + t.Error("Expected error for negative number") + } +} + +func TestParseOutputs_InvalidFormat(t *testing.T) { + _, err := parseOutputs("abc") + if err == nil { + t.Error("Expected error for invalid format") + } +} + +func TestParseOutputs_Sorted(t *testing.T) { + outputs, err := parseOutputs("3,1,2") + if err != nil { + t.Fatal("Unexpected error:", err) + } + if outputs[0] != 1 || outputs[1] != 2 || outputs[2] != 3 { + t.Errorf("Expected sorted [1, 2, 3], got %v", outputs) + } } func TestHttpDriver_FactoryValidateParameters(t *testing.T) { @@ -191,3 +395,601 @@ func TestHttpDriver_FactoryValidateParameters(t *testing.T) { } } + +func TestHttpDriver_MultiOutput_DiscreteOutputs(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] // Remove "http://" + + f := HttpDriverFactory() + + params := map[string]interface{}{ + "Address": address, + "Output": "1,2,3", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver:", err) + } + + // Check digital output pins + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + if len(dout.DigitalOutputPins()) != 3 { + t.Errorf("Expected 3 digital output pins, got %d", len(dout.DigitalOutputPins())) + } + + // Test each pin + for i := 0; i < 3; i++ { + pin, err := dout.DigitalOutputPin(i) + if err != nil { + t.Errorf("Failed to get pin %d: %v", i, err) + } + + // Test Write + err = pin.Write(true) + if err != nil { + t.Errorf("Pin %d: Failed to write true: %v", i, err) + } + + state := pin.LastState() + if !state { + t.Errorf("Pin %d: Expected LastState true, got false", i) + } + + // Test Write false + err = pin.Write(false) + if err != nil { + t.Errorf("Pin %d: Failed to write false: %v", i, err) + } + + state = pin.LastState() + if state { + t.Errorf("Pin %d: Expected LastState false, got true", i) + } + } + + // Check PWM channels + pwm, ok := d.(hal.PWMDriver) + if !ok { + t.Fatal("Failed to type to PWMDriver") + } + + if len(pwm.PWMChannels()) != 3 { + t.Errorf("Expected 3 PWM channels, got %d", len(pwm.PWMChannels())) + } + + // Test each channel - note: Dimmer is a global Tasmota command + for i := 0; i < 3; i++ { + ch, err := pwm.PWMChannel(i) + if err != nil { + t.Errorf("Failed to get channel %d: %v", i, err) + } + + // Test Set - verify no errors + err = ch.Set(100) + if err != nil { + t.Errorf("Channel %d: Failed to set 100: %v", i, err) + } + + // Test Set to 0 + err = ch.Set(0) + if err != nil { + t.Errorf("Channel %d: Failed to set 0: %v", i, err) + } + } +} + +func TestHttpDriver_MultiOutput_Range(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] // Remove "http://" + + f := HttpDriverFactory() + + params := map[string]interface{}{ + "Address": address, + "Output": "1-3", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver:", err) + } + + // Check digital output pins + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + pins := dout.DigitalOutputPins() + if len(pins) != 3 { + t.Errorf("Expected 3 digital output pins from range 1-3, got %d", len(pins)) + } + + // Test that each pin can be controlled independently + for i := 0; i < 3; i++ { + pin, err := dout.DigitalOutputPin(i) + if err != nil { + t.Errorf("Failed to get pin %d: %v", i, err) + } + + err = pin.Write(true) + if err != nil { + t.Errorf("Pin %d: Failed to write: %v", i, err) + } + + if !pin.LastState() { + t.Errorf("Pin %d: Expected state true, got false", i) + } + } +} + +func TestHttpDriver_MultiOutput_OutOfBounds(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] // Remove "http://" + + f := HttpDriverFactory() + + params := map[string]interface{}{ + "Address": address, + "Output": "1,2", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + // Try to access out-of-bounds pin + _, err = dout.DigitalOutputPin(5) + if err == nil { + t.Error("Expected error for out-of-bounds pin access") + } + + pwm, ok := d.(hal.PWMDriver) + if !ok { + t.Fatal("Failed to type to PWMDriver") + } + + // Try to access out-of-bounds channel + _, err = pwm.PWMChannel(5) + if err == nil { + t.Error("Expected error for out-of-bounds channel access") + } +} + +func TestHttpDriver_BackwardCompatibility_SingleIntegerOutput(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] // Remove "http://" + + f := HttpDriverFactory() + + // Test with integer output (old format) + params := map[string]interface{}{ + "Address": address, + "Output": 1, + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver with integer output:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + if len(dout.DigitalOutputPins()) != 1 { + t.Errorf("Expected 1 pin, got %d", len(dout.DigitalOutputPins())) + } + + pin, err := dout.DigitalOutputPin(0) + if err != nil { + t.Fatal("Failed to get pin:", err) + } + + err = pin.Write(true) + if err != nil { + t.Fatal("Failed to write:", err) + } + + if !pin.LastState() { + t.Error("Expected state true") + } +} + +func TestValidation_ValidOutputFormats(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] + f := HttpDriverFactory() + + testCases := []struct { + name string + output interface{} + }{ + {"single digit", 1}, + {"single string", "1"}, + {"discrete outputs", "1,2,3"}, + {"range outputs", "1-5"}, + {"mixed format", "1-3,5,7-9"}, + } + + for _, tc := range testCases { + params := map[string]interface{}{ + "Address": address, + "Output": tc.output, + } + + _, err := f.NewDriver(params, nil) + if err != nil { + t.Errorf("Output format '%v' (%s): unexpected error: %v", tc.output, tc.name, err) + } + } +} + +func TestValidation_InvalidOutputConfigs(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] + f := HttpDriverFactory() + + testCases := []struct { + name string + output interface{} + }{ + {"empty string", ""}, + {"negative number", -1}, + {"duplicate outputs", "1,1,2"}, + {"reversed range", "5-1"}, + {"invalid format", "abc"}, + {"invalid range", "1-a"}, + } + + for _, tc := range testCases { + params := map[string]interface{}{ + "Address": address, + "Output": tc.output, + } + + _, err := f.NewDriver(params, nil) + if err == nil { + t.Errorf("Output '%v' (%s): expected error but got none", tc.output, tc.name) + } + } +} + +func TestValidation_MissingAddress(t *testing.T) { + f := HttpDriverFactory() + + params := map[string]interface{}{ + "Output": "1", + } + + _, err := f.NewDriver(params, nil) + if err == nil { + t.Error("Expected error for missing address") + } +} + +func TestValidation_EmptyAddress(t *testing.T) { + f := HttpDriverFactory() + + params := map[string]interface{}{ + "Address": "", + "Output": "1", + } + + _, err := f.NewDriver(params, nil) + if err == nil { + t.Error("Expected error for empty address") + } +} + +func TestValidation_InvalidAddressType(t *testing.T) { + f := HttpDriverFactory() + + params := map[string]interface{}{ + "Address": 12345, + "Output": "1", + } + + _, err := f.NewDriver(params, nil) + if err == nil { + t.Error("Expected error for non-string address") + } +} + +func TestValidation_MissingOutput(t *testing.T) { + f := HttpDriverFactory() + + params := map[string]interface{}{ + "Address": "192.168.1.1", + } + + // Should not error - Output should default to "1" + d, err := f.NewDriver(params, nil) + if err != nil { + t.Errorf("Expected no error with default output, got: %v", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + if len(dout.DigitalOutputPins()) != 1 { + t.Errorf("Expected 1 pin with default output, got %d", len(dout.DigitalOutputPins())) + } +} + +// mockErrorTasmotaServer creates a mock Tasmota server that returns errors +func mockErrorTasmotaServer(t *testing.T, statusCode int) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + w.Write([]byte("Error")) + })) + return server +} + +// mockMalformedJsonServer creates a mock server that returns malformed JSON +func mockMalformedJsonServer(t *testing.T) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{invalid json")) + })) + return server +} + +func TestEdgeCase_SingleOutput_ZeroIndex(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] + + f := HttpDriverFactory() + params := map[string]interface{}{ + "Address": address, + "Output": "0", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver with output 0:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + if len(dout.DigitalOutputPins()) != 1 { + t.Errorf("Expected 1 pin for output 0, got %d", len(dout.DigitalOutputPins())) + } + + pin, err := dout.DigitalOutputPin(0) + if err != nil { + t.Fatal("Failed to get pin:", err) + } + + err = pin.Write(true) + if err != nil { + t.Fatal("Failed to write:", err) + } +} + +func TestEdgeCase_LargeOutputNumber(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] + + f := HttpDriverFactory() + params := map[string]interface{}{ + "Address": address, + "Output": "32", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver with output 32:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + if len(dout.DigitalOutputPins()) != 1 { + t.Errorf("Expected 1 pin for output 32, got %d", len(dout.DigitalOutputPins())) + } +} + +func TestEdgeCase_WideOutputRange(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] + + f := HttpDriverFactory() + params := map[string]interface{}{ + "Address": address, + "Output": "1-10", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver with range 1-10:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + if len(dout.DigitalOutputPins()) != 10 { + t.Errorf("Expected 10 pins for range 1-10, got %d", len(dout.DigitalOutputPins())) + } +} + +func TestErrorScenario_HTTPError500(t *testing.T) { + server := mockErrorTasmotaServer(t, 500) + defer server.Close() + + address := server.URL[7:] + + f := HttpDriverFactory() + params := map[string]interface{}{ + "Address": address, + "Output": "1", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + pin, err := dout.DigitalOutputPin(0) + if err != nil { + t.Fatal("Failed to get pin:", err) + } + + err = pin.Write(true) + if err == nil { + t.Error("Expected error on HTTP 500, got nil") + } +} + +func TestErrorScenario_MalformedJSON(t *testing.T) { + server := mockMalformedJsonServer(t) + defer server.Close() + + address := server.URL[7:] + + f := HttpDriverFactory() + params := map[string]interface{}{ + "Address": address, + "Output": "1", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + pin, err := dout.DigitalOutputPin(0) + if err != nil { + t.Fatal("Failed to get pin:", err) + } + + // LastState should return false on malformed JSON + state := pin.LastState() + if state { + t.Error("Expected LastState to return false on malformed JSON") + } +} + +func TestEdgeCase_MultipleOutputsConsistentState(t *testing.T) { + server := mockTasmotaServer(t) + defer server.Close() + + address := server.URL[7:] + + f := HttpDriverFactory() + params := map[string]interface{}{ + "Address": address, + "Output": "1,2,3", + } + + d, err := f.NewDriver(params, nil) + if err != nil { + t.Fatal("Failed to create driver:", err) + } + + dout, ok := d.(hal.DigitalOutputDriver) + if !ok { + t.Fatal("Failed to type to DigitalOutputDriver") + } + + // Set all outputs to true + for i := 0; i < 3; i++ { + pin, err := dout.DigitalOutputPin(i) + if err != nil { + t.Fatalf("Failed to get pin %d: %v", i, err) + } + + err = pin.Write(true) + if err != nil { + t.Fatalf("Pin %d: Failed to write true: %v", i, err) + } + } + + // Verify all are true + for i := 0; i < 3; i++ { + pin, err := dout.DigitalOutputPin(i) + if err != nil { + t.Fatalf("Failed to get pin %d: %v", i, err) + } + + state := pin.LastState() + if !state { + t.Errorf("Pin %d: Expected state true, got false", i) + } + } + + // Set all to false + for i := 0; i < 3; i++ { + pin, err := dout.DigitalOutputPin(i) + if err != nil { + t.Fatalf("Failed to get pin %d: %v", i, err) + } + + err = pin.Write(false) + if err != nil { + t.Fatalf("Pin %d: Failed to write false: %v", i, err) + } + } + + // Verify all are false + for i := 0; i < 3; i++ { + pin, err := dout.DigitalOutputPin(i) + if err != nil { + t.Fatalf("Failed to get pin %d: %v", i, err) + } + + state := pin.LastState() + if state { + t.Errorf("Pin %d: Expected state false, got true", i) + } + } +}