From a9eaa3c0f75a1dc4600f80cc855a0fe1bc89480f Mon Sep 17 00:00:00 2001 From: Sam Willcocks Date: Sun, 7 Nov 2021 17:57:27 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20Initial=20working=20disaster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + config.toml.example | 6 ++ go.mod | 13 +++ go.sum | 48 ++++++++++ main.go | 216 ++++++++++++++++++++++++++++++++++++++++++++ readme.md | 11 +++ 6 files changed, 295 insertions(+) create mode 100644 .gitignore create mode 100644 config.toml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 readme.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b6c096 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.toml diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..5dfb0bb --- /dev/null +++ b/config.toml.example @@ -0,0 +1,6 @@ +[mqtt] +Host = "192.168.0.123" + +[[targets]] +Host = "192.168.0.222" +Port = 161 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a87369d --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module code.int.wlcx.cc/apc2mqtt + +go 1.17 + +require ( + github.com/BurntSushi/toml v0.4.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.3.5 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/gosnmp/gosnmp v1.33.0 // indirect + github.com/sirupsen/logrus v1.8.1 // indirect + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 // indirect + golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e9f5e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= +github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.mqtt.golang v1.3.5 h1:sWtmgNxYM9P2sP+xEItMozsR3w0cqZFlqnNN1bdl41Y= +github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosnmp/gosnmp v1.33.0 h1:WNwN5Rj/9Y70VplIKXuaUiYVxdcaXhfAuLElKx4lnpU= +github.com/gosnmp/gosnmp v1.33.0/go.mod h1:QWTRprXN9haHFof3P96XTDYc46boCGAh5IXp0DniEx4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5ee4b8e --- /dev/null +++ b/main.go @@ -0,0 +1,216 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/BurntSushi/toml" + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/gosnmp/gosnmp" + log "github.com/sirupsen/logrus" +) + +// OIDs of interest. +// snmptranslate -m PowerNet-MIB -Pu -Tso | less +const ( + sPDUMasterConfigPDUName = ".1.3.6.1.4.1.318.1.1.4.3.3.0" + sPDUOutletName = ".1.3.6.1.4.1.318.1.1.4.5.2.1.3" + sPDUOutletCtl = ".1.3.6.1.4.1.318.1.1.4.4.2.1.3" + sPDUIdentSerialNumber = ".1.3.6.1.4.1.318.1.1.4.1.5.0" + sPDUIdentModelNumber = ".1.3.6.1.4.1.318.1.1.4.1.4.0" +) + +type targetConfig struct { + Host string + Port uint16 +} + +type config struct { + MQTT struct { + Host string + } + Targets []targetConfig +} + +func parseConfig(path string) (*config, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open config file: %w", err) + } + var conf config + dec := toml.NewDecoder(file) + if _, err := dec.Decode(&conf); err != nil { + return nil, fmt.Errorf("decoding config: %w", err) + } + + return &conf, nil +} + +func check(err error) { + if err != nil { + panic(err) + } +} + +type Outlet struct { + Name string + State bool +} + +type PDUState struct { + Name string + Serial string + Model string + Outlets []Outlet +} + +type PDUCommand struct { + Outlet int + State bool +} + +func runSNMP(host string, port uint16, stateCh chan PDUState, commandCh chan PDUCommand) { + snmp := &gosnmp.GoSNMP{ + Target: host, + Port: port, + Timeout: time.Duration(2) * time.Second, + Transport: "udp", + Community: "private", + Version: gosnmp.Version1, + Retries: 3, + } + err := snmp.Connect() + check(err) + defer snmp.Conn.Close() + poll := time.NewTicker(1 * time.Second) + for { + select { + case <-poll.C: + res, err := snmp.Get([]string{sPDUMasterConfigPDUName, sPDUIdentSerialNumber, sPDUIdentModelNumber}) + check(err) + state := PDUState{ + Name: string(res.Variables[0].Value.([]byte)), + Serial: string(res.Variables[1].Value.([]byte)), + Model: string(res.Variables[2].Value.([]byte)), + } + + outletNames, err := snmp.WalkAll(sPDUOutletName) + check(err) + + for _, val := range outletNames { + state.Outlets = append(state.Outlets, Outlet{ + Name: string(val.Value.([]byte)), + }) + } + + outletStates, err := snmp.WalkAll(sPDUOutletCtl) + check(err) + for i, val := range outletStates { + state.Outlets[i].State = val.Value.(int) == 1 + } + stateCh <- state + case cmd := <-commandCh: + var value int + if cmd.State { + value = 1 + } else { + value = 2 + } + res, err := snmp.Set([]gosnmp.SnmpPDU{{ + Value: value, + Name: fmt.Sprintf("%s.%d", sPDUOutletCtl, cmd.Outlet), + Type: gosnmp.Integer, + }}) + check(err) + if res.Error != gosnmp.NoError { + log.Errorf("error in snmp set: %s", res.Error) + } + } + } +} + +type HassDeviceConfig struct { + Name string `json:"name"` + Identifiers string `json:"identifiers"` + Model string `json:"model"` + Manufacturer string `json:"manufacturer"` +} +type HassSwitchConfig struct { + Name string `json:"name"` + CommandTopic string `json:"command_topic"` + StateTopic string `json:"state_topic"` + UniqueId string `json:"unique_id"` + Device HassDeviceConfig `json:"device"` +} + +func spawnTarget(target targetConfig, mqttClient mqtt.Client) { + stateCh := make(chan PDUState) + commandCh := make(chan PDUCommand) + var lastState PDUState + + go runSNMP(target.Host, target.Port, stateCh, commandCh) + + for state := range stateCh { + for i, outlet := range state.Outlets { + uid := fmt.Sprintf("apc_%s_%d", strings.ToLower(state.Serial), i) + topicBase := fmt.Sprintf("homeassistant/switch/%s/", uid) + if len(lastState.Outlets) == 0 || lastState.Outlets[i].Name != outlet.Name { + // Update hass config + hsc, err := json.Marshal(HassSwitchConfig{ + Name: outlet.Name, + CommandTopic: topicBase + "set", + StateTopic: topicBase + "state", + UniqueId: uid, + Device: HassDeviceConfig{ + Name: state.Name, + Identifiers: state.Serial, + Model: state.Model, + Manufacturer: "APC", + }, + }) + check(err) + mqttClient.Publish(topicBase+"config", 0, false, hsc) + } + if len(lastState.Outlets) == 0 { + // First time we've got a state, so subscribe to mqtt topic + mqttClient.Subscribe(topicBase+"set", 0, func(idx int) func(mqtt.Client, mqtt.Message) { + return func(_ mqtt.Client, message mqtt.Message) { + commandCh <- PDUCommand{ + Outlet: idx, + State: string(message.Payload()) == "ON", + } + } + }(i+1)) + } + var oState string + if outlet.State { + oState = "ON" + } else { + oState = "OFF" + } + mqttClient.Publish(topicBase+"state", 0, false, oState) + } + lastState = state + } +} +func main() { + var configpath = flag.String("conf", "config.toml", "Path to toml config file") + flag.Parse() + conf, err := parseConfig(*configpath) + check(err) + + mqttOpts := mqtt.NewClientOptions().AddBroker(fmt.Sprintf("tcp://%s:1883", conf.MQTT.Host)).SetClientID("apc2mqtt") + mqttClient := mqtt.NewClient(mqttOpts) + if token := mqttClient.Connect(); token.Wait() && token.Error() != nil { + panic(token.Error()) + } + + for _, target := range conf.Targets { + go spawnTarget(target, mqttClient) + } + select {} +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c28617a --- /dev/null +++ b/readme.md @@ -0,0 +1,11 @@ +# APC2MQTT + +Bridges between MQTT and SNMP to enable Home Assistant[hass] to control of a network enabled APC PDU. + +Uses MQTT Discovery[mqttdisco] to automatically configure entities and devices in Home Assistant - all you should need to do is point it at the PDU and MQTT server. + +Tested and working with: +- AP7921 + +[mqttdisco]: https://www.home-assistant.io/docs/mqtt/discovery/ +[hass]: https://www.home-assistant.io/