diff options
author | Mike Crute <mike@crute.us> | 2022-01-02 13:28:25 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2022-01-02 13:28:25 -0800 |
commit | 6833878bde3b04931ebbfbc7a2c72882fe7c7f23 (patch) | |
tree | 54627e82fddaea84bbf7bb54be950117cde13d30 | |
parent | 5eb13e82544d6e4c1b07dd57f41fd8d228bc0960 (diff) | |
download | mfi_homekit-master.tar.bz2 mfi_homekit-master.tar.xz mfi_homekit-master.zip |
-rw-r--r-- | mqtt-controller/Makefile | 2 | ||||
-rw-r--r-- | mqtt-controller/db/bridgeConfig | 5 | ||||
-rw-r--r-- | mqtt-controller/devices.go | 280 | ||||
-rw-r--r-- | mqtt-controller/go.mod | 8 | ||||
-rw-r--r-- | mqtt-controller/go.sum | 38 | ||||
-rw-r--r-- | mqtt-controller/main.go | 146 | ||||
-rw-r--r-- | mqtt-controller/mqtt.go | 93 | ||||
-rw-r--r-- | mqtt-controller/status.go | 80 | ||||
-rw-r--r-- | mqtt-controller/unified_switch.go | 124 |
9 files changed, 776 insertions, 0 deletions
diff --git a/mqtt-controller/Makefile b/mqtt-controller/Makefile new file mode 100644 index 0000000..c3efc35 --- /dev/null +++ b/mqtt-controller/Makefile | |||
@@ -0,0 +1,2 @@ | |||
1 | mqtt-controller: devices.go main.go mqtt.go status.go unified_switch.go | ||
2 | go build -o $@ $^ | ||
diff --git a/mqtt-controller/db/bridgeConfig b/mqtt-controller/db/bridgeConfig new file mode 100644 index 0000000..db3afce --- /dev/null +++ b/mqtt-controller/db/bridgeConfig | |||
@@ -0,0 +1,5 @@ | |||
1 | { | ||
2 | "Broker": "172.16.0.186:1883", | ||
3 | "BridgeName": "MQTT Bridge", | ||
4 | "Pin": "00102003" | ||
5 | } | ||
diff --git a/mqtt-controller/devices.go b/mqtt-controller/devices.go new file mode 100644 index 0000000..9f11333 --- /dev/null +++ b/mqtt-controller/devices.go | |||
@@ -0,0 +1,280 @@ | |||
1 | package main | ||
2 | |||
3 | import ( | ||
4 | "encoding/json" | ||
5 | "fmt" | ||
6 | "log" | ||
7 | "strconv" | ||
8 | "strings" | ||
9 | ) | ||
10 | |||
11 | type Direction int | ||
12 | |||
13 | const ( | ||
14 | DirectionInput Direction = iota | ||
15 | DirectionOutput | ||
16 | ) | ||
17 | |||
18 | type Device interface { | ||
19 | Identity() string | ||
20 | Connect(*MQTTBroker, chan<- Event, chan<- Metric, chan<- interface{}) (<-chan Command, error) | ||
21 | OutputOn(int) error | ||
22 | OutputOff(int) error | ||
23 | Disconnect() error | ||
24 | } | ||
25 | |||
26 | type Command struct { | ||
27 | Index int | ||
28 | Active bool | ||
29 | } | ||
30 | |||
31 | type Event struct { | ||
32 | Device Device | ||
33 | Index int | ||
34 | Active bool | ||
35 | Direction Direction | ||
36 | } | ||
37 | |||
38 | type Metric struct { | ||
39 | Device Device | ||
40 | Name string | ||
41 | Value interface{} | ||
42 | } | ||
43 | |||
44 | type DeviceAnnounce struct { | ||
45 | ID string `json:"id"` | ||
46 | IP string `json:"ip"` | ||
47 | MacAddress string `json:"mac"` | ||
48 | Model string `json:"model"` | ||
49 | FirmwareVersion string `json:"fw_ver"` | ||
50 | NewFirmwareAvailable bool `json:"new_fw"` | ||
51 | } | ||
52 | |||
53 | func NewDevice(b *MQTTBroker, da DeviceAnnounce) (Device, error) { | ||
54 | switch da.Model { | ||
55 | case "SHSW-PM": | ||
56 | return NewShellyDevice(da, 1, 1), nil | ||
57 | case "SHSW-25": | ||
58 | return NewShellyDevice(da, 2, 2), nil | ||
59 | } | ||
60 | return nil, fmt.Errorf("Unknown device model %s", da.Model) | ||
61 | } | ||
62 | |||
63 | type ShellyDevice struct { | ||
64 | ID string | ||
65 | Manufacturer string | ||
66 | IP string // TODO: Fix type | ||
67 | MacAddress string // TODO: Fix type | ||
68 | Model string // TODO: Fix type | ||
69 | FirmwareVersion string // TODO: Fix type | ||
70 | NewFirmwareAvailable bool | ||
71 | broker *MQTTBroker | ||
72 | inputCount int | ||
73 | outputCount int | ||
74 | connected bool | ||
75 | inputState []bool | ||
76 | outputState []bool | ||
77 | subscribed []string | ||
78 | events chan<- Event | ||
79 | metrics chan<- Metric | ||
80 | info chan<- interface{} | ||
81 | commands chan Command | ||
82 | } | ||
83 | |||
84 | func NewShellyDevice(da DeviceAnnounce, inputCount, outputCount int) *ShellyDevice { | ||
85 | return &ShellyDevice{ | ||
86 | Manufacturer: "Shelly", | ||
87 | ID: da.ID, | ||
88 | IP: da.IP, | ||
89 | MacAddress: da.MacAddress, | ||
90 | Model: da.Model, | ||
91 | FirmwareVersion: da.FirmwareVersion, | ||
92 | NewFirmwareAvailable: da.NewFirmwareAvailable, | ||
93 | inputCount: inputCount, | ||
94 | outputCount: outputCount, | ||
95 | inputState: make([]bool, inputCount), | ||
96 | outputState: make([]bool, outputCount), | ||
97 | connected: false, | ||
98 | subscribed: []string{}, | ||
99 | commands: make(chan Command, 100), | ||
100 | } | ||
101 | } | ||
102 | |||
103 | var _ Device = (*ShellyDevice)(nil) | ||
104 | |||
105 | func (d *ShellyDevice) Identity() string { | ||
106 | return d.ID | ||
107 | } | ||
108 | |||
109 | func (d *ShellyDevice) handleOutputMessages(topic string, payload []byte) { | ||
110 | path := strings.Split(topic, "/")[2:] | ||
111 | |||
112 | switch path[0] { | ||
113 | case "info": | ||
114 | p := ShellyStatus{Device: d} | ||
115 | if err := json.Unmarshal(payload, &p); err != nil { | ||
116 | log.Printf("Error parsing info packet: %s", err) | ||
117 | return | ||
118 | } | ||
119 | d.info <- p | ||
120 | case "input": | ||
121 | idx, err := strconv.Atoi(path[1]) | ||
122 | if err != nil { | ||
123 | log.Println("Error parsing index") | ||
124 | return | ||
125 | } | ||
126 | state := payload[0] == '0' | ||
127 | if d.inputState[idx] != state { | ||
128 | d.events <- Event{ | ||
129 | Device: d, | ||
130 | Index: idx, | ||
131 | Active: state, | ||
132 | Direction: DirectionInput, | ||
133 | } | ||
134 | d.inputState[idx] = state | ||
135 | } | ||
136 | case "relay": | ||
137 | idx, err := strconv.Atoi(path[1]) | ||
138 | if err != nil { | ||
139 | log.Println("Error parsing index") | ||
140 | return | ||
141 | } | ||
142 | if len(path) == 2 { | ||
143 | state := string(payload) == "on" | ||
144 | if d.outputState[idx] != state { | ||
145 | d.events <- Event{ | ||
146 | Device: d, | ||
147 | Index: idx, | ||
148 | Active: state, | ||
149 | Direction: DirectionOutput, | ||
150 | } | ||
151 | d.outputState[idx] = state | ||
152 | } | ||
153 | } else { | ||
154 | switch path[2] { | ||
155 | case "power": | ||
156 | m, err := strconv.ParseFloat(string(payload), 16) | ||
157 | if err != nil { | ||
158 | log.Printf("Failed to parse metric") | ||
159 | return | ||
160 | } | ||
161 | d.metrics <- Metric{ | ||
162 | Device: d, | ||
163 | Name: fmt.Sprintf("relay_%d_power", idx), | ||
164 | Value: m, | ||
165 | } | ||
166 | case "energy": | ||
167 | m, err := strconv.ParseInt(string(payload), 10, 32) | ||
168 | if err != nil { | ||
169 | log.Printf("Failed to parse metric") | ||
170 | return | ||
171 | } | ||
172 | d.metrics <- Metric{ | ||
173 | Device: d, | ||
174 | Name: fmt.Sprintf("relay_%d_energy", idx), | ||
175 | Value: m, | ||
176 | } | ||
177 | case "overpower_value": | ||
178 | m, err := strconv.ParseInt(string(payload), 10, 32) | ||
179 | if err != nil { | ||
180 | log.Printf("Failed to parse metric") | ||
181 | return | ||
182 | } | ||
183 | d.metrics <- Metric{ | ||
184 | Device: d, | ||
185 | Name: fmt.Sprintf("relay_%d_overpower_value", idx), | ||
186 | Value: m, | ||
187 | } | ||
188 | } | ||
189 | } | ||
190 | case "temperature": | ||
191 | m, err := strconv.ParseFloat(string(payload), 16) | ||
192 | if err != nil { | ||
193 | log.Printf("Failed to parse metric") | ||
194 | return | ||
195 | } | ||
196 | d.metrics <- Metric{ | ||
197 | Device: d, | ||
198 | Name: "temperature_c", | ||
199 | Value: m, | ||
200 | } | ||
201 | case "overtemperature": | ||
202 | d.metrics <- Metric{ | ||
203 | Device: d, | ||
204 | Name: "is_overtemperature", | ||
205 | Value: payload[0] == '1', | ||
206 | } | ||
207 | } | ||
208 | } | ||
209 | |||
210 | func (d *ShellyDevice) OutputOn(idx int) error { | ||
211 | if idx < 0 || idx >= d.outputCount { | ||
212 | return fmt.Errorf("Index is out of range for device") | ||
213 | } | ||
214 | if d.broker == nil { | ||
215 | return fmt.Errorf("Device is not connected to broker") | ||
216 | } | ||
217 | |||
218 | return d.broker.Publish(fmt.Sprintf("shellies/%s/relay/%d/command", d.Identity(), idx), "on") | ||
219 | } | ||
220 | |||
221 | func (d *ShellyDevice) OutputOff(idx int) error { | ||
222 | if idx < 0 || idx >= d.outputCount { | ||
223 | return fmt.Errorf("Index is out of range for device") | ||
224 | } | ||
225 | if d.broker == nil { | ||
226 | return fmt.Errorf("Device is not connected to broker") | ||
227 | } | ||
228 | |||
229 | return d.broker.Publish(fmt.Sprintf("shellies/%s/relay/%d/command", d.Identity(), idx), "off") | ||
230 | } | ||
231 | |||
232 | func (d *ShellyDevice) Connect(b *MQTTBroker, events chan<- Event, metrics chan<- Metric, info chan<- interface{}) (<-chan Command, error) { | ||
233 | d.broker = b | ||
234 | d.events = events | ||
235 | d.metrics = metrics | ||
236 | d.info = info | ||
237 | |||
238 | outputs := []string{ | ||
239 | "shellies/{ID}/info", | ||
240 | "shellies/{ID}/temperature", | ||
241 | "shellies/{ID}/overtemperature", | ||
242 | } | ||
243 | |||
244 | for i := d.inputCount - 1; i >= 0; i-- { | ||
245 | outputs = append(outputs, fmt.Sprintf("shellies/{ID}/input/%d", i)) | ||
246 | outputs = append(outputs, fmt.Sprintf("shellies/{ID}/longpush/%d", i)) | ||
247 | } | ||
248 | |||
249 | for i := d.outputCount - 1; i >= 0; i-- { | ||
250 | outputs = append(outputs, fmt.Sprintf("shellies/{ID}/relay/%d", i)) | ||
251 | outputs = append(outputs, fmt.Sprintf("shellies/{ID}/relay/%d/power", i)) | ||
252 | outputs = append(outputs, fmt.Sprintf("shellies/{ID}/relay/%d/energy", i)) | ||
253 | outputs = append(outputs, fmt.Sprintf("shellies/{ID}/relay/%d/overpower_value", i)) | ||
254 | } | ||
255 | |||
256 | // TODO: Handle these | ||
257 | inputs := []string{ | ||
258 | "", | ||
259 | } | ||
260 | _ = inputs | ||
261 | |||
262 | for _, topic := range outputs { | ||
263 | tn := strings.Replace(topic, "{ID}", d.ID, 1) | ||
264 | if err := b.Subscribe(tn, d.handleOutputMessages); err != nil { | ||
265 | return nil, err | ||
266 | } | ||
267 | d.subscribed = append(d.subscribed, tn) | ||
268 | } | ||
269 | |||
270 | d.connected = true | ||
271 | |||
272 | return d.commands, nil | ||
273 | } | ||
274 | |||
275 | func (d *ShellyDevice) Disconnect() error { | ||
276 | if !d.connected { | ||
277 | return fmt.Errorf("Device not connected, can not disconnect") | ||
278 | } | ||
279 | return nil | ||
280 | } | ||
diff --git a/mqtt-controller/go.mod b/mqtt-controller/go.mod new file mode 100644 index 0000000..128b792 --- /dev/null +++ b/mqtt-controller/go.mod | |||
@@ -0,0 +1,8 @@ | |||
1 | module golang.crute.me/mfi_homekit | ||
2 | |||
3 | go 1.15 | ||
4 | |||
5 | require ( | ||
6 | github.com/brutella/hc v1.2.3 | ||
7 | github.com/eclipse/paho.mqtt.golang v1.3.0 | ||
8 | ) | ||
diff --git a/mqtt-controller/go.sum b/mqtt-controller/go.sum new file mode 100644 index 0000000..88cfc14 --- /dev/null +++ b/mqtt-controller/go.sum | |||
@@ -0,0 +1,38 @@ | |||
1 | github.com/brutella/dnssd v1.1.1 h1:Ar5ytE2Z9x5DTmuNnASlMTBpcQWQLm9ceHb326s0ykg= | ||
2 | github.com/brutella/dnssd v1.1.1/go.mod h1:9gIcMKQSJvYlO2x+HR50cqqjghb9IWK9hvykmyveVVs= | ||
3 | github.com/brutella/hc v1.2.3 h1:9a3h61apXx+63b1T+W1vscs+G3xZkLS131gypnh1FIE= | ||
4 | github.com/brutella/hc v1.2.3/go.mod h1:zknCv+aeiYM27tBXr3WFL49C8UPHMxP2IVY9c5TpMOY= | ||
5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
6 | github.com/eclipse/paho.mqtt.golang v1.3.0 h1:MU79lqr3FKNKbSrGN7d7bNYqh8MwWW7Zcx0iG+VIw9I= | ||
7 | github.com/eclipse/paho.mqtt.golang v1.3.0/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= | ||
8 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= | ||
9 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | ||
10 | github.com/miekg/dns v1.1.1/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= | ||
11 | github.com/miekg/dns v1.1.4 h1:rCMZsU2ScVSYcAsOXgmC6+AKOK+6pmQTOcw03nfwYV0= | ||
12 | github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= | ||
13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||
15 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= | ||
16 | github.com/tadglines/go-pkgs v0.0.0-20140924210655-1f86682992f1 h1:ms/IQpkxq+t7hWpgKqCE5KjAUQWC24mqBrnL566SWgE= | ||
17 | github.com/tadglines/go-pkgs v0.0.0-20140924210655-1f86682992f1/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= | ||
18 | github.com/xiam/to v0.0.0-20191116183551-8328998fc0ed h1:Gjnw8buhv4V8qXaHtAWPnKXNpCNx62heQpjO8lOY0/M= | ||
19 | github.com/xiam/to v0.0.0-20191116183551-8328998fc0ed/go.mod h1:cqbG7phSzrbdg3aj+Kn63bpVruzwDZi58CpxlZkjwzw= | ||
20 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= | ||
21 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= | ||
22 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||
23 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||
24 | golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||
25 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U= | ||
26 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= | ||
27 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
28 | golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||
29 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||
30 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||
31 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= | ||
32 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
33 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||
34 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= | ||
35 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | ||
36 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||
37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
38 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||
diff --git a/mqtt-controller/main.go b/mqtt-controller/main.go new file mode 100644 index 0000000..29d5d28 --- /dev/null +++ b/mqtt-controller/main.go | |||
@@ -0,0 +1,146 @@ | |||
1 | package main | ||
2 | |||
3 | import ( | ||
4 | "encoding/json" | ||
5 | "log" | ||
6 | "os" | ||
7 | "os/signal" | ||
8 | "sync" | ||
9 | "syscall" | ||
10 | "time" | ||
11 | |||
12 | "github.com/brutella/hc" | ||
13 | "github.com/brutella/hc/accessory" | ||
14 | "github.com/brutella/hc/util" | ||
15 | ) | ||
16 | |||
17 | type AppConfig struct { | ||
18 | Broker string | ||
19 | BridgeName string | ||
20 | Pin string | ||
21 | } | ||
22 | |||
23 | func loadAppConfig(s util.Storage) (*AppConfig, error) { | ||
24 | d, err := s.Get("bridgeConfig") | ||
25 | if err != nil { | ||
26 | return nil, err | ||
27 | } | ||
28 | |||
29 | c := &AppConfig{} | ||
30 | if err = json.Unmarshal(d, c); err != nil { | ||
31 | return nil, err | ||
32 | } | ||
33 | |||
34 | return c, nil | ||
35 | } | ||
36 | |||
37 | func main() { | ||
38 | wg := &sync.WaitGroup{} | ||
39 | done := make(chan interface{}) | ||
40 | |||
41 | c := make(chan os.Signal) | ||
42 | signal.Notify(c, os.Interrupt) | ||
43 | signal.Notify(c, os.Kill) | ||
44 | signal.Notify(c, syscall.SIGTERM) | ||
45 | |||
46 | storage, err := util.NewFileStorage("db") | ||
47 | if err != nil { | ||
48 | panic(err) | ||
49 | } | ||
50 | |||
51 | cfg, err := loadAppConfig(storage) | ||
52 | if err != nil { | ||
53 | panic(err) | ||
54 | } | ||
55 | |||
56 | broker, err := NewMQTTBroker(cfg.Broker) | ||
57 | if err != nil { | ||
58 | panic(err) | ||
59 | } | ||
60 | |||
61 | if err := broker.Subscribe("shellies/announce", nil); err != nil { | ||
62 | panic(err) | ||
63 | } | ||
64 | |||
65 | if err := broker.Publish("shellies/command", "announce"); err != nil { | ||
66 | panic(err) | ||
67 | } | ||
68 | |||
69 | ac := make(chan *UnifiedSwitch, 10) | ||
70 | |||
71 | go func() { | ||
72 | wg.Add(1) | ||
73 | defer wg.Done() | ||
74 | |||
75 | log.Println("Starting homekit thread") | ||
76 | |||
77 | reg := []*accessory.Accessory{} | ||
78 | |||
79 | // Collect devices until the discovery process quiesces, then start the | ||
80 | // transport | ||
81 | quiescence := 10 * time.Second | ||
82 | t := time.NewTimer(quiescence) | ||
83 | |||
84 | C: | ||
85 | for { | ||
86 | select { | ||
87 | case a := <-ac: | ||
88 | log.Println("Homekit: new device") | ||
89 | reg = append(reg, a.HomeKitSwitch.Accessory) | ||
90 | // TODO: Extend timer deadline | ||
91 | case <-t.C: | ||
92 | log.Println("Homekit: timeout over") | ||
93 | t.Stop() | ||
94 | break C | ||
95 | case <-done: | ||
96 | log.Println("Homekit: cancelled") | ||
97 | return | ||
98 | } | ||
99 | } | ||
100 | |||
101 | log.Println("MQTT quiesces, starting HomeKit") | ||
102 | |||
103 | br := accessory.NewBridge(accessory.Info{Name: cfg.BridgeName}) | ||
104 | |||
105 | transport, err := hc.NewIPTransport(hc.Config{Pin: cfg.Pin}, br.Accessory, reg...) | ||
106 | if err != nil { | ||
107 | log.Fatalf("%s", err) | ||
108 | } | ||
109 | go transport.Start() | ||
110 | log.Printf("HomeKit transport started") | ||
111 | |||
112 | select { | ||
113 | case <-done: | ||
114 | log.Printf("Shutting down transport") | ||
115 | if transport != nil { | ||
116 | <-transport.Stop() | ||
117 | } | ||
118 | } | ||
119 | log.Printf("Transport shut down") | ||
120 | }() | ||
121 | |||
122 | for { | ||
123 | select { | ||
124 | case d := <-broker.Devices: | ||
125 | if d == nil { | ||
126 | continue | ||
127 | } | ||
128 | |||
129 | us, err := NewUnifiedSwitch(broker, d) | ||
130 | if err != nil { | ||
131 | log.Printf("Error connecting device: %e", err) | ||
132 | continue | ||
133 | } | ||
134 | |||
135 | go us.MessageLoop(done, wg) | ||
136 | |||
137 | ac <- us | ||
138 | log.Printf("New Device: %s\n", d.Identity()) | ||
139 | case <-c: | ||
140 | close(done) | ||
141 | broker.Shutdown() | ||
142 | wg.Wait() | ||
143 | return | ||
144 | } | ||
145 | } | ||
146 | } | ||
diff --git a/mqtt-controller/mqtt.go b/mqtt-controller/mqtt.go new file mode 100644 index 0000000..0b2687c --- /dev/null +++ b/mqtt-controller/mqtt.go | |||
@@ -0,0 +1,93 @@ | |||
1 | package main | ||
2 | |||
3 | import ( | ||
4 | "encoding/json" | ||
5 | "log" | ||
6 | |||
7 | "github.com/eclipse/paho.mqtt.golang" | ||
8 | ) | ||
9 | |||
10 | type MessageHandler func(topic string, payload []byte) | ||
11 | |||
12 | func mqttSync(t mqtt.Token) error { | ||
13 | if t.Wait() && t.Error() != nil { | ||
14 | return t.Error() | ||
15 | } | ||
16 | return nil | ||
17 | } | ||
18 | |||
19 | type MQTTBroker struct { | ||
20 | Devices chan Device | ||
21 | messages chan mqtt.Message | ||
22 | done chan bool | ||
23 | |||
24 | client mqtt.Client | ||
25 | } | ||
26 | |||
27 | func NewMQTTBroker(broker string) (*MQTTBroker, error) { | ||
28 | bufferSize := 100 | ||
29 | |||
30 | b := &MQTTBroker{ | ||
31 | Devices: make(chan Device, bufferSize), | ||
32 | messages: make(chan mqtt.Message, bufferSize), | ||
33 | done: make(chan bool), | ||
34 | } | ||
35 | |||
36 | b.client = mqtt.NewClient( | ||
37 | mqtt.NewClientOptions(). | ||
38 | AddBroker(broker). | ||
39 | SetDefaultPublishHandler(b.handleMessage). | ||
40 | SetClientID("homekit-controller")) | ||
41 | |||
42 | if err := mqttSync(b.client.Connect()); err != nil { | ||
43 | return nil, err | ||
44 | } | ||
45 | |||
46 | return b, nil | ||
47 | } | ||
48 | |||
49 | func (b *MQTTBroker) Subscribe(topic string, h MessageHandler) error { | ||
50 | var handler mqtt.MessageHandler | ||
51 | if h != nil { | ||
52 | handler = func(c mqtt.Client, m mqtt.Message) { | ||
53 | h(m.Topic(), m.Payload()) | ||
54 | } | ||
55 | } | ||
56 | |||
57 | if err := mqttSync(b.client.Subscribe(topic, 0, handler)); err != nil { | ||
58 | return err | ||
59 | } | ||
60 | |||
61 | return nil | ||
62 | } | ||
63 | |||
64 | func (b *MQTTBroker) Publish(topic string, msg interface{}) error { | ||
65 | if err := mqttSync(b.client.Publish(topic, 0, false, msg)); err != nil { | ||
66 | return err | ||
67 | } | ||
68 | return nil | ||
69 | } | ||
70 | |||
71 | // Runs synchronously in the mqtt client comms handler goroutine | ||
72 | func (b *MQTTBroker) handleMessage(c mqtt.Client, m mqtt.Message) { | ||
73 | switch m.Topic() { | ||
74 | case "shellies/announce", "ubiquiti/announce": | ||
75 | a := DeviceAnnounce{} | ||
76 | if err := json.Unmarshal(m.Payload(), &a); err != nil { | ||
77 | log.Printf("Unable to unmarshal device announce JSON: %e", err) | ||
78 | return | ||
79 | } | ||
80 | |||
81 | d, err := NewDevice(b, a) | ||
82 | if err != nil { | ||
83 | log.Printf("Error creating device driver: %e", err) | ||
84 | return | ||
85 | } | ||
86 | |||
87 | b.Devices <- d | ||
88 | } | ||
89 | } | ||
90 | |||
91 | func (b *MQTTBroker) Shutdown() { | ||
92 | b.client.Disconnect(250) | ||
93 | } | ||
diff --git a/mqtt-controller/status.go b/mqtt-controller/status.go new file mode 100644 index 0000000..f469256 --- /dev/null +++ b/mqtt-controller/status.go | |||
@@ -0,0 +1,80 @@ | |||
1 | package main | ||
2 | |||
3 | /* | ||
4 | shellies/announce <- coming online announcements | ||
5 | shellies/<deviceid>/info <- status endpoint | ||
6 | |||
7 | shellies/command -> publish to address all (announce, update, update_fw) | ||
8 | shellies/<deviceid>/command -> publish to address one | ||
9 | |||
10 | <i> is zero based | ||
11 | |||
12 | shellies/<deviceid>/relay/<i>/command (input: on, off, toggle) | ||
13 | |||
14 | shellies/<deviceid>/input/<i> (output: 0, 1) | ||
15 | shellies/<deviceid>/longpush/<i> (output: 0 short, 1 long) | ||
16 | shellies/<deviceid>/temperature (float, device temp in C) | ||
17 | shellies/<deviceid>/overtemperature (int, 1 if overtemp, 0 otherwise) | ||
18 | shellies/<deviceid>/relay/<i> (output: on, off, overpower) | ||
19 | shellies/<deviceid>/relay/<i>/power (float, instantaneous power in watts) | ||
20 | shellies/<deviceid>/relay/<i>/energy (int, watt-minute counter) | ||
21 | shellies/<deviceid>/relay/<i>/overpower_value (power in watts where overpower occurred) | ||
22 | */ | ||
23 | |||
24 | type ShellyInput struct { | ||
25 | Event string `json:"event"` // L=Long Press, S=Short Press, =None/Invalid | ||
26 | EventCount int `json:"event_cnt"` | ||
27 | CurrentState int `json:"input"` | ||
28 | } | ||
29 | |||
30 | type ShellyMeter struct { | ||
31 | EnergyCounters []float32 `json:"counters"` // Last 3 round minutes in watt-minute | ||
32 | Valid bool `json:"is_valid"` | ||
33 | Overpower float32 `json:"overpower"` // Value in Watts, on which an overpower condition is detected | ||
34 | Timestamp int `json:"timestamp"` // Last counter value reading time | ||
35 | Total int `json:"total"` // Total watt-minutes consumed | ||
36 | } | ||
37 | |||
38 | type ShellyRelay struct { | ||
39 | HasTimer bool `json:"has_timer"` | ||
40 | IsValid bool `json:"is_valid"` | ||
41 | IsOn bool `json:"ison"` | ||
42 | Overpower bool `json:"overpower"` | ||
43 | OverTemperature bool `json:"overtemperature"` | ||
44 | Source string `json:"source"` | ||
45 | TimerDuration int `json:"timer_duration"` | ||
46 | TimerRemaining int `json:"timer_remaining"` | ||
47 | TimerStarted int `json:"timer_started"` // Unix timestamp of start | ||
48 | } | ||
49 | |||
50 | type ShellyStatus struct { | ||
51 | Device Device | ||
52 | ConfigChangeCount int `json:"cfg_changed_cnt"` | ||
53 | Cloud struct { | ||
54 | Connected bool `json:"connected"` | ||
55 | Enabled bool `json:"enabled"` | ||
56 | } `json:"cloud"` | ||
57 | MQTT struct { | ||
58 | Connected bool `json:"connected"` | ||
59 | } `json:"mqtt"` | ||
60 | FilesystemFreeBytes int `json:"fs_free"` | ||
61 | FilesystemSizeBytes int `json:"fs_size"` | ||
62 | RAMFreeBytes int `json:"ram_free"` | ||
63 | RAMSizeBytes int `json:"ram_total"` | ||
64 | HasUpdate bool `json:"has_update"` | ||
65 | MacAddress string `json:"mac"` | ||
66 | OverTemperature bool `json:"overtemperature"` | ||
67 | TemperatureC float32 `json:"temperature"` | ||
68 | TemperatureStatus string `json:"temperature_status"` | ||
69 | Uptime int `json:"uptime"` | ||
70 | Voltage float32 `json:"voltage"` | ||
71 | WifiClient struct { | ||
72 | Connected bool `json:"connected"` | ||
73 | IP string `json:"ip"` | ||
74 | RSSI int `json:"rssi"` | ||
75 | SSID string `json:"ssid"` | ||
76 | } `json:"wifi_sta"` | ||
77 | Inputs []ShellyInput `json:"inputs"` | ||
78 | Meters []ShellyMeter `json:"meters"` | ||
79 | Relays []ShellyRelay `json:"relays"` | ||
80 | } | ||
diff --git a/mqtt-controller/unified_switch.go b/mqtt-controller/unified_switch.go new file mode 100644 index 0000000..fb62c7a --- /dev/null +++ b/mqtt-controller/unified_switch.go | |||
@@ -0,0 +1,124 @@ | |||
1 | package main | ||
2 | |||
3 | import ( | ||
4 | "hash/crc64" | ||
5 | "log" | ||
6 | "sync" | ||
7 | |||
8 | "github.com/brutella/hc/accessory" | ||
9 | ) | ||
10 | |||
11 | type UnifiedSwitch struct { | ||
12 | HomeKitSwitch *accessory.Switch | ||
13 | MqttDevice Device | ||
14 | Commands <-chan Command | ||
15 | metrics chan Metric | ||
16 | events chan Event | ||
17 | infos chan interface{} | ||
18 | } | ||
19 | |||
20 | func NewUnifiedSwitch(b *MQTTBroker, d Device) (*UnifiedSwitch, error) { | ||
21 | us := &UnifiedSwitch{ | ||
22 | MqttDevice: d, | ||
23 | metrics: make(chan Metric, 100), | ||
24 | events: make(chan Event, 100), | ||
25 | infos: make(chan interface{}, 100), | ||
26 | } | ||
27 | |||
28 | cmd, err := d.Connect(b, us.events, us.metrics, us.infos) | ||
29 | if err != nil { | ||
30 | log.Printf("Error connecting device: %e", err) | ||
31 | return nil, err | ||
32 | } | ||
33 | |||
34 | us.Commands = cmd | ||
35 | us.makeHomekitSwitch() | ||
36 | |||
37 | return us, nil | ||
38 | } | ||
39 | |||
40 | func (s *UnifiedSwitch) MessageLoop(done <-chan interface{}, wg *sync.WaitGroup) { | ||
41 | wg.Add(1) | ||
42 | defer wg.Done() | ||
43 | |||
44 | log.Printf("Starting switch %s", s.MqttDevice.Identity()) | ||
45 | |||
46 | C: | ||
47 | for { | ||
48 | select { | ||
49 | case e := <-s.events: | ||
50 | direction := "output" | ||
51 | if e.Direction == DirectionInput { | ||
52 | direction = "input" | ||
53 | } | ||
54 | |||
55 | s.HomeKitSwitch.Switch.On.SetValue(e.Active) | ||
56 | |||
57 | log.Printf("New event for '%s'.%d %s: %t", e.Device.Identity(), e.Index, direction, e.Active) | ||
58 | case m := <-s.metrics: | ||
59 | /* | ||
60 | switch t := m.Value.(type) { | ||
61 | case float64: | ||
62 | log.Printf("New metric '%s' for '%s': %#v", m.Name, m.Device.Identity(), t) | ||
63 | case int64: | ||
64 | log.Printf("New metric '%s' for '%s': %#v", m.Name, m.Device.Identity(), t) | ||
65 | case bool: | ||
66 | log.Printf("New metric '%s' for '%s': %#v", m.Name, m.Device.Identity(), t) | ||
67 | default: | ||
68 | log.Printf("Unknown metric: %#v", m.Value) | ||
69 | } | ||
70 | */ | ||
71 | _ = m | ||
72 | case i := <-s.infos: | ||
73 | switch it := i.(type) { | ||
74 | case ShellyStatus: | ||
75 | // TODO: Handle multiple relays | ||
76 | log.Printf("Info relays: %#v", it.Relays) | ||
77 | s.HomeKitSwitch.Switch.On.SetValue(it.Relays[0].IsOn) | ||
78 | default: | ||
79 | // Just discard them for now | ||
80 | } | ||
81 | case <-done: | ||
82 | log.Printf("Shutting down switch %s", s.MqttDevice.Identity()) | ||
83 | break C | ||
84 | } | ||
85 | } | ||
86 | |||
87 | log.Printf("Switch %s shut down", s.MqttDevice.Identity()) | ||
88 | } | ||
89 | |||
90 | func (s *UnifiedSwitch) HomeKitID() uint64 { | ||
91 | h64 := crc64.New(crc64.MakeTable(crc64.ISO)) | ||
92 | h64.Write([]byte(s.MqttDevice.Identity())) | ||
93 | return h64.Sum64() | ||
94 | } | ||
95 | |||
96 | func (s *UnifiedSwitch) makeHomekitSwitch() { | ||
97 | // TODO: Get name from local DB? | ||
98 | switch dt := s.MqttDevice.(type) { | ||
99 | case *ShellyDevice: | ||
100 | s.HomeKitSwitch = accessory.NewSwitch(accessory.Info{ | ||
101 | ID: s.HomeKitID(), | ||
102 | Name: s.MqttDevice.Identity(), | ||
103 | Manufacturer: dt.Manufacturer, | ||
104 | Model: dt.Model, | ||
105 | FirmwareRevision: dt.FirmwareVersion, | ||
106 | }) | ||
107 | default: | ||
108 | s.HomeKitSwitch = accessory.NewSwitch(accessory.Info{ | ||
109 | ID: s.HomeKitID(), | ||
110 | Name: s.MqttDevice.Identity(), | ||
111 | }) | ||
112 | } | ||
113 | s.HomeKitSwitch.Switch.On.SetValue(false) | ||
114 | s.HomeKitSwitch.Switch.On.OnValueRemoteUpdate(s.HomeKitSetValue) | ||
115 | } | ||
116 | |||
117 | func (s *UnifiedSwitch) HomeKitSetValue(state bool) { | ||
118 | log.Printf("Client updated state: %t", state) | ||
119 | if state { | ||
120 | s.MqttDevice.OutputOn(0) | ||
121 | } else { | ||
122 | s.MqttDevice.OutputOff(0) | ||
123 | } | ||
124 | } | ||