Browse Source

Initial import

master
parent
commit
546976caf5
16 changed files with 6452 additions and 0 deletions
  1. +3991
    -0
      api/api.pb.go
  2. +744
    -0
      api/api.proto
  3. +24
    -0
      api/api_options.proto
  4. +54
    -0
      api/const.go
  5. +330
    -0
      api/marshal.go
  6. +24
    -0
      api/marshal_test.go
  7. +132
    -0
      camera.go
  8. +515
    -0
      client.go
  9. +47
    -0
      cmd/client.go
  10. +62
    -0
      cmd/esphome-grab/main.go
  11. +54
    -0
      cmd/esphome-list-entities/main.go
  12. +31
    -0
      cmd/esphome-logs/main.go
  13. +426
    -0
      entity.go
  14. +11
    -0
      error.go
  15. +5
    -0
      go.mod
  16. +2
    -0
      go.sum

+ 3991
- 0
api/api.pb.go
File diff suppressed because it is too large
View File


+ 744
- 0
api/api.proto View File

@ -0,0 +1,744 @@
syntax = "proto3";
import "api_options.proto";
service APIConnection {
rpc hello (HelloRequest) returns (HelloResponse) {
option (needs_setup_connection) = false;
option (needs_authentication) = false;
}
rpc connect (ConnectRequest) returns (ConnectResponse) {
option (needs_setup_connection) = false;
option (needs_authentication) = false;
}
rpc disconnect (DisconnectRequest) returns (DisconnectResponse) {
option (needs_setup_connection) = false;
option (needs_authentication) = false;
}
rpc ping (PingRequest) returns (PingResponse) {
option (needs_setup_connection) = false;
option (needs_authentication) = false;
}
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
option (needs_authentication) = false;
}
rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
rpc subscribe_homeassistant_services (SubscribeHomeassistantServicesRequest) returns (void) {}
rpc subscribe_home_assistant_states (SubscribeHomeAssistantStatesRequest) returns (void) {}
rpc get_time (GetTimeRequest) returns (GetTimeResponse) {
option (needs_authentication) = false;
}
rpc execute_service (ExecuteServiceRequest) returns (void) {}
rpc cover_command (CoverCommandRequest) returns (void) {}
rpc fan_command (FanCommandRequest) returns (void) {}
rpc light_command (LightCommandRequest) returns (void) {}
rpc switch_command (SwitchCommandRequest) returns (void) {}
rpc camera_image (CameraImageRequest) returns (void) {}
rpc climate_command (ClimateCommandRequest) returns (void) {}
}
// ==================== BASE PACKETS ====================
// The Home Assistant protocol is structured as a simple
// TCP socket with short binary messages encoded in the protocol buffers format
// First, a message in this protocol has a specific format:
// * VarInt denoting the size of the message object. (type is not part of this)
// * VarInt denoting the type of message.
// * The message object encoded as a ProtoBuf message
// The connection is established in 4 steps:
// * First, the client connects to the server and sends a "Hello Request" identifying itself
// * The server responds with a "Hello Response" and selects the protocol version
// * After receiving this message, the client attempts to authenticate itself using
// the password and a "Connect Request"
// * The server responds with a "Connect Response" and notifies of invalid password.
// If anything in this initial process fails, the connection must immediately closed
// by both sides and _no_ disconnection message is to be sent.
// Message sent at the beginning of each connection
// Can only be sent by the client and only at the beginning of the connection
message HelloRequest {
option (id) = 1;
option (source) = SOURCE_CLIENT;
option (no_delay) = true;
// Description of client (like User Agent)
// For example "Home Assistant"
// Not strictly necessary to send but nice for debugging
// purposes.
string client_info = 1;
}
// Confirmation of successful connection request.
// Can only be sent by the server and only at the beginning of the connection
message HelloResponse {
option (id) = 2;
option (source) = SOURCE_SERVER;
option (no_delay) = true;
// The version of the API to use. The _client_ (for example Home Assistant) needs to check
// for compatibility and if necessary adopt to an older API.
// Major is for breaking changes in the base protocol - a mismatch will lead to immediate disconnect_client_
// Minor is for breaking changes in individual messages - a mismatch will lead to a warning message
uint32 api_version_major = 1;
uint32 api_version_minor = 2;
// A string identifying the server (ESP); like client info this may be empty
// and only exists for debugging/logging purposes.
// For example "ESPHome v1.10.0 on ESP8266"
string server_info = 3;
}
// Message sent at the beginning of each connection to authenticate the client
// Can only be sent by the client and only at the beginning of the connection
message ConnectRequest {
option (id) = 3;
option (source) = SOURCE_CLIENT;
option (no_delay) = true;
// The password to log in with
string password = 1;
}
// Confirmation of successful connection. After this the connection is available for all traffic.
// Can only be sent by the server and only at the beginning of the connection
message ConnectResponse {
option (id) = 4;
option (source) = SOURCE_SERVER;
option (no_delay) = true;
bool invalid_password = 1;
}
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
option (id) = 5;
option (source) = SOURCE_BOTH;
option (no_delay) = true;
// Do not close the connection before the acknowledgement arrives
}
message DisconnectResponse {
option (id) = 6;
option (source) = SOURCE_BOTH;
option (no_delay) = true;
// Empty - Both parties are required to close the connection after this
// message has been received.
}
message PingRequest {
option (id) = 7;
option (source) = SOURCE_BOTH;
// Empty
}
message PingResponse {
option (id) = 8;
option (source) = SOURCE_BOTH;
// Empty
}
message DeviceInfoRequest {
option (id) = 9;
option (source) = SOURCE_CLIENT;
// Empty
}
message DeviceInfoResponse {
option (id) = 10;
option (source) = SOURCE_SERVER;
bool uses_password = 1;
// The name of the node, given by "App.set_name()"
string name = 2;
// The mac address of the device. For example "AC:BC:32:89:0E:A9"
string mac_address = 3;
// A string describing the ESPHome version. For example "1.10.0"
string esphome_version = 4;
// A string describing the date of compilation, this is generated by the compiler
// and therefore may not be in the same format all the time.
// If the user isn't using ESPHome, this will also not be set.
string compilation_time = 5;
// The model of the board. For example NodeMCU
string model = 6;
bool has_deep_sleep = 7;
}
message ListEntitiesRequest {
option (id) = 11;
option (source) = SOURCE_CLIENT;
// Empty
}
message ListEntitiesDoneResponse {
option (id) = 19;
option (source) = SOURCE_SERVER;
option (no_delay) = true;
// Empty
}
message SubscribeStatesRequest {
option (id) = 20;
option (source) = SOURCE_CLIENT;
// Empty
}
// ==================== BINARY SENSOR ====================
message ListEntitiesBinarySensorResponse {
option (id) = 12;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BINARY_SENSOR";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
string device_class = 5;
bool is_status_binary_sensor = 6;
}
message BinarySensorStateResponse {
option (id) = 21;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BINARY_SENSOR";
option (no_delay) = true;
fixed32 key = 1;
bool state = 2;
// If the binary sensor does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
bool missing_state = 3;
}
// ==================== COVER ====================
message ListEntitiesCoverResponse {
option (id) = 13;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_COVER";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
bool assumed_state = 5;
bool supports_position = 6;
bool supports_tilt = 7;
string device_class = 8;
}
enum LegacyCoverState {
LEGACY_COVER_STATE_OPEN = 0;
LEGACY_COVER_STATE_CLOSED = 1;
}
enum CoverOperation {
COVER_OPERATION_IDLE = 0;
COVER_OPERATION_IS_OPENING = 1;
COVER_OPERATION_IS_CLOSING = 2;
}
message CoverStateResponse {
option (id) = 22;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_COVER";
option (no_delay) = true;
fixed32 key = 1;
// legacy: state has been removed in 1.13
// clients/servers must still send/accept it until the next protocol change
LegacyCoverState legacy_state = 2;
float position = 3;
float tilt = 4;
CoverOperation current_operation = 5;
}
enum LegacyCoverCommand {
LEGACY_COVER_COMMAND_OPEN = 0;
LEGACY_COVER_COMMAND_CLOSE = 1;
LEGACY_COVER_COMMAND_STOP = 2;
}
message CoverCommandRequest {
option (id) = 30;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_COVER";
option (no_delay) = true;
fixed32 key = 1;
// legacy: command has been removed in 1.13
// clients/servers must still send/accept it until the next protocol change
bool has_legacy_command = 2;
LegacyCoverCommand legacy_command = 3;
bool has_position = 4;
float position = 5;
bool has_tilt = 6;
float tilt = 7;
bool stop = 8;
}
// ==================== FAN ====================
message ListEntitiesFanResponse {
option (id) = 14;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_FAN";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
bool supports_oscillation = 5;
bool supports_speed = 6;
}
enum FanSpeed {
FAN_SPEED_LOW = 0;
FAN_SPEED_MEDIUM = 1;
FAN_SPEED_HIGH = 2;
}
message FanStateResponse {
option (id) = 23;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_FAN";
option (no_delay) = true;
fixed32 key = 1;
bool state = 2;
bool oscillating = 3;
FanSpeed speed = 4;
}
message FanCommandRequest {
option (id) = 31;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_FAN";
option (no_delay) = true;
fixed32 key = 1;
bool has_state = 2;
bool state = 3;
bool has_speed = 4;
FanSpeed speed = 5;
bool has_oscillating = 6;
bool oscillating = 7;
}
// ==================== LIGHT ====================
message ListEntitiesLightResponse {
option (id) = 15;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_LIGHT";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
bool supports_brightness = 5;
bool supports_rgb = 6;
bool supports_white_value = 7;
bool supports_color_temperature = 8;
float min_mireds = 9;
float max_mireds = 10;
repeated string effects = 11;
}
message LightStateResponse {
option (id) = 24;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_LIGHT";
option (no_delay) = true;
fixed32 key = 1;
bool state = 2;
float brightness = 3;
float red = 4;
float green = 5;
float blue = 6;
float white = 7;
float color_temperature = 8;
string effect = 9;
}
message LightCommandRequest {
option (id) = 32;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_LIGHT";
option (no_delay) = true;
fixed32 key = 1;
bool has_state = 2;
bool state = 3;
bool has_brightness = 4;
float brightness = 5;
bool has_rgb = 6;
float red = 7;
float green = 8;
float blue = 9;
bool has_white = 10;
float white = 11;
bool has_color_temperature = 12;
float color_temperature = 13;
bool has_transition_length = 14;
uint32 transition_length = 15;
bool has_flash_length = 16;
uint32 flash_length = 17;
bool has_effect = 18;
string effect = 19;
}
// ==================== SENSOR ====================
message ListEntitiesSensorResponse {
option (id) = 16;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENSOR";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
string icon = 5;
string unit_of_measurement = 6;
int32 accuracy_decimals = 7;
bool force_update = 8;
}
message SensorStateResponse {
option (id) = 25;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENSOR";
option (no_delay) = true;
fixed32 key = 1;
float state = 2;
// If the sensor does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
bool missing_state = 3;
}
// ==================== SWITCH ====================
message ListEntitiesSwitchResponse {
option (id) = 17;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SWITCH";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
string icon = 5;
bool assumed_state = 6;
}
message SwitchStateResponse {
option (id) = 26;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SWITCH";
option (no_delay) = true;
fixed32 key = 1;
bool state = 2;
}
message SwitchCommandRequest {
option (id) = 33;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SWITCH";
option (no_delay) = true;
fixed32 key = 1;
bool state = 2;
}
// ==================== TEXT SENSOR ====================
message ListEntitiesTextSensorResponse {
option (id) = 18;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_TEXT_SENSOR";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
string icon = 5;
}
message TextSensorStateResponse {
option (id) = 27;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_TEXT_SENSOR";
option (no_delay) = true;
fixed32 key = 1;
string state = 2;
// If the text sensor does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
bool missing_state = 3;
}
// ==================== SUBSCRIBE LOGS ====================
enum LogLevel {
LOG_LEVEL_NONE = 0;
LOG_LEVEL_ERROR = 1;
LOG_LEVEL_WARN = 2;
LOG_LEVEL_INFO = 3;
LOG_LEVEL_DEBUG = 4;
LOG_LEVEL_VERBOSE = 5;
LOG_LEVEL_VERY_VERBOSE = 6;
}
message SubscribeLogsRequest {
option (id) = 28;
option (source) = SOURCE_CLIENT;
LogLevel level = 1;
bool dump_config = 2;
}
message SubscribeLogsResponse {
option (id) = 29;
option (source) = SOURCE_SERVER;
option (log) = false;
option (no_delay) = false;
LogLevel level = 1;
string tag = 2;
string message = 3;
bool send_failed = 4;
}
// ==================== HOMEASSISTANT.SERVICE ====================
message SubscribeHomeassistantServicesRequest {
option (id) = 34;
option (source) = SOURCE_CLIENT;
}
message HomeassistantServiceMap {
string key = 1;
string value = 2;
}
message HomeassistantServiceResponse {
option (id) = 35;
option (source) = SOURCE_SERVER;
option (no_delay) = true;
string service = 1;
repeated HomeassistantServiceMap data = 2;
repeated HomeassistantServiceMap data_template = 3;
repeated HomeassistantServiceMap variables = 4;
bool is_event = 5;
}
// ==================== IMPORT HOME ASSISTANT STATES ====================
// 1. Client sends SubscribeHomeAssistantStatesRequest
// 2. Server responds with zero or more SubscribeHomeAssistantStateResponse (async)
// 3. Client sends HomeAssistantStateResponse for state changes.
message SubscribeHomeAssistantStatesRequest {
option (id) = 38;
option (source) = SOURCE_CLIENT;
}
message SubscribeHomeAssistantStateResponse {
option (id) = 39;
option (source) = SOURCE_SERVER;
string entity_id = 1;
}
message HomeAssistantStateResponse {
option (id) = 40;
option (source) = SOURCE_CLIENT;
option (no_delay) = true;
string entity_id = 1;
string state = 2;
}
// ==================== IMPORT TIME ====================
message GetTimeRequest {
option (id) = 36;
option (source) = SOURCE_BOTH;
}
message GetTimeResponse {
option (id) = 37;
option (source) = SOURCE_BOTH;
option (no_delay) = true;
fixed32 epoch_seconds = 1;
}
// ==================== USER-DEFINES SERVICES ====================
enum ServiceArgType {
SERVICE_ARG_TYPE_BOOL = 0;
SERVICE_ARG_TYPE_INT = 1;
SERVICE_ARG_TYPE_FLOAT = 2;
SERVICE_ARG_TYPE_STRING = 3;
SERVICE_ARG_TYPE_BOOL_ARRAY = 4;
SERVICE_ARG_TYPE_INT_ARRAY = 5;
SERVICE_ARG_TYPE_FLOAT_ARRAY = 6;
SERVICE_ARG_TYPE_STRING_ARRAY = 7;
}
message ListEntitiesServicesArgument {
string name = 1;
ServiceArgType type = 2;
}
message ListEntitiesServicesResponse {
option (id) = 41;
option (source) = SOURCE_SERVER;
string name = 1;
fixed32 key = 2;
repeated ListEntitiesServicesArgument args = 3;
}
message ExecuteServiceArgument {
bool bool_ = 1;
int32 legacy_int = 2;
float float_ = 3;
string string_ = 4;
// ESPHome 1.14 (api v1.3) make int a signed value
sint32 int_ = 5;
repeated bool bool_array = 6 [packed=false];
repeated sint32 int_array = 7 [packed=false];
repeated float float_array = 8 [packed=false];
repeated string string_array = 9;
}
message ExecuteServiceRequest {
option (id) = 42;
option (source) = SOURCE_CLIENT;
option (no_delay) = true;
fixed32 key = 1;
repeated ExecuteServiceArgument args = 2;
}
// ==================== CAMERA ====================
message ListEntitiesCameraResponse {
option (id) = 43;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_ESP32_CAMERA";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
}
message CameraImageResponse {
option (id) = 44;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_ESP32_CAMERA";
fixed32 key = 1;
bytes data = 2;
bool done = 3;
}
message CameraImageRequest {
option (id) = 45;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_ESP32_CAMERA";
option (no_delay) = true;
bool single = 1;
bool stream = 2;
}
// ==================== CLIMATE ====================
enum ClimateMode {
CLIMATE_MODE_OFF = 0;
CLIMATE_MODE_AUTO = 1;
CLIMATE_MODE_COOL = 2;
CLIMATE_MODE_HEAT = 3;
CLIMATE_MODE_FAN_ONLY = 4;
CLIMATE_MODE_DRY = 5;
}
enum ClimateFanMode {
CLIMATE_FAN_ON = 0;
CLIMATE_FAN_OFF = 1;
CLIMATE_FAN_AUTO = 2;
CLIMATE_FAN_LOW = 3;
CLIMATE_FAN_MEDIUM = 4;
CLIMATE_FAN_HIGH = 5;
CLIMATE_FAN_MIDDLE = 6;
CLIMATE_FAN_FOCUS = 7;
CLIMATE_FAN_DIFFUSE = 8;
}
enum ClimateSwingMode {
CLIMATE_SWING_OFF = 0;
CLIMATE_SWING_BOTH = 1;
CLIMATE_SWING_VERTICAL = 2;
CLIMATE_SWINT_HORIZONTAL = 3;
}
enum ClimateAction {
CLIMATE_ACTION_OFF = 0;
// values same as mode for readability
CLIMATE_ACTION_COOLING = 2;
CLIMATE_ACTION_HEATING = 3;
CLIMATE_ACTION_IDLE = 4;
CLIMATE_ACTION_DRYING = 5;
CLIMATE_ACTION_FAN = 6;
}
message ListEntitiesClimateResponse {
option (id) = 46;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_CLIMATE";
string object_id = 1;
fixed32 key = 2;
string name = 3;
string unique_id = 4;
bool supports_current_temperature = 5;
bool supports_two_point_target_temperature = 6;
repeated ClimateMode supported_modes = 7;
float visual_min_temperature = 8;
float visual_max_temperature = 9;
float visual_temperature_step = 10;
bool supports_away = 11;
bool supports_action = 12;
repeated ClimateFanMode supported_fan_modes = 13;
repeated ClimateSwingMode supported_swing_modes = 14;
}
message ClimateStateResponse {
option (id) = 47;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_CLIMATE";
option (no_delay) = true;
fixed32 key = 1;
ClimateMode mode = 2;
float current_temperature = 3;
float target_temperature = 4;
float target_temperature_low = 5;
float target_temperature_high = 6;
bool away = 7;
ClimateAction action = 8;
ClimateFanMode fan_mode = 9;
ClimateSwingMode swing_mode = 10;
}
message ClimateCommandRequest {
option (id) = 48;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_CLIMATE";
option (no_delay) = true;
fixed32 key = 1;
bool has_mode = 2;
ClimateMode mode = 3;
bool has_target_temperature = 4;
float target_temperature = 5;
bool has_target_temperature_low = 6;
float target_temperature_low = 7;
bool has_target_temperature_high = 8;
float target_temperature_high = 9;
bool has_away = 10;
bool away = 11;
bool has_fan_mode = 12;
ClimateFanMode fan_mode = 13;
bool has_swing_mode = 14;
ClimateSwingMode swing_mode = 15;
}

+ 24
- 0
api/api_options.proto View File

@ -0,0 +1,24 @@
syntax = "proto2";
import "google/protobuf/descriptor.proto";
enum APISourceType {
SOURCE_BOTH = 0;
SOURCE_SERVER = 1;
SOURCE_CLIENT = 2;
}
message void {}
extend google.protobuf.MethodOptions {
optional bool needs_setup_connection = 1038 [default=true];
optional bool needs_authentication = 1039 [default=true];
}
extend google.protobuf.MessageOptions {
optional uint32 id = 1036 [default=0];
optional APISourceType source = 1037 [default=SOURCE_BOTH];
optional string ifdef = 1038;
optional bool log = 1039 [default=true];
optional bool no_delay = 1040 [default=false];
}

+ 54
- 0
api/const.go View File

@ -0,0 +1,54 @@
package api
// API request/response types.
const (
UnknownType = iota
HelloRequestType
HelloResponseType
ConnectRequestType
ConnectResponseType
DisconnectRequestType
DisconnectResponseType
PingRequestType
PingResponseType
DeviceInfoRequestType
DeviceInfoResponseType
ListEntitiesRequestType
ListEntitiesBinarySensorResponseType
ListEntitiesCoverResponseType
ListEntitiesFanResponseType
ListEntitiesLightResponseType
ListEntitiesSensorResponseType
ListEntitiesSwitchResponseType
ListEntitiesTextSensorResponseType
ListEntitiesDoneResponseType
SubscribeStatesRequestType
BinarySensorStateResponseType
CoverStateResponseType
FanStateResponseType
LightStateResponseType
SensorStateResponseType
SwitchStateResponseType
TextSensorStateResponseType
SubscribeLogsRequestType
SubscribeLogsResponseType
CoverCommandRequestType
FanCommandRequestType
LightCommandRequestType
SwitchCommandRequestType
SubscribeHomeAssistantServicesRequestType
HomeAssistantServiceResponseType
GetTimeRequestType
GetTimeResponseType
SubscribeHomeAssistantStatesRequestType
SubscribeHomeAssistantStateResponseType
HomeAssistantStateResponseType
ListEntitiesServicesResponseType
ExecuteServiceRequestType
ListEntitiesCameraResponseType
CameraImageResponseType
CameraImageRequestType
ListEntitiesClimateResponseType
ClimateStateResponseType
ClimateCommandRequestType
)

+ 330
- 0
api/marshal.go View File

@ -0,0 +1,330 @@
package api
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/golang/protobuf/proto"
)
var messageType = map[int]interface{}{
1: HelloRequest{},
2: HelloResponse{},
3: ConnectRequest{},
4: ConnectResponse{},
5: DisconnectRequest{},
6: DisconnectResponse{},
7: PingRequest{},
8: PingResponse{},
9: DeviceInfoRequest{},
10: DeviceInfoResponse{},
11: ListEntitiesRequest{},
12: ListEntitiesBinarySensorResponse{},
13: ListEntitiesCoverResponse{},
14: ListEntitiesFanResponse{},
15: ListEntitiesLightResponse{},
16: ListEntitiesSensorResponse{},
17: ListEntitiesSwitchResponse{},
18: ListEntitiesTextSensorResponse{},
19: ListEntitiesDoneResponse{},
20: SubscribeStatesRequest{},
21: BinarySensorStateResponse{},
22: CoverStateResponse{},
23: FanStateResponse{},
24: LightStateResponse{},
25: SensorStateResponse{},
26: SwitchStateResponse{},
27: TextSensorStateResponse{},
28: SubscribeLogsRequest{},
29: SubscribeLogsResponse{},
30: CoverCommandRequest{},
31: FanCommandRequest{},
32: LightCommandRequest{},
33: SwitchCommandRequest{},
34: SubscribeHomeassistantServicesRequest{},
35: HomeassistantServiceResponse{},
36: GetTimeRequest{},
37: GetTimeResponse{},
38: SubscribeHomeAssistantStatesRequest{},
39: SubscribeHomeAssistantStateResponse{},
40: HomeAssistantStateResponse{},
41: ListEntitiesServicesResponse{},
42: ExecuteServiceRequest{},
43: ListEntitiesCameraResponse{},
44: CameraImageResponse{},
45: CameraImageRequest{},
46: ListEntitiesClimateResponse{},
47: ClimateStateResponse{},
48: ClimateCommandRequest{},
}
func Marshal(message proto.Message) ([]byte, error) {
encoded, err := proto.Marshal(message)
if err != nil {
return nil, err
}
var (
packed = make([]byte, len(encoded)+17)
n = 1
)
// Write encoded message length
n += binary.PutUvarint(packed[n:], uint64(len(encoded)))
// Write message type
n += binary.PutUvarint(packed[n:], TypeOf(message))
// Write message
copy(packed[n:], encoded)
n += len(encoded)
return packed[:n], nil
}
func ReadMessage(r *bufio.Reader) (proto.Message, error) {
b, err := r.ReadByte()
if err != nil {
return nil, err
}
if b != 0x00 {
return nil, errors.New("api: protocol error: expected null byte")
}
// Read encoded message length
length, err := binary.ReadUvarint(r)
if err != nil {
return nil, err
}
// Read encoded message type
kind, err := binary.ReadUvarint(r)
if err != nil {
return nil, err
}
// Read encoded message
encoded := make([]byte, length)
if _, err = io.ReadFull(r, encoded); err != nil {
return nil, err
}
message := newMessage(kind)
if message == nil {
return nil, fmt.Errorf("api: protocol error: unknown message type %#x", kind)
}
if err = proto.Unmarshal(encoded, message); err != nil {
return nil, err
}
return message, nil
}
func TypeOf(value interface{}) uint64 {
switch value.(type) {
case HelloRequest, *HelloRequest:
return HelloRequestType
case HelloResponse, *HelloResponse:
return HelloResponseType
case ConnectRequest, *ConnectRequest:
return ConnectRequestType
case ConnectResponse, *ConnectResponse:
return ConnectResponseType
case DisconnectRequest, *DisconnectRequest:
return DisconnectRequestType
case DisconnectResponse, *DisconnectResponse:
return DisconnectResponseType
case PingRequest, *PingRequest:
return PingRequestType
case PingResponse, *PingResponse:
return PingResponseType
case DeviceInfoRequest, *DeviceInfoRequest:
return DeviceInfoRequestType
case DeviceInfoResponse, *DeviceInfoResponse:
return DeviceInfoResponseType
case ListEntitiesRequest, *ListEntitiesRequest:
return ListEntitiesRequestType
case ListEntitiesBinarySensorResponse, *ListEntitiesBinarySensorResponse:
return ListEntitiesBinarySensorResponseType
case ListEntitiesCoverResponse, *ListEntitiesCoverResponse:
return ListEntitiesCoverResponseType
case ListEntitiesFanResponse, *ListEntitiesFanResponse:
return ListEntitiesFanResponseType
case ListEntitiesLightResponse, *ListEntitiesLightResponse:
return ListEntitiesLightResponseType
case ListEntitiesSensorResponse, *ListEntitiesSensorResponse:
return ListEntitiesSensorResponseType
case ListEntitiesSwitchResponse, *ListEntitiesSwitchResponse:
return ListEntitiesSwitchResponseType
case ListEntitiesTextSensorResponse, *ListEntitiesTextSensorResponse:
return ListEntitiesTextSensorResponseType
case ListEntitiesDoneResponse, *ListEntitiesDoneResponse:
return ListEntitiesDoneResponseType
case SubscribeStatesRequest, *SubscribeStatesRequest:
return SubscribeStatesRequestType
case BinarySensorStateResponse, *BinarySensorStateResponse:
return BinarySensorStateResponseType
case CoverStateResponse, *CoverStateResponse:
return CoverStateResponseType
case FanStateResponse, *FanStateResponse:
return FanStateResponseType
case LightStateResponse, *LightStateResponse:
return LightStateResponseType
case SensorStateResponse, *SensorStateResponse:
return SensorStateResponseType
case SwitchStateResponse, *SwitchStateResponse:
return SwitchStateResponseType
case TextSensorStateResponse, *TextSensorStateResponse:
return TextSensorStateResponseType
case SubscribeLogsRequest, *SubscribeLogsRequest:
return SubscribeLogsRequestType
case SubscribeLogsResponse, *SubscribeLogsResponse:
return SubscribeLogsResponseType
case CoverCommandRequest, *CoverCommandRequest:
return CoverCommandRequestType
case FanCommandRequest, *FanCommandRequest:
return FanCommandRequestType
case LightCommandRequest, *LightCommandRequest:
return LightCommandRequestType
case SwitchCommandRequest, *SwitchCommandRequest:
return SwitchCommandRequestType
case SubscribeHomeassistantServicesRequest, *SubscribeHomeassistantServicesRequest:
return SubscribeHomeAssistantServicesRequestType
case HomeassistantServiceResponse, *HomeassistantServiceResponse:
return HomeAssistantServiceResponseType
case GetTimeRequest, *GetTimeRequest:
return GetTimeRequestType
case GetTimeResponse, *GetTimeResponse:
return GetTimeResponseType
case SubscribeHomeAssistantStatesRequest, *SubscribeHomeAssistantStatesRequest:
return SubscribeHomeAssistantStatesRequestType
case SubscribeHomeAssistantStateResponse, *SubscribeHomeAssistantStateResponse:
return SubscribeHomeAssistantStateResponseType
case HomeAssistantStateResponse, *HomeAssistantStateResponse:
return HomeAssistantStateResponseType
case ListEntitiesServicesResponse, *ListEntitiesServicesResponse:
return ListEntitiesServicesResponseType
case ExecuteServiceRequest, *ExecuteServiceRequest:
return ExecuteServiceRequestType
case ListEntitiesCameraResponse, *ListEntitiesCameraResponse:
return ListEntitiesCameraResponseType
case CameraImageResponse, *CameraImageResponse:
return CameraImageResponseType
case CameraImageRequest, *CameraImageRequest:
return CameraImageRequestType
case ListEntitiesClimateResponse, *ListEntitiesClimateResponse:
return ListEntitiesClimateResponseType
case ClimateStateResponse, *ClimateStateResponse:
return ClimateStateResponseType
case ClimateCommandRequest, *ClimateCommandRequest:
return ClimateCommandRequestType
default:
return UnknownType
}
}
func newMessage(kind uint64) proto.Message {
switch kind {
case 1:
return new(HelloRequest)
case 2:
return new(HelloResponse)
case 3:
return new(ConnectRequest)
case 4:
return new(ConnectResponse)
case 5:
return new(DisconnectRequest)
case 6:
return new(DisconnectResponse)
case 7:
return new(PingRequest)
case 8:
return new(PingResponse)
case 9:
return new(DeviceInfoRequest)
case 10:
return new(DeviceInfoResponse)
case 11:
return new(ListEntitiesRequest)
case 12:
return new(ListEntitiesBinarySensorResponse)
case 13:
return new(ListEntitiesCoverResponse)
case 14:
return new(ListEntitiesFanResponse)
case 15:
return new(ListEntitiesLightResponse)
case 16:
return new(ListEntitiesSensorResponse)
case 17:
return new(ListEntitiesSwitchResponse)
case 18:
return new(ListEntitiesTextSensorResponse)
case 19:
return new(ListEntitiesDoneResponse)
case 20:
return new(SubscribeStatesRequest)
case 21:
return new(BinarySensorStateResponse)
case 22:
return new(CoverStateResponse)
case 23:
return new(FanStateResponse)
case 24:
return new(LightStateResponse)
case 25:
return new(SensorStateResponse)
case 26:
return new(SwitchStateResponse)
case 27:
return new(TextSensorStateResponse)
case 28:
return new(SubscribeLogsRequest)
case 29:
return new(SubscribeLogsResponse)
case 30:
return new(CoverCommandRequest)
case 31:
return new(FanCommandRequest)
case 32:
return new(LightCommandRequest)
case 33:
return new(SwitchCommandRequest)
case 34:
return new(SubscribeHomeassistantServicesRequest)
case 35:
return new(HomeassistantServiceResponse)
case 36:
return new(GetTimeRequest)
case 37:
return new(GetTimeResponse)
case 38:
return new(SubscribeHomeAssistantStatesRequest)
case 39:
return new(SubscribeHomeAssistantStateResponse)
case 40:
return new(HomeAssistantStateResponse)
case 41:
return new(ListEntitiesServicesResponse)
case 42:
return new(ExecuteServiceRequest)
case 43:
return new(ListEntitiesCameraResponse)
case 44:
return new(CameraImageResponse)
case 45:
return new(CameraImageRequest)
case 46:
return new(ListEntitiesClimateResponse)
case 47:
return new(ClimateStateResponse)
case 48:
return new(ClimateCommandRequest)
default:
return nil
}
}

+ 24
- 0
api/marshal_test.go View File

@ -0,0 +1,24 @@
package api
import (
"bufio"
"bytes"
"encoding/hex"
"testing"
)
func TestReadMessage(t *testing.T) {
var (
bb = bytes.NewBuffer(testHelloResponseBytes)
br = bufio.NewReader(bb)
)
m, err := ReadMessage(br)
if err != nil {
t.Fatal(err)
}
t.Logf("%T: %+v", m, m)
}
var (
testHelloResponseBytes, _ = hex.DecodeString("001f02080110031a1963616d657261302028657370686f6d652076312e31342e3329")
)

+ 132
- 0
camera.go View File

@ -0,0 +1,132 @@
package esphome
import (
"bytes"
"image"
"image/jpeg"
"time"
"github.com/golang/protobuf/proto"
"maze.io/esphome/api"
)
// Camera is an ESP32 camera.
type Camera struct {
Entity
}
func newCamera(client *Client, entity *api.ListEntitiesCameraResponse) *Camera {
return &Camera{
Entity: Entity{
Name: entity.Name,
ObjectID: entity.ObjectId,
UniqueID: entity.UniqueId,
Key: entity.Key,
client: client,
},
}
}
// Image grabs one image frame from the camera.
func (entity *Camera) Image() (image.Image, error) {
if err := entity.client.sendTimeout(&api.CameraImageRequest{
Stream: true,
}, entity.client.Timeout); err != nil {
return nil, err
}
var (
in = make(chan proto.Message, 1)
out = make(chan []byte)
)
entity.client.waitMutex.Lock()
entity.client.wait[api.CameraImageResponseType] = in
entity.client.waitMutex.Unlock()
go func(in <-chan proto.Message, out chan []byte) {
for message := range in {
if message, ok := message.(*api.CameraImageResponse); ok {
out <- message.Data
if message.Done {
close(out)
return
}
}
}
}(in, out)
var buffer = new(bytes.Buffer)
for chunk := range out {
buffer.Write(chunk)
}
entity.client.waitMutex.Lock()
delete(entity.client.wait, api.CameraImageResponseType)
entity.client.waitMutex.Unlock()
return jpeg.Decode(buffer)
}
// Stream returns a channel with raw image frame buffers.
func (entity *Camera) Stream() (<-chan *bytes.Buffer, error) {
if err := entity.client.sendTimeout(&api.CameraImageRequest{
Stream: true,
}, entity.client.Timeout); err != nil {
return nil, err
}
in := make(chan proto.Message, 1)
entity.client.waitMutex.Lock()
entity.client.wait[api.CameraImageResponseType] = in
entity.client.waitMutex.Unlock()
out := make(chan *bytes.Buffer)
go func(in <-chan proto.Message, out chan<- *bytes.Buffer) {
var (
ticker = time.NewTicker(time.Second)
buffer = new(bytes.Buffer)
)
defer ticker.Stop()
for {
select {
case message := <-in:
frame := message.(*api.CameraImageResponse)
buffer.Write(frame.Data)
if frame.Done {
out <- buffer
buffer = new(bytes.Buffer)
}
case <-ticker.C:
if err := entity.client.sendTimeout(&api.CameraImageRequest{
Stream: true,
}, entity.client.Timeout); err != nil {
close(out)
return
}
}
}
}(in, out)
return out, nil
}
// ImageStream is like Stream, returning decoded frame images.
func (entity *Camera) ImageStream() (<-chan image.Image, error) {
in, err := entity.Stream()
if err != nil {
return nil, err
}
out := make(chan image.Image)
go func(in <-chan *bytes.Buffer, out chan image.Image) {
defer close(out)
for frame := range in {
if i, err := jpeg.Decode(frame); err == nil {
out <- i
}
}
}(in, out)
return out, nil
}

+ 515
- 0
client.go View File

@ -0,0 +1,515 @@
package esphome
import (
"bufio"
"fmt"
"net"
"sync"
"time"
"maze.io/esphome/api"
proto "github.com/golang/protobuf/proto"
)
const (
DefaultTimeout = 10 * time.Second
DefaultPort = "6053"
defaultClientInfo = "maze.io go/esphome"
)
type Client struct {
// Info identifies this device with the ESPHome node.
Info string
// Timeout for read and write operations.
Timeout time.Duration
// Clock returns the current time.
Clock func() time.Time
conn net.Conn
br *bufio.Reader
entities clientEntities
err error
in chan proto.Message
stop chan struct{}
waitMutex sync.RWMutex
wait map[uint64]chan proto.Message
}
type clientEntities struct {
binarySensor map[uint32]*BinarySensor
camera map[uint32]*Camera
climate map[uint32]*Climate
cover map[uint32]*Cover
fan map[uint32]*Fan
light map[uint32]*Light
sensor map[uint32]*Sensor
switches map[uint32]*Switch
textSensor map[uint32]*TextSensor
}
func newClientEntities() clientEntities {
return clientEntities{
binarySensor: make(map[uint32]*BinarySensor),
camera: make(map[uint32]*Camera),
climate: make(map[uint32]*Climate),
cover: make(map[uint32]*Cover),
fan: make(map[uint32]*Fan),
light: make(map[uint32]*Light),
sensor: make(map[uint32]*Sensor),
switches: make(map[uint32]*Switch),
textSensor: make(map[uint32]*TextSensor),
}
}
// Dial connects to ESPHome native API on the supplied TCP address.
func Dial(addr string) (*Client, error) {
return DialTimeout(addr, 0)
}
func DialTimeout(addr string, timeout time.Duration) (*Client, error) {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return nil, err
}
c := &Client{
Timeout: timeout,
Info: defaultClientInfo,
Clock: func() time.Time { return time.Now() },
conn: conn,
br: bufio.NewReader(conn),
in: make(chan proto.Message, 16),
wait: make(map[uint64]chan proto.Message),
stop: make(chan struct{}),
entities: newClientEntities(),
}
go c.reader()
return c, nil
}
func (c *Client) reader() {
defer c.conn.Close()
for {
select {
case <-c.stop:
return
default:
if err := c.readMessage(); err != nil {
c.err = err
return
}
}
}
}
func (c *Client) nextMessage() (proto.Message, error) {
if c.err != nil {
return nil, c.err
}
return <-c.in, nil
}
func (c *Client) nextMessageTimeout(timeout time.Duration) (proto.Message, error) {
if c.err != nil {
return nil, c.err
}
select {
case message := <-c.in:
return message, nil
case <-time.After(timeout):
return nil, ErrTimeout
}
}
func (c *Client) waitFor(messageType uint64, in chan proto.Message) {
c.waitMutex.Lock()
{
if other, waiting := c.wait[messageType]; waiting {
other <- nil
close(other)
}
c.wait[messageType] = in
}
c.waitMutex.Unlock()
}
func (c *Client) waitDone(messageType uint64) {
c.waitMutex.Lock()
{
delete(c.wait, messageType)
}
c.waitMutex.Unlock()
}
func (c *Client) waitMessage(messageType uint64) proto.Message {
in := make(chan proto.Message, 1)
c.waitFor(messageType, in)
message := <-in
c.waitDone(messageType)
return message
}
func (c *Client) waitMessageTimeout(messageType uint64, timeout time.Duration) (proto.Message, error) {
in := make(chan proto.Message, 1)
c.waitFor(messageType, in)
defer c.waitDone(messageType)
select {
case message := <-in:
return message, nil
case <-time.After(timeout):
return nil, ErrTimeout
}
}
func (c *Client) readMessage() (err error) {
var message proto.Message
if message, err = api.ReadMessage(c.br); err == nil {
if !c.handleInternal(message) {
c.waitMutex.Lock()
in, waiting := c.wait[api.TypeOf(message)]
c.waitMutex.Unlock()
if waiting {
in <- message
} else {
c.in <- message
}
}
}
return
}
func (c *Client) handleInternal(message proto.Message) bool {
switch message := message.(type) {
case *api.DisconnectRequest:
_ = c.sendTimeout(&api.DisconnectResponse{}, c.Timeout)
c.Close()
return true
case *api.PingRequest:
_ = c.sendTimeout(&api.PingResponse{}, c.Timeout)
return true
case *api.GetTimeRequest:
_ = c.sendTimeout(&api.GetTimeResponse{EpochSeconds: uint32(c.Clock().Unix())}, c.Timeout)
return true
case *api.FanStateResponse:
if _, ok := c.entities.fan[message.Key]; ok {
// TODO
// entity.update(message)
}
case *api.CoverStateResponse:
if _, ok := c.entities.cover[message.Key]; ok {
// TODO
// entity.update(message)
}
case *api.LightStateResponse:
if entity, ok := c.entities.light[message.Key]; ok {
entity.update(message)
}
case *api.SensorStateResponse:
if entity, ok := c.entities.sensor[message.Key]; ok {
entity.update(message)
}
case *api.SwitchStateResponse:
if entity, ok := c.entities.switches[message.Key]; ok {
entity.update(message)
}
}
return false
}
func (c *Client) send(message proto.Message) error {
packed, err := api.Marshal(message)
if err != nil {
return err
}
if _, err = c.conn.Write(packed); err != nil {
return err
}
return nil
}
func (c *Client) sendTimeout(message proto.Message, timeout time.Duration) error {
packed, err := api.Marshal(message)
if err != nil {
return err
}
if err = c.conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
return err
}
if _, err = c.conn.Write(packed); err != nil {
return err
}
if err = c.conn.SetWriteDeadline(time.Time{}); err != nil {
return err
}
return nil
}
func (c *Client) sendAndWaitResponse(message proto.Message, messageType uint64) (proto.Message, error) {
if err := c.send(message); err != nil {
return nil, err
}
return c.waitMessage(messageType), nil
}
func (c *Client) sendAndWaitResponseTimeout(message proto.Message, messageType uint64, timeout time.Duration) (proto.Message, error) {
if timeout <= 0 {
return c.sendAndWaitResponse(message, messageType)
}
if err := c.sendTimeout(message, timeout); err != nil {
return nil, err
}
return c.waitMessageTimeout(messageType, timeout)
}
// Login must be called to do the initial handshake. The provided password can be empty.
func (c *Client) Login(password string) error {
message, err := c.sendAndWaitResponseTimeout(&api.HelloRequest{
ClientInfo: c.Info,
}, api.HelloResponseType, c.Timeout)
if err != nil {
return err
}
if message, err = c.sendAndWaitResponseTimeout(&api.ConnectRequest{
Password: password,
}, api.ConnectResponseType, c.Timeout); err != nil {
return err
}
connectResponse := message.(*api.ConnectResponse)
if connectResponse.InvalidPassword {
return ErrPassword
}
// Query available entities, this allows us to map sensor/actor names to keys.
entities, err := c.listEntities()
if err != nil {
return err
}
for _, item := range entities {
switch item := item.(type) {
case *api.ListEntitiesBinarySensorResponse:
c.entities.binarySensor[item.Key] = newBinarySensor(c, item)
case *api.ListEntitiesCameraResponse:
c.entities.camera[item.Key] = newCamera(c, item)
case *api.ListEntitiesClimateResponse:
c.entities.climate[item.Key] = newClimate(c, item)
case *api.ListEntitiesCoverResponse:
c.entities.cover[item.Key] = newCover(c, item)
case *api.ListEntitiesFanResponse:
c.entities.fan[item.Key] = newFan(c, item)
case *api.ListEntitiesLightResponse:
c.entities.light[item.Key] = newLight(c, item)
case *api.ListEntitiesSensorResponse:
c.entities.sensor[item.Key] = newSensor(c, item)
case *api.ListEntitiesSwitchResponse:
c.entities.switches[item.Key] = newSwitch(c, item)
case *api.ListEntitiesTextSensorResponse:
c.entities.textSensor[item.Key] = newTextSensor(c, item)
default:
fmt.Printf("unknown\t%T\n", item)
}
}
// Subscribe to states, this is also used for streaming requests.
if err = c.sendTimeout(&api.SubscribeStatesRequest{}, c.Timeout); err != nil {
return err
}
return nil
}
func (c *Client) Close() error {
_, err := c.sendAndWaitResponseTimeout(&api.DisconnectRequest{}, api.DisconnectResponseType, 5*time.Second)
select {
case c.stop <- struct{}{}:
default:
}
return err
}
// DeviceInfo contains information about the ESPHome node.
type DeviceInfo struct {
UsesPassword bool
// The name of the node, given by "App.set_name()"
Name string
// The mac address of the device. For example "AC:BC:32:89:0E:A9"
MacAddress string
// A string describing the ESPHome version. For example "1.10.0"
EsphomeVersion string
// A string describing the date of compilation, this is generated by the compiler
// and therefore may not be in the same format all the time.
// If the user isn't using ESPHome, this will also not be set.
CompilationTime string
// The model of the board. For example NodeMCU
Model string
HasDeepSleep bool
}