From 1a953af65ca5b3a83e7a50407a0c480ab22cc924 Mon Sep 17 00:00:00 2001 From: xengineering Date: Sat, 21 Mar 2026 16:06:11 +0100 Subject: tools: websocket: Add dummy and build integration Adding support for Shelly devices requires usage of the Websocket API. To make development easier a debug tool is created. Since it is not relevant for users it is not an artefact included into the deploy file archive. This also avoids later removing it which is by definition of the public API (see README.md) a breaking change. --- meson.build | 7 ++++--- tools/meson.build | 15 +++++++++++++++ tools/websocket.go | 9 +++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 tools/meson.build create mode 100644 tools/websocket.go diff --git a/meson.build b/meson.build index fb1ec5d..4bc72bd 100644 --- a/meson.build +++ b/meson.build @@ -3,12 +3,13 @@ project( version : '0.1.0-dev', ) -subdir('configs') -subdir('systemd') - go = find_program('go', required : true) tar = find_program('tar', required : true) +subdir('configs') +subdir('systemd') +subdir('tools') + fs = import('fs') readme = fs.copyfile(meson.current_source_dir() / 'README.md') diff --git a/tools/meson.build b/tools/meson.build new file mode 100644 index 0000000..1322ce0 --- /dev/null +++ b/tools/meson.build @@ -0,0 +1,15 @@ +websocket_linux_amd64 = custom_target( + input : [ + meson.current_source_dir() / 'websocket.go', + ], + output : 'websocket-linux-amd64', + env : {'GOOS': 'linux', 'GOARCH': 'amd64'}, + command : [ + go, + 'build', + '-o', + '@OUTPUT@', + '@INPUT@', + ], + build_by_default : true, +) diff --git a/tools/websocket.go b/tools/websocket.go new file mode 100644 index 0000000..e429a6d --- /dev/null +++ b/tools/websocket.go @@ -0,0 +1,9 @@ +package main + +import ( + "fmt" +) + +func main() { + fmt.Println("Websockets are cool.") +} -- cgit v1.3 From c47c0736bceff60de31c0afc7005c51c5bf1daa6 Mon Sep 17 00:00:00 2001 From: xengineering Date: Sat, 21 Mar 2026 16:38:56 +0100 Subject: tools: websocket: Add connection set up / tear down This was validated by sniffing the communication with Wireshark. The following is executed: - TCP initial handshake - GET /rpc from tool - HTTP 101 Switching Protocols (to Websocket) from Shelly - ACK by tool - TCP connection close initiated by tool This shows that the tool is able to make Websocket connections. --- tools/websocket.go | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tools/websocket.go b/tools/websocket.go index e429a6d..575bcd5 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -1,9 +1,47 @@ +// Websocket debug tool +// +// Usage: ./websocket-linux-amd64 ws:///rpc +// +// This tools is intended to support development of the Websocket-based +// application programming interface (API) of the Shelly Internet of Things +// (IoT) devices. + package main import ( - "fmt" + "log" + "net/url" + "os" + "os/signal" + + "github.com/gorilla/websocket" ) func main() { - fmt.Println("Websockets are cool.") + log.SetFlags(0) + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + var u url.URL = getURL() + log.Printf("connecting to %s", u.String()) + + c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if (err != nil) { + log.Fatal(err) + } + defer c.Close() +} + +func getURL() url.URL { + if (len(os.Args) != 2) { + log.Fatalf("Exactly one argument expected but got %d.", len(os.Args) - 1) + } + + maybeURL, err := url.Parse(os.Args[1]) + if (err != nil) { + log.Fatalf("Cannot parse given URL: %s", os.Args[1]) + } + + return *maybeURL } -- cgit v1.3 From c68b6988d6a6e18efc73d2a979ab427c20ef2108 Mon Sep 17 00:00:00 2001 From: xengineering Date: Sat, 21 Mar 2026 17:02:00 +0100 Subject: tools: websocket: Add wait for CTRL-C Now the connection is immediately established but just closed on SIGTERM and SIGINT. This allows to keep the connection for some time and lets the user decide when to stop. --- tools/websocket.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tools/websocket.go b/tools/websocket.go index 575bcd5..fc76d56 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "os/signal" + "syscall" "github.com/gorilla/websocket" ) @@ -31,6 +32,8 @@ func main() { log.Fatal(err) } defer c.Close() + + Await(syscall.SIGTERM, syscall.SIGINT) } func getURL() url.URL { @@ -45,3 +48,12 @@ func getURL() url.URL { return *maybeURL } + +func Await(signals ...os.Signal) { + listener := make(chan os.Signal, 1) + signal.Notify(listener, signals...) + defer signal.Stop(listener) + + sig := <-listener + log.Printf("Received OS signal '%v'\n", sig) +} -- cgit v1.3 From 8ee2df7b5f85b2914c074bf07da5557d5dbe4ebf Mon Sep 17 00:00:00 2001 From: xengineering Date: Sat, 21 Mar 2026 17:09:37 +0100 Subject: tools: websocket: Remove needless braces Programming in too many languages ... of course not needed in Go. --- tools/websocket.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/websocket.go b/tools/websocket.go index fc76d56..ea2ea86 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -28,7 +28,7 @@ func main() { log.Printf("connecting to %s", u.String()) c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) - if (err != nil) { + if err != nil { log.Fatal(err) } defer c.Close() @@ -37,12 +37,12 @@ func main() { } func getURL() url.URL { - if (len(os.Args) != 2) { + if len(os.Args) != 2 { log.Fatalf("Exactly one argument expected but got %d.", len(os.Args) - 1) } maybeURL, err := url.Parse(os.Args[1]) - if (err != nil) { + if err != nil { log.Fatalf("Cannot parse given URL: %s", os.Args[1]) } -- cgit v1.3 From 9ef837ba3816c57dccc35c39318824851896ff11 Mon Sep 17 00:00:00 2001 From: xengineering Date: Sun, 22 Mar 2026 09:54:32 +0100 Subject: tools: websocket: Implement Sys.GenConfig This is the first working command for the Shelly 2PM Gen3. --- tools/websocket.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/websocket.go b/tools/websocket.go index ea2ea86..c882ce2 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -33,6 +33,19 @@ func main() { } defer c.Close() + go func() { + for { + _, message, err := c.ReadMessage() + if err != nil { + log.Println("read:", err) + return + } + log.Printf("recv: %s", message) + } + }() + + getConfig(c) + Await(syscall.SIGTERM, syscall.SIGINT) } @@ -57,3 +70,21 @@ func Await(signals ...os.Signal) { sig := <-listener log.Printf("Received OS signal '%v'\n", sig) } + +func getConfig(c *websocket.Conn) { + request := ` +{ + "jsonrpc":"2.0", + "id": 1, + "src":"user_1", + "method":"Sys.GetConfig", + "params": { + "id":2 + } +} +` + err := c.WriteMessage(websocket.TextMessage, []byte(request)) + if err != nil { + log.Fatal(err) + } +} -- cgit v1.3 From f4cdb605863c883f77ff3a6dab6a5ead8930b0fc Mon Sep 17 00:00:00 2001 From: xengineering Date: Sun, 22 Mar 2026 10:12:18 +0100 Subject: tools: websocket: Log pretty-printed TX JSON This allows to more easily see what is sent to the Shelly device. --- tools/websocket.go | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tools/websocket.go b/tools/websocket.go index c882ce2..b956ee8 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -9,10 +9,12 @@ package main import ( + "encoding/json" "log" "net/url" "os" "os/signal" + "strings" "syscall" "github.com/gorilla/websocket" @@ -83,8 +85,38 @@ func getConfig(c *websocket.Conn) { } } ` - err := c.WriteMessage(websocket.TextMessage, []byte(request)) + + tx(c, request) +} + +func tx(c *websocket.Conn, d string) { + input := []byte(d) + + var parsed any + + err := json.Unmarshal(input, &parsed) + if err != nil { + log.Fatal(err) + } + + pretty, err := json.MarshalIndent(parsed, "", " ") if err != nil { log.Fatal(err) } + + log.Println(quote(string(pretty), "> ")) + err = c.WriteMessage(websocket.TextMessage, pretty) + if err != nil { + log.Fatal(err) + } +} + +func quote(input string, quotation string) string { + lines := strings.Split(input, "\n") + + for i, line := range lines { + lines[i] = quotation + line + } + + return strings.Join(lines, "\n") } -- cgit v1.3 From 5bae36f4ccee5e699969770948a02f70e8fed9b0 Mon Sep 17 00:00:00 2001 From: xengineering Date: Sun, 22 Mar 2026 10:21:36 +0100 Subject: tools: websocket: Quote and prettify RX JSON This makes the responses from the Shelly device readable. --- tools/websocket.go | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/tools/websocket.go b/tools/websocket.go index b956ee8..31c39fb 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -35,16 +35,7 @@ func main() { } defer c.Close() - go func() { - for { - _, message, err := c.ReadMessage() - if err != nil { - log.Println("read:", err) - return - } - log.Printf("recv: %s", message) - } - }() + go rx(c) getConfig(c) @@ -89,26 +80,40 @@ func getConfig(c *websocket.Conn) { tx(c, request) } -func tx(c *websocket.Conn, d string) { - input := []byte(d) +func rx(c *websocket.Conn) { + for { + _, message, err := c.ReadMessage() + if err != nil { + log.Println("read:", err) + return + } + log.Println(quote(prettify(string(message)), "< ")) + } +} - var parsed any +func tx(c *websocket.Conn, d string) { + log.Println(quote(prettify(d), "> ")) - err := json.Unmarshal(input, &parsed) + err := c.WriteMessage(websocket.TextMessage, []byte(d)) if err != nil { log.Fatal(err) } +} - pretty, err := json.MarshalIndent(parsed, "", " ") +func prettify(input string) string { + var parsed any + + err := json.Unmarshal([]byte(input), &parsed) if err != nil { log.Fatal(err) } - log.Println(quote(string(pretty), "> ")) - err = c.WriteMessage(websocket.TextMessage, pretty) + pretty, err := json.MarshalIndent(parsed, "", " ") if err != nil { log.Fatal(err) } + + return string(pretty) } func quote(input string, quotation string) string { -- cgit v1.3 From 10ef120da246e0e0eb2245e160792ed29e623a30 Mon Sep 17 00:00:00 2001 From: xengineering Date: Sun, 22 Mar 2026 10:26:42 +0100 Subject: tools: websocket: Add empty line before RX log This makes it easier to distinguish the JSON messages visually. --- tools/websocket.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/websocket.go b/tools/websocket.go index 31c39fb..594cb1b 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -87,6 +87,7 @@ func rx(c *websocket.Conn) { log.Println("read:", err) return } + log.Println("") log.Println(quote(prettify(string(message)), "< ")) } } -- cgit v1.3 From 589425d307cd7d06d3f7d052f6ea1a5245688d0d Mon Sep 17 00:00:00 2001 From: xengineering Date: Sun, 22 Mar 2026 10:44:27 +0100 Subject: tools: websocket: Implement cover close / open This implements the minimal functionality to control covers with the Sia server in the development tool. Based on that the actual Sia server software can be adapted. --- tools/websocket.go | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/tools/websocket.go b/tools/websocket.go index 594cb1b..af23959 100644 --- a/tools/websocket.go +++ b/tools/websocket.go @@ -16,6 +16,7 @@ import ( "os/signal" "strings" "syscall" + "time" "github.com/gorilla/websocket" ) @@ -38,6 +39,9 @@ func main() { go rx(c) getConfig(c) + coverClose(c) + time.Sleep(1 * time.Second) + coverOpen(c) Await(syscall.SIGTERM, syscall.SIGINT) } @@ -65,7 +69,7 @@ func Await(signals ...os.Signal) { } func getConfig(c *websocket.Conn) { - request := ` + tx(c, ` { "jsonrpc":"2.0", "id": 1, @@ -75,9 +79,35 @@ func getConfig(c *websocket.Conn) { "id":2 } } -` +`) +} - tx(c, request) +func coverClose(c *websocket.Conn) { + tx(c, ` +{ + "jsonrpc":"2.0", + "id": 1, + "src":"user_1", + "method":"Cover.Close", + "params": { + "id":0 + } +} +`) +} + +func coverOpen(c *websocket.Conn) { + tx(c, ` +{ + "jsonrpc":"2.0", + "id": 1, + "src":"user_1", + "method":"Cover.Open", + "params": { + "id":0 + } +} +`) } func rx(c *websocket.Conn) { -- cgit v1.3 From 52d5d96fb3389ffb415756d0a806bb321227303a Mon Sep 17 00:00:00 2001 From: xengineering Date: Sat, 21 Mar 2026 15:45:42 +0100 Subject: Document Shelly 2PM Gen3 support This advertises the support for this and documents links to documentation and the vendor home page. --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c9f153a..74ac093 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,20 @@ implemented by apps connecting to this central Sia server. ## Supported vendors and devices -Currently only [Homematic IP][1] as a vendor and the [OpenCCU][2] as interface -is supported. +### Homematic IP -The currently only supported device is: +[Homematic IP][1] with the [OpenCCU][2] as central device is supported with +these IoT products: - [HmIP-SWDO-2][3] - optical window or door contact -Further device support is planned. +### Shelly + +[Shelly][8] is supported with the following product: + +- [Shelly 2PM Gen3][7] - twin relay e.g. for roller shutter control + +The implemented API is documented [here][6]. ## Build instructions @@ -70,3 +76,6 @@ Only aspects explicitly stated here are part of the public API: [3]: https://homematic-ip.com/en/product/window-and-door-contact-optical [4]: https://systemd.io/ [5]: https://semver.org/ +[6]: https://shelly-api-docs.shelly.cloud/gen2/ +[7]: https://www.shelly.com/de/products/shelly-2pm-gen3-1/ +[8]: https://shelly.com/ -- cgit v1.3 From 793f50953241d151745642d238ac5fe13aee4692 Mon Sep 17 00:00:00 2001 From: xengineering Date: Sun, 22 Mar 2026 11:32:12 +0100 Subject: Document MQTT interface It is even quite late to document the main application programming interface (API) of the Sia server. This is now fixed. --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index 74ac093..d4a73b8 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,47 @@ Only aspects explicitly stated here are part of the public API: - configuration file format - MQTT interface +## MQTT interface + +The Sia server connects to a MQTT broker and exposes its client interface +there. + +The MQTT broker host, port and the Sia server's client ID is configured via the +Sia configuration file. + +Furthermore a topic prefix is selected. **All topics documented below are +implicitly prefixed with this topic prefix.** This allows using multiple Sia +server instances on one MQTT broker. + +All message payloads are UTF-8 encoded strings. + +For all terms not explained here see the [MQTT version 3.1.1 documentation][9]. + +### `/server/health` + +- description: Indicates if Sia server is connected to the broker +- direction: Sia server to client +- Quality of Service: QoS 1 (at least once) +- retained: yes +- receives will message: yes, indicating sudden disconnect of Sia server +- topic parameters: none +- payloads: + - `good`: Sia server is connected to MQTT broker + - `bad`: Sia server is disconnected from MQTT broker + +### `/contact//state` + +- description: Indicates state of Homematic IP SWDO-2 contacts +- direction: Sia server to client +- Quality of Service: QoS 1 (at least once) +- retained: yes +- receives will message: no +- topic parameters: + - `id`: ID of the Homematic IP SWDO-2 contact +- payloads: + - `open`: contact is open + - `closed`: contact is closed + [1]: https://homematic-ip.com/ [2]: https://openccu.de/ [3]: https://homematic-ip.com/en/product/window-and-door-contact-optical @@ -79,3 +120,4 @@ Only aspects explicitly stated here are part of the public API: [6]: https://shelly-api-docs.shelly.cloud/gen2/ [7]: https://www.shelly.com/de/products/shelly-2pm-gen3-1/ [8]: https://shelly.com/ +[9]: https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html -- cgit v1.3 From 1337229d4a202099d9847778507faa0f0f207f82 Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 17:07:25 +0100 Subject: Simplify TestDefaultConfig The validation step is always called in .FromJSON(). --- config_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/config_test.go b/config_test.go index 0972d5d..c568a34 100644 --- a/config_test.go +++ b/config_test.go @@ -11,9 +11,4 @@ func TestDefaultConfig(t *testing.T) { if err != nil { t.Fatalf("Failed parsing default config from JSON: %v", err) } - - err = config.Validate() - if err != nil { - t.Fatalf("Failed to validate default config: %v", err) - } } -- cgit v1.3 From 04e6d681f04731c67b9b65fb6a55a21184fd4baa Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 17:25:50 +0100 Subject: Move default.json to configs/valid Since the default configuration is a valid configuration it should belong to this new directory. This allows to continue with extending automated tests to test all configurations in this directory. --- config.go | 2 +- configs/default.json | 11 ----------- configs/meson.build | 2 +- configs/valid/default.json | 11 +++++++++++ 4 files changed, 13 insertions(+), 13 deletions(-) delete mode 100644 configs/default.json create mode 100644 configs/valid/default.json diff --git a/config.go b/config.go index d39efca..b3cd9dc 100644 --- a/config.go +++ b/config.go @@ -31,7 +31,7 @@ func init() { mqttTopicPrefixRegexp = regexp.MustCompile(MQTT_TOPIC_PREFIX_REGEX) } -//go:embed configs/default.json +//go:embed configs/valid/default.json var defaultConfig []byte type MQTTConfig struct { diff --git a/configs/default.json b/configs/default.json deleted file mode 100644 index b291185..0000000 --- a/configs/default.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mqtt": { - "broker": "tcp://127.0.0.1:1883", - "client-id": "siaserver", - "topic-prefix": "sia" - }, - "homematic": { - "ccu": "http://127.0.0.1:8080", - "polling-period": "50ms" - } -} diff --git a/configs/meson.build b/configs/meson.build index 98110da..aa16a47 100644 --- a/configs/meson.build +++ b/configs/meson.build @@ -1,3 +1,3 @@ fs = import('fs') -default_config = fs.copyfile(meson.current_source_dir() / 'default.json') +default_config = fs.copyfile(meson.current_source_dir() / 'valid' / 'default.json') diff --git a/configs/valid/default.json b/configs/valid/default.json new file mode 100644 index 0000000..b291185 --- /dev/null +++ b/configs/valid/default.json @@ -0,0 +1,11 @@ +{ + "mqtt": { + "broker": "tcp://127.0.0.1:1883", + "client-id": "siaserver", + "topic-prefix": "sia" + }, + "homematic": { + "ccu": "http://127.0.0.1:8080", + "polling-period": "50ms" + } +} -- cgit v1.3 From 14c0c7fa79f599ddab8fa0b2fd2ee7853fa617fe Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 17:40:30 +0100 Subject: Test everything in configs/valid This allows easily to add JSON configuration files testing certain aspects. --- config_test.go | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/config_test.go b/config_test.go index c568a34..78d07fb 100644 --- a/config_test.go +++ b/config_test.go @@ -1,14 +1,35 @@ package main import ( + "embed" + "io/fs" "testing" ) -func TestDefaultConfig(t *testing.T) { - config := StartupConfig{} +//go:embed configs/valid/*.json +var valid embed.FS - err := config.FromJSON(defaultConfig) - if err != nil { - t.Fatalf("Failed parsing default config from JSON: %v", err) - } +func TestValidConfigs(t *testing.T) { + fs.WalkDir(valid, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + t.Fatalf("Failed to walk valid config files: %v", err) + } + + if d.IsDir() { + return nil + } + + data, err := valid.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read config from path %s: %v", path, err) + } + + config := StartupConfig{} + err = config.FromJSON(data) + if err != nil { + t.Fatalf("Failed parsing config %s from JSON: %v", path, err) + } + + return nil + }) } -- cgit v1.3 From 91844eb24c16d7ba768f913597702f8075fe8af2 Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 17:41:22 +0100 Subject: Add test mqtt-topic-prefix-max-characters.json This makes sure the maximum `mqtt/topic-prefix` string length is accepted. --- configs/valid/mqtt-topic-prefix-max-characters.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 configs/valid/mqtt-topic-prefix-max-characters.json diff --git a/configs/valid/mqtt-topic-prefix-max-characters.json b/configs/valid/mqtt-topic-prefix-max-characters.json new file mode 100644 index 0000000..99f3cf1 --- /dev/null +++ b/configs/valid/mqtt-topic-prefix-max-characters.json @@ -0,0 +1,11 @@ +{ + "mqtt": { + "broker": "tcp://127.0.0.1:1883", + "client-id": "siaserver", + "topic-prefix": "aaaaaaaaaaaaaaaaaaaa" + }, + "homematic": { + "ccu": "http://127.0.0.1:8080", + "polling-period": "50ms" + } +} -- cgit v1.3 From 776e6fdd8415edd0daa5743c35cbee029a89c6a7 Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 17:59:33 +0100 Subject: Add shelly configuration parsing This allows to specify Shelly cover devices to be added to the configuration file with all information required by the Sia server. --- config.go | 15 +++++++++++++++ configs/valid/shelly.json | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 configs/valid/shelly.json diff --git a/config.go b/config.go index b3cd9dc..a294b6c 100644 --- a/config.go +++ b/config.go @@ -45,9 +45,17 @@ type HomematicConfig struct { PollingPeriod string `json:"polling-period"` } +type ShellyConfig struct { + ID string `json:"id"` + IP string `json:"ip"` +} + +type ShellyConfigs []ShellyConfig + type StartupConfig struct { MQTT MQTTConfig `json:"mqtt"` Homematic HomematicConfig `json:"homematic"` + Shelly ShellyConfigs `json:"shelly"` } func (sc StartupConfig) String() string { @@ -117,6 +125,13 @@ func (sc StartupConfig) Validate() error { return fmt.Errorf("homematic/polling-period configuration '%s' could not be parsed to duration: %v", sc.Homematic.PollingPeriod, err) } + for _, shelly := range sc.Shelly { + ip := net.ParseIP(shelly.IP) + if ip == nil { + return fmt.Errorf("Failed to parse IP address '%s'.", shelly.IP) + } + } + return nil } diff --git a/configs/valid/shelly.json b/configs/valid/shelly.json new file mode 100644 index 0000000..578f6dc --- /dev/null +++ b/configs/valid/shelly.json @@ -0,0 +1,21 @@ +{ + "mqtt": { + "broker": "tcp://127.0.0.1:1883", + "client-id": "siaserver", + "topic-prefix": "sia" + }, + "homematic": { + "ccu": "http://127.0.0.1:8080", + "polling-period": "50ms" + }, + "shelly": [ + { + "id": "shelly1", + "ip": "192.168.1.20" + }, + { + "id": "shelly2", + "ip": "2001:db8::68" + } + ] +} -- cgit v1.3 From ece5ee8e002d7025eb2ed82f58c94f8a028f3b91 Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 20:10:04 +0100 Subject: Document /cover//movement MQTT topic --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index d4a73b8..2b40691 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,20 @@ For all terms not explained here see the [MQTT version 3.1.1 documentation][9]. - `open`: contact is open - `closed`: contact is closed +### `/cover//movement` + +- description: Allows control of Shelly 2PM Gen3 covers +- direction: client to Sia server +- Quality of Service: QoS 2 (exactly once) +- retained: no +- receives will message: no +- topic parameters: + - `id`: ID of the Shelly 2PM Gen3 cover +- payloads: + - `extend`: cover increases the covering surface + - `retract`: cover decreases the covering surface + - `stop`: cover stops current motion if given + [1]: https://homematic-ip.com/ [2]: https://openccu.de/ [3]: https://homematic-ip.com/en/product/window-and-door-contact-optical -- cgit v1.3 From 64f8cf0630ce51349b94aca2f91617d373ee800d Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 20:46:34 +0100 Subject: Add MQTTMessage.String() This simplifies debugging. --- mqtt.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mqtt.go b/mqtt.go index a7b374d..d9f0632 100644 --- a/mqtt.go +++ b/mqtt.go @@ -25,6 +25,10 @@ type MQTTMessage struct { Payload []byte } +func (m MQTTMessage) String() string { + return fmt.Sprintf("topic='%s' message='%s'", m.Topic, string(m.Payload)) +} + func MQTTRun(config MQTTConfig, tx chan MQTTMessage) { mqttServerHealthTopic = fmt.Sprintf("%s/server/health", config.TopicPrefix) -- cgit v1.3 From 1bd2833f81379f25b29ab5d929f14e51700fa471 Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 20:53:40 +0100 Subject: Add MQTT subscription for /cover//movement This let's the Sia server receive cover movement commands. For now they are simply logged. --- main.go | 4 +++- meson.build | 1 + mqtt.go | 27 ++++++++++++++++++++------- shelly.go | 11 +++++++++++ 4 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 shelly.go diff --git a/main.go b/main.go index 6a59d64..e123c83 100644 --- a/main.go +++ b/main.go @@ -18,10 +18,12 @@ func main() { config := GetStartupConfig(flags.ConfigPath) + rx := make(chan MQTTMessage) tx := make(chan MQTTMessage) - go MQTTRun(config.MQTT, tx) + go MQTTRun(config.MQTT, rx, tx) go HomematicRun(config.Homematic, tx) + go ShellyRun(config.Shelly, rx) Await(syscall.SIGTERM, syscall.SIGINT) } diff --git a/meson.build b/meson.build index 4bc72bd..e8878ce 100644 --- a/meson.build +++ b/meson.build @@ -23,6 +23,7 @@ sia_server_linux_amd64 = custom_target( meson.current_source_dir() / 'homematic.go', meson.current_source_dir() / 'config.go', meson.current_source_dir() / 'flags.go', + meson.current_source_dir() / 'shelly.go', ], output : 'sia-server-linux-amd64', env : {'GOOS': 'linux', 'GOARCH': 'amd64'}, diff --git a/mqtt.go b/mqtt.go index d9f0632..7ad47ba 100644 --- a/mqtt.go +++ b/mqtt.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "time" + "strings" mqtt "github.com/eclipse/paho.mqtt.golang" ) @@ -12,6 +13,7 @@ const ( QOS = byte(1) RETAINED = true MQTT_CONNECT_TIMEOUT = 1 * time.Second + MQTT_SUBSCRIBE_TIMEOUT = 1 * time.Second MQTT_DISCONNECT_TIMEOUT_US = 500 MQTT_KEEPALIVE_PERIOD = 2 * time.Second ) @@ -29,14 +31,30 @@ func (m MQTTMessage) String() string { return fmt.Sprintf("topic='%s' message='%s'", m.Topic, string(m.Payload)) } -func MQTTRun(config MQTTConfig, tx chan MQTTMessage) { +func MQTTRun(config MQTTConfig, rx chan MQTTMessage, tx chan MQTTMessage) { mqttServerHealthTopic = fmt.Sprintf("%s/server/health", config.TopicPrefix) opts := mqtt.NewClientOptions() opts.AddBroker(config.Broker) opts.SetClientID(config.ClientID) opts.SetCleanSession(true) - opts.SetOnConnectHandler(MQTTOnConnectHandler) + opts.SetOnConnectHandler(func(c mqtt.Client) { + log.Printf("Connected to MQTT broker.") + c.Publish(mqttServerHealthTopic, QOS, true, []byte(`good`)) + + topic := fmt.Sprintf("%s/cover/+/movement", config.TopicPrefix) + token := c.Subscribe(topic, byte(2), func(c mqtt.Client, msg mqtt.Message) { + message := MQTTMessage{ + Topic: strings.TrimPrefix(msg.Topic(), config.TopicPrefix + "/"), + Payload: msg.Payload(), + } + rx <- message + }) + success := token.WaitTimeout(MQTT_SUBSCRIBE_TIMEOUT) + if !success { + log.Fatal("Initial topic subscription failed.") + } + }) opts.SetConnectionLostHandler(MQTTConnectionLostHandler) opts.SetAutoReconnect(true) opts.SetConnectRetry(true) @@ -60,11 +78,6 @@ func MQTTRun(config MQTTConfig, tx chan MQTTMessage) { } } -func MQTTOnConnectHandler(c mqtt.Client) { - log.Printf("Connected to MQTT broker.") - c.Publish(mqttServerHealthTopic, QOS, true, []byte(`good`)) -} - func MQTTConnectionLostHandler(c mqtt.Client, err error) { log.Printf("Connection to MQTT broker lost: %v", err) } diff --git a/shelly.go b/shelly.go new file mode 100644 index 0000000..0d33182 --- /dev/null +++ b/shelly.go @@ -0,0 +1,11 @@ +package main + +import ( + "log" +) + +func ShellyRun(config ShellyConfigs, rx chan MQTTMessage) { + for message := range rx { + log.Printf("Got MQTT message: %v", message) + } +} -- cgit v1.3 From bfd840bfd843f95183568f7ef6a9880a810ce049 Mon Sep 17 00:00:00 2001 From: xengineering Date: Mon, 23 Mar 2026 21:34:58 +0100 Subject: Add Shelly cover message parsing This results in the information of which command is to issue and which IP address the command has to be sent to. This is what is needed to deliver the message with Websockets. This delivery is the last step to implement basic Shelly cover support. --- shelly.go | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/shelly.go b/shelly.go index 0d33182..7396bbb 100644 --- a/shelly.go +++ b/shelly.go @@ -1,11 +1,57 @@ package main import ( + "fmt" "log" + "net" + "strings" ) func ShellyRun(config ShellyConfigs, rx chan MQTTMessage) { for message := range rx { - log.Printf("Got MQTT message: %v", message) + ip, command, err := parseMessage(config, message) + if err != nil { + log.Println(err) + continue + } + + log.Printf("Send '%s' to '%s'.", command, ip) + } +} + +func parseMessage(config ShellyConfigs, m MQTTMessage) (ip *net.IP, command string, err error) { + elements := strings.Split(m.Topic, "/") + + if len(elements) != 3 { + return nil, "", fmt.Errorf( + "Expected three topic levels but got %d in '%s'.", + len(elements), m.Topic, + ) + } + + if elements[0] != "cover" || elements[2] != "movement" { + return nil, "", fmt.Errorf("Expected cover//movement but got: %s", m.Topic) } + + switch string(m.Payload) { + case "extend": + command = "Cover.Close" + case "retract": + command = "Cover.Open" + case "stop": + command = "Cover.Stop" + default: + return nil, "", fmt.Errorf("Invalid payload '%s'.", m.Payload) + } + + id := elements[1] + + for _, c := range config { + if c.ID == id { + ip := net.ParseIP(c.IP) + return &ip, command, nil + } + } + + return nil, "", fmt.Errorf("Got message for unknown cover '%s'", id) } -- cgit v1.3 From 476db7047a9c650057c034c647ea66f3c38e8a53 Mon Sep 17 00:00:00 2001 From: xengineering Date: Wed, 25 Mar 2026 20:33:22 +0100 Subject: Refactor and add routing concept This scales better when additional receiving routes will be added. --- main.go | 6 +++--- mqtt.go | 44 +++++++++++++++++++++++++++++--------------- shelly.go | 4 ++-- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/main.go b/main.go index e123c83..1678eac 100644 --- a/main.go +++ b/main.go @@ -18,12 +18,12 @@ func main() { config := GetStartupConfig(flags.ConfigPath) - rx := make(chan MQTTMessage) tx := make(chan MQTTMessage) + coverMovement := NewRoute("cover/+/movement", QoS2) - go MQTTRun(config.MQTT, rx, tx) + go MQTTRun(config.MQTT, tx, coverMovement) go HomematicRun(config.Homematic, tx) - go ShellyRun(config.Shelly, rx) + go ShellyRun(config.Shelly, coverMovement) Await(syscall.SIGTERM, syscall.SIGINT) } diff --git a/mqtt.go b/mqtt.go index 7ad47ba..fd1cba1 100644 --- a/mqtt.go +++ b/mqtt.go @@ -10,7 +10,9 @@ import ( ) const ( - QOS = byte(1) + QoS0 = byte(0) + QoS1 = byte(1) + QoS2 = byte(2) RETAINED = true MQTT_CONNECT_TIMEOUT = 1 * time.Second MQTT_SUBSCRIBE_TIMEOUT = 1 * time.Second @@ -27,11 +29,21 @@ type MQTTMessage struct { Payload []byte } +type Route struct { + Topic string + QoS byte + Destination chan MQTTMessage +} + +func NewRoute(topic string, qos byte) Route { + return Route{topic, qos, make(chan MQTTMessage)} +} + func (m MQTTMessage) String() string { return fmt.Sprintf("topic='%s' message='%s'", m.Topic, string(m.Payload)) } -func MQTTRun(config MQTTConfig, rx chan MQTTMessage, tx chan MQTTMessage) { +func MQTTRun(config MQTTConfig, tx chan MQTTMessage, routes ...Route) { mqttServerHealthTopic = fmt.Sprintf("%s/server/health", config.TopicPrefix) opts := mqtt.NewClientOptions() @@ -40,19 +52,21 @@ func MQTTRun(config MQTTConfig, rx chan MQTTMessage, tx chan MQTTMessage) { opts.SetCleanSession(true) opts.SetOnConnectHandler(func(c mqtt.Client) { log.Printf("Connected to MQTT broker.") - c.Publish(mqttServerHealthTopic, QOS, true, []byte(`good`)) + c.Publish(mqttServerHealthTopic, QoS1, true, []byte(`good`)) - topic := fmt.Sprintf("%s/cover/+/movement", config.TopicPrefix) - token := c.Subscribe(topic, byte(2), func(c mqtt.Client, msg mqtt.Message) { - message := MQTTMessage{ - Topic: strings.TrimPrefix(msg.Topic(), config.TopicPrefix + "/"), - Payload: msg.Payload(), + for _, route := range routes { + topic := config.TopicPrefix + "/" + route.Topic + token := c.Subscribe(topic, route.QoS, func(c mqtt.Client, msg mqtt.Message) { + message := MQTTMessage{ + Topic: strings.TrimPrefix(msg.Topic(), config.TopicPrefix + "/"), + Payload: msg.Payload(), + } + route.Destination <- message + }) + success := token.WaitTimeout(MQTT_SUBSCRIBE_TIMEOUT) + if !success { + log.Fatalf("Topic subscription failed for topic '%s'", topic) } - rx <- message - }) - success := token.WaitTimeout(MQTT_SUBSCRIBE_TIMEOUT) - if !success { - log.Fatal("Initial topic subscription failed.") } }) opts.SetConnectionLostHandler(MQTTConnectionLostHandler) @@ -60,7 +74,7 @@ func MQTTRun(config MQTTConfig, rx chan MQTTMessage, tx chan MQTTMessage) { opts.SetConnectRetry(true) opts.SetConnectTimeout(MQTT_CONNECT_TIMEOUT) opts.SetKeepAlive(MQTT_KEEPALIVE_PERIOD) - opts.SetWill(mqttServerHealthTopic, `bad`, QOS, true) + opts.SetWill(mqttServerHealthTopic, `bad`, QoS1, true) client := mqtt.NewClient(opts) @@ -74,7 +88,7 @@ func MQTTRun(config MQTTConfig, rx chan MQTTMessage, tx chan MQTTMessage) { for message := range tx { topic := fmt.Sprintf("%s/%s", config.TopicPrefix, message.Topic) - client.Publish(topic, QOS, RETAINED, message.Payload) + client.Publish(topic, QoS1, RETAINED, message.Payload) } } diff --git a/shelly.go b/shelly.go index 7396bbb..5f6f2ad 100644 --- a/shelly.go +++ b/shelly.go @@ -7,8 +7,8 @@ import ( "strings" ) -func ShellyRun(config ShellyConfigs, rx chan MQTTMessage) { - for message := range rx { +func ShellyRun(config ShellyConfigs, route Route) { + for message := range route.Destination { ip, command, err := parseMessage(config, message) if err != nil { log.Println(err) -- cgit v1.3 From 6001997a66c4c4b12e9d8b0853fef0fc0ff14768 Mon Sep 17 00:00:00 2001 From: xengineering Date: Wed, 25 Mar 2026 21:00:13 +0100 Subject: Add Shelly command sending This allows basic control of Covers connected to Shelly devices. --- shelly.go | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/shelly.go b/shelly.go index 5f6f2ad..508b393 100644 --- a/shelly.go +++ b/shelly.go @@ -5,6 +5,8 @@ import ( "log" "net" "strings" + + "github.com/gorilla/websocket" ) func ShellyRun(config ShellyConfigs, route Route) { @@ -15,7 +17,10 @@ func ShellyRun(config ShellyConfigs, route Route) { continue } - log.Printf("Send '%s' to '%s'.", command, ip) + err = shellySendCommand(ip, command) + if err != nil { + log.Printf("Could not send command '%s' to %v: %v", command, ip, err) + } } } @@ -55,3 +60,31 @@ func parseMessage(config ShellyConfigs, m MQTTMessage) (ip *net.IP, command stri return nil, "", fmt.Errorf("Got message for unknown cover '%s'", id) } + +func shellySendCommand(ip *net.IP, command string) error { + template := ` +{ + "jsonrpc":"2.0", + "id": 1, + "src":"user_1", + "method":"%s", + "params": { + "id":0 + } +} +` + message := fmt.Appendf([]byte{}, template, command) + + c, _, err := websocket.DefaultDialer.Dial("ws://" + ip.String() + "/rpc", nil) + if err != nil { + return fmt.Errorf("Could not connect to Shelly: %w", err) + } + defer c.Close() + + err = c.WriteMessage(websocket.TextMessage, message) + if err != nil { + return fmt.Errorf("Failed writing websocket message to Shelly: %w", err) + } + + return nil +} -- cgit v1.3