plugctl/plug.go

230 lines
6.4 KiB
Go

/*
Copyright 2023 Josh Deprez
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package plugctl provides an API for controlling Kasa smart home switches.
package plugctl
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
)
// const reqGetEMeter = `{"emeter":{"get_realtime":null}}`
// Plug provides an API for a single Kasa smart home plug or switch.
type Plug struct {
Addr string
}
// SetRelayState turns the plug on or off.
func (p *Plug) SetRelayState(on bool) error {
state := 0
if on {
state = 1
}
req := fmt.Sprintf(`{"system":{"set_relay_state":{"state":%d}}}`, state)
resp, err := p.roundTrip(req)
if err != nil {
return err
}
if resp.System == nil {
return fmt.Errorf("nil system in response")
}
if resp.System.SetRelayState == nil {
return fmt.Errorf("nil set_relay_state in system")
}
if errCode := resp.System.SetRelayState.ErrCode; errCode != 0 {
return fmt.Errorf("plug errcode %d", errCode)
}
return nil
}
// TurnOn turns the plug on.
func (p *Plug) TurnOn() error {
return p.SetRelayState(true)
}
// TurnOff turns the plug off.
func (p *Plug) TurnOff() error {
return p.SetRelayState(false)
}
// SysInfo gets the system information from the plug.
func (p *Plug) SysInfo() (*SysInfo, error) {
resp, err := p.roundTrip(`{"system":{"get_sysinfo":null}}`)
if err != nil {
return nil, err
}
if resp.System == nil {
return nil, fmt.Errorf("nil system in response")
}
if resp.System.GetSysInfo == nil {
return nil, fmt.Errorf("nil get_sysinfo in system")
}
return resp.System.GetSysInfo, nil
}
// RelayState reports whether the plug is currently on.
func (p *Plug) RelayState() (bool, error) {
si, err := p.SysInfo()
if err != nil {
return false, err
}
return si.RelayState == 1, nil
}
func (p *Plug) roundTrip(req string) (*response, error) {
conn, err := net.Dial("tcp", p.Addr)
if err != nil {
return nil, fmt.Errorf("dialing plug: %w", err)
}
defer conn.Close()
lp := []byte{0, 0, 0, 0}
binary.BigEndian.PutUint32(lp, uint32(len(req)))
// Start by writing length
if _, err := conn.Write(lp); err != nil {
return nil, fmt.Errorf("writing length: %w", err)
}
// Rest of the message is encrypted
enc := newEncrypter(conn)
if _, err := enc.Write([]byte(req)); err != nil {
return nil, fmt.Errorf("writing message: %w", err)
}
// Read length of response
if _, err := io.ReadFull(conn, lp); err != nil {
return nil, fmt.Errorf("reading length: %w", err)
}
rlen := binary.BigEndian.Uint32(lp)
// Read the encrypted response
buf := make([]byte, rlen)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
// Decrypt and parse the response
var resp *response
dec := json.NewDecoder(newDecrypter(bytes.NewReader(buf)))
if err := dec.Decode(&resp); err != nil {
return nil, fmt.Errorf("reading message: %w", err)
}
return resp, nil
}
// Implements TP Link's weaksauce encryption.
// Do NOT use for actually important secrets.
type decryptReader struct {
r io.Reader
c byte
}
func newDecrypter(r io.Reader) *decryptReader {
return &decryptReader{
r: r,
c: 0xab,
}
}
func (d *decryptReader) Read(b []byte) (int, error) {
n, err := d.r.Read(b)
for i, x := range b {
b[i] = d.c ^ x
d.c = x
}
return n, err
}
// Implements TP Link's weaksauce encryption.
// Do NOT use for actually important secrets.
type encryptWriter struct {
w io.Writer
c byte
}
func newEncrypter(w io.Writer) *encryptWriter {
return &encryptWriter{
w: w,
c: 0xab,
}
}
func (e *encryptWriter) Write(b []byte) (int, error) {
eb := make([]byte, len(b))
for i, x := range b {
e.c ^= x
eb[i] = e.c
}
return e.w.Write(eb)
}
type response struct {
System *systemMsg `json:"system,omitempty"`
// EMeter *eMeterMsg `json:"emeter,omitempty"`
}
// type eMeterMsg struct {
// GetRealtime *getRealtimeMsg `json:"get_realtime"`
// }
// type getRealtimeMsg struct{}
type systemMsg struct {
GetSysInfo *SysInfo `json:"get_sysinfo"`
SetRelayState *setRelayStateMsg `json:"set_relay_state,omitempty"`
}
type SysInfo struct {
SWVer string `json:"sw_ver,omitempty"` // e.g. "1.0.3 Build 210506 Rel.105435",
HWVer string `json:"hw_ver,omitempty"` // e.g. "1.0",
Model string `json:"model,omitempty"` // e.g. "KP105(AU)",
DeviceID string `json:"deviceId,omitempty"` // e.g. "8006CF0E89296D2A5BFBDE11C844BB2B1E2D2B7C",
OEMID string `json:"oemId,omitempty"` // e.g. "EAB15315847573797920EFE7E9000562",
HWID string `json:"hwId,omitempty"` // e.g. "120EF751A1A4F170831E6C1F57D97B38",
RSSI int `json:"rssi,omitempty"` // e.g. -58,
LongitudeI int `json:"longitude_i,omitempty"` // e.g. 1473005,
LatitudeI int `json:"latitude_i,omitempty"` // e.g. -428429,
Alias string `json:"alias,omitempty"` // e.g. "Car charger",
Status string `json:"status,omitempty"` // e.g. "new",
MICType string `json:"mic_type,omitempty"` // e.g. "IOT.SMARTPLUGSWITCH",
Feature string `json:"feature,omitempty"` // e.g. "TIM",
MAC string `json:"mac,omitempty"` // e.g. "00:5F:67:01:A5:50",
Updating int `json:"updating,omitempty"` // e.g. 0,
LEDOff int `json:"led_off,omitempty"` // e.g. 0,
OBDSrc string `json:"obd_src,omitempty"` // e.g. "tplink",
RelayState int `json:"relay_state,omitempty"` // e.g. 0,
OnTime int `json:"on_time,omitempty"` // e.g. 0,
ActiveMode string `json:"active_mode,omitempty"` // e.g. "schedule",
IconHash string `json:"icon_hash,omitempty"` // e.g. "",
DevName string `json:"dev_name,omitempty"` // e.g. "Smart Wi-Fi Plug",
NextAction struct {
Type int `json:"type,omitempty"`
} `json:"next_action,omitempty"` // e.g. {"type": -1},
NTCState int `json:"ntc_state,omitempty"` // e.g. 0,
ErrCode int `json:"err_code,omitempty"` // e.g. 0
}
type setRelayStateMsg struct {
ErrCode int `json:"err_code,omitempty"`
State int `json:"state,omitempty"`
}