apc2mqtt/main.go

263 lines
6.4 KiB
Go

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"
)
var version = "unknown"
// 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
Port uint16
User string
Pass 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 getPDUState(snmp *gosnmp.GoSNMP) (*PDUState, error) {
res, err := snmp.Get([]string{sPDUMasterConfigPDUName, sPDUIdentSerialNumber, sPDUIdentModelNumber})
if err != nil {
return nil, fmt.Errorf("get PDU info: %w", 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)
if err != nil {
return nil, fmt.Errorf("walk PDU outlet names: %w", err)
}
for _, val := range outletNames {
state.Outlets = append(state.Outlets, Outlet{
Name: string(val.Value.([]byte)),
})
}
outletStates, err := snmp.WalkAll(sPDUOutletCtl)
if err != nil {
return nil, fmt.Errorf("walk PDU outlet states: %w", err)
}
for i, val := range outletStates {
state.Outlets[i].State = val.Value.(int) == 1
}
return &state, nil
}
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,
}
// Connect loop
connect_loop:
for {
log.Info("Opening SNMP connection")
if err := snmp.Connect(); err != nil {
log.Warnf("Error connecting SNMP target %s: `%s`. Sleeping...", host, err.Error())
time.Sleep(time.Second * 5)
continue // Try again
}
defer snmp.Conn.Close()
poll := time.NewTicker(1 * time.Second)
for {
select {
case <-poll.C:
state, err := getPDUState(snmp)
if err != nil {
log.Errorf("getting pdu state: %s", err.Error())
continue connect_loop // Reconnect
}
log.Debugf("Got state: %s", state)
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,
}})
if err != nil {
log.Errorf("setting pdu state: %s", err.Error())
}
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")
var verbose = flag.Bool("v", false, "Enable debug logging")
flag.Parse()
if *verbose {
log.SetLevel(log.DebugLevel)
}
log.Infof("Starting version %s", version)
conf, err := parseConfig(*configpath)
check(err)
mqttOpts := mqtt.NewClientOptions().AddBroker(
fmt.Sprintf("tcp://%s:%d", conf.MQTT.Host, conf.MQTT.Port),
).SetClientID("apc2mqtt").SetConnectTimeout(time.Second * 5)
if conf.MQTT.User != "" {
mqttOpts.SetUsername(conf.MQTT.User)
}
if conf.MQTT.Pass != "" {
mqttOpts.SetPassword(conf.MQTT.Pass)
}
mqttClient := mqtt.NewClient(mqttOpts)
for {
if token := mqttClient.Connect(); token.Wait() && token.Error() != nil {
log.Fatalf("connect mqtt: %s", token.Error())
}
for _, target := range conf.Targets {
go spawnTarget(target, mqttClient)
}
select {}
}
}