├── image.png ├── docker-compose.yml ├── app ├── webClient │ ├── models │ │ └── models.go │ └── client.go ├── repository │ ├── models │ │ ├── errors.go │ │ └── models.go │ └── cache │ │ └── cache.go ├── service │ ├── models │ │ ├── errors.go │ │ ├── dictionary.go │ │ └── models.go │ └── service.go ├── mqtt │ ├── subscriber │ │ ├── routers.go │ │ └── subscriber.go │ ├── mqtt.go │ ├── models │ │ └── models.go │ └── publisher │ │ └── publisher.go └── domain.go ├── pkg ├── converter │ └── converter.go └── coder │ └── coder.go ├── go.mod ├── config ├── config.json ├── config.yml └── config.go ├── Dockerfile ├── LICENSE ├── .github └── workflows │ ├── build-and-publish-binaries.yml │ └── docker-publish.yml ├── go.sum ├── README.md └── main.go /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArtemVladimirov/broadlinkac2mqtt/HEAD/image.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | services: 3 | broadlinkac2mqtt: 4 | build: . 5 | restart: on-failure 6 | -------------------------------------------------------------------------------- /app/webClient/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type SendCommandInput struct { 4 | Payload []byte 5 | Ip string 6 | Port uint16 7 | } 8 | 9 | type SendCommandReturn struct { 10 | Payload []byte 11 | } 12 | -------------------------------------------------------------------------------- /pkg/converter/converter.go: -------------------------------------------------------------------------------- 1 | package converter 2 | 3 | func Temperature(inputUnit, outputUnit string, value float32) float32 { 4 | if inputUnit == "C" { 5 | if outputUnit == "C" { 6 | return value 7 | } 8 | 9 | if outputUnit == "F" { 10 | return float32(int((value * 9 / 5) + 32)) 11 | } 12 | } 13 | 14 | if inputUnit == "F" { 15 | if outputUnit == "C" { 16 | return float32(int((value-32)*5/9*10)) / 10 17 | } 18 | 19 | if outputUnit == "F" { 20 | return value 21 | } 22 | } 23 | 24 | return 0 25 | } 26 | -------------------------------------------------------------------------------- /app/repository/models/errors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrorDeviceNotFound = errors.New("ErrorDeviceNotFound") 7 | ErrorDeviceAuthNotFound = errors.New("ErrorDeviceAuthNotFound") 8 | ErrorDeviceStatusRawNotFound = errors.New("ErrorDeviceStatusRawNotFound") 9 | ErrorDeviceStatusAvailabilityNotFound = errors.New("ErrorDeviceStatusAvailabilityNotFound") 10 | 11 | ErrorDeviceStatusAmbientTempNotFound = errors.New("ErrorDeviceStatusAmbientTempNotFound") 12 | ) 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ArtemVladimirov/broadlinkac2mqtt 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/eclipse/paho.mqtt.golang v1.5.0 7 | github.com/ilyakaznacheev/cleanenv v1.5.0 8 | golang.org/x/sync v0.11.0 9 | ) 10 | 11 | require ( 12 | github.com/BurntSushi/toml v1.2.1 // indirect 13 | github.com/gorilla/websocket v1.5.3 // indirect 14 | github.com/joho/godotenv v1.5.1 // indirect 15 | golang.org/x/net v0.35.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "service": { 3 | "update_interval": 10, 4 | "log_level": "error" 5 | }, 6 | "mqtt": { 7 | "broker": "mqtt://192.168.1.10:1883", 8 | "user": "admin", 9 | "password": "password", 10 | "client_id": "aircac2", 11 | "topic_prefix": "aircon2", 12 | "auto_discovery_topic": "homeassistant", 13 | "auto_discovery_topic_retain": false 14 | }, 15 | "devices": [ 16 | { 17 | "ip": "192.168.1.12", 18 | "mac": "34ea345b0fd4", 19 | "name": "MH Childroom AC", 20 | "port": 80, 21 | "temperature_unit": "C" 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /app/service/models/errors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrorInvalidResultPacket = errors.New("ErrorInvalidResultPacket") 7 | ErrorInvalidResultPacketLength = errors.New("ErrorInvalidResultPacketLength") 8 | 9 | ErrorInvalidParameterTemperature = errors.New("ErrorInvalidParameterTemperature") 10 | ErrorInvalidParameterSwingMode = errors.New("ErrorInvalidParameterSwingMode") 11 | ErrorInvalidParameterFanMode = errors.New("ErrorInvalidParameterFanMode") 12 | ErrorInvalidParameterMode = errors.New("ErrorInvalidParameterMode") 13 | ErrorInvalidParameterDisplayStatus = errors.New("ErrorInvalidParameterDisplayStatus") 14 | ) 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.22-alpine as base 2 | 3 | RUN adduser \ 4 | --disabled-password \ 5 | --gecos "" \ 6 | --home "/nonexistent" \ 7 | --shell "/sbin/nologin" \ 8 | --no-create-home \ 9 | --uid 65532 \ 10 | small-user 11 | 12 | WORKDIR $GOPATH/src/broadlinkac/app/ 13 | 14 | COPY . . 15 | 16 | RUN go mod download 17 | RUN go mod verify 18 | 19 | ARG TARGETOS TARGETARCH 20 | RUN --mount=target=. \ 21 | --mount=type=cache,target=/root/.cache/go-build \ 22 | --mount=type=cache,target=/go/pkg \ 23 | GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/main . 24 | 25 | FROM scratch 26 | 27 | COPY --from=base /etc/passwd /etc/passwd 28 | COPY --from=base /etc/group /etc/group 29 | 30 | COPY --from=base /out/main . 31 | COPY ./config/config.yml ./config/config.yml 32 | 33 | USER small-user:small-user 34 | 35 | CMD ["/main"] 36 | -------------------------------------------------------------------------------- /pkg/coder/coder.go: -------------------------------------------------------------------------------- 1 | package coder 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | ) 7 | 8 | func Encrypt(key, iv, plaintext []byte) ([]byte, error) { 9 | block, err := aes.NewCipher(key) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | ciphertext := make([]byte, len(plaintext)) 15 | 16 | mode := cipher.NewCBCEncrypter(block, iv) 17 | mode.CryptBlocks(ciphertext, plaintext) 18 | 19 | return ciphertext, nil 20 | } 21 | 22 | func Decrypt(key, iv, ciphertext []byte) ([]byte, error) { 23 | block, err := aes.NewCipher(key) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | for { 29 | if len(ciphertext)%block.BlockSize() != 0 { 30 | ciphertext = append(ciphertext, byte(0)) 31 | } else { 32 | break 33 | } 34 | } 35 | 36 | decrypted := make([]byte, len(ciphertext)) 37 | mode := cipher.NewCBCDecrypter(block, iv) 38 | mode.CryptBlocks(decrypted, ciphertext) 39 | 40 | return decrypted, nil 41 | } 42 | -------------------------------------------------------------------------------- /config/config.yml: -------------------------------------------------------------------------------- 1 | service: 2 | update_interval: 10 #Seconds 3 | log_level: error 4 | 5 | mqtt: 6 | ## Use mqtts for SSL support 7 | broker: "mqtt://192.168.1.10:1883" 8 | user: admin 9 | password: password 10 | client_id: aircac2 11 | topic_prefix: aircon2 12 | auto_discovery_topic: homeassistant 13 | auto_discovery_topic_retain: false 14 | ## CA certificate in CRT format. 15 | # certificate_authority: "./config/cert/ca.crt" 16 | ## Don’t verify if the common name in the server certificate matches the value of broker 17 | # skip_cert_cn_check: true 18 | ## Authorization using client certificates 19 | # certificate_client: "./config/cert/client.crt" 20 | # key-client: "./config/cert/client.key" 21 | 22 | devices: 23 | - ip: 192.168.1.12 24 | mac: 34ea345b0fd4 25 | name: MH Childroom AC 26 | port: 80 27 | # Temperature Unit defines the temperature unit of the device, C or F. 28 | # If this is not set, the temperature unit is Celsius. 29 | temperature_unit: C -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Artem Vladimirov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build-and-publish-binaries.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | 5 | jobs: 6 | releases-matrix: 7 | name: Release Go Binary 8 | permissions: 9 | contents: write 10 | packages: write 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 15 | goos: [linux, windows, darwin] 16 | goarch: ["386", amd64, arm64] 17 | include: 18 | - goarch: "arm" 19 | goos: linux 20 | - goarch: "arm" 21 | goos: linux 22 | goarm: "7" 23 | exclude: 24 | - goarch: "386" 25 | goos: darwin 26 | - goarch: arm64 27 | goos: windows 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: wangyoucao577/go-release-action@v1 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | goos: ${{ matrix.goos }} 34 | goarch: ${{ matrix.goarch }} 35 | goarm: ${{ matrix.goarm }} 36 | binary_name: "broadlinkac2mqtt" 37 | extra_files: LICENSE README.md 38 | -------------------------------------------------------------------------------- /app/mqtt/subscriber/routers.go: -------------------------------------------------------------------------------- 1 | package subscriber 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app" 8 | mqtt "github.com/eclipse/paho.mqtt.golang" 9 | ) 10 | 11 | func Routers(ctx context.Context, logger *slog.Logger, mac string, topicPrefix string, client mqtt.Client, handler app.MqttSubscriber) { 12 | prefix := topicPrefix + "/" + mac 13 | 14 | if token := client.Subscribe(prefix+"/fan_mode/set", 0, handler.UpdateFanModeCommandTopic(ctx)); token.Wait() && token.Error() != nil { 15 | logger.ErrorContext(ctx, "failed to subscribe on topic", slog.Any("err", token.Error())) 16 | } 17 | if token := client.Subscribe(prefix+"/swing_mode/set", 0, handler.UpdateSwingModeCommandTopic(ctx)); token.Wait() && token.Error() != nil { 18 | logger.ErrorContext(ctx, "failed to subscribe on topic", slog.Any("err", token.Error())) 19 | } 20 | if token := client.Subscribe(prefix+"/mode/set", 0, handler.UpdateModeCommandTopic(ctx)); token.Wait() && token.Error() != nil { 21 | logger.ErrorContext(ctx, "failed to subscribe on topic", slog.Any("err", token.Error())) 22 | } 23 | if token := client.Subscribe(prefix+"/temp/set", 0, handler.UpdateTemperatureCommandTopic(ctx)); token.Wait() && token.Error() != nil { 24 | logger.ErrorContext(ctx, "failed to subscribe on topic", slog.Any("err", token.Error())) 25 | } 26 | if token := client.Subscribe(prefix+"/display/switch/set", 0, handler.UpdateDisplaySwitchCommandTopic(ctx)); token.Wait() && token.Error() != nil { 27 | logger.ErrorContext(ctx, "failed to subscribe on topic", slog.Any("err", token.Error())) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/webClient/client.go: -------------------------------------------------------------------------------- 1 | package webClient 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app" 11 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app/webClient/models" 12 | ) 13 | 14 | type webClient struct { 15 | logger *slog.Logger 16 | } 17 | 18 | func NewWebClient(logger *slog.Logger) app.WebClient { 19 | return &webClient{ 20 | logger: logger, 21 | } 22 | } 23 | 24 | func (w *webClient) SendCommand(ctx context.Context, input *models.SendCommandInput) (*models.SendCommandReturn, error) { 25 | conn, err := net.Dial("udp", input.Ip+":"+strconv.Itoa(int(input.Port))) 26 | if err != nil { 27 | w.logger.ErrorContext(ctx, "Failed to dial address", slog.Any("err", err)) 28 | return nil, err 29 | } 30 | defer func(conn net.Conn) { 31 | err = conn.Close() 32 | if err != nil { 33 | w.logger.ErrorContext(ctx, "Failed to close client connection", slog.Any("err", err)) 34 | } 35 | }(conn) 36 | 37 | err = conn.SetDeadline(time.Now().Add(time.Second * 10)) 38 | if err != nil { 39 | w.logger.ErrorContext(ctx, "Failed to set deadline", slog.Any("err", err)) 40 | return nil, err 41 | } 42 | 43 | _, err = conn.Write(input.Payload) 44 | if err != nil { 45 | w.logger.ErrorContext(ctx, "Failed to write the payload", slog.Any("err", err)) 46 | return nil, err 47 | } 48 | 49 | response := make([]byte, 1024) 50 | _, err = conn.Read(response) 51 | if err != nil { 52 | w.logger.ErrorContext(ctx, "Failed to read the response", slog.Any("err", err)) 53 | return nil, err 54 | } 55 | 56 | return &models.SendCommandReturn{Payload: response}, nil 57 | } 58 | -------------------------------------------------------------------------------- /app/service/models/dictionary.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | StatusOn byte = 1 5 | StatusOff byte = 0 6 | 7 | StatusOnline = "online" 8 | StatusOffline = "offline" 9 | 10 | Fahrenheit = "F" 11 | Celsius = "C" 12 | ) 13 | 14 | var ( 15 | VerticalFixationStatuses = map[int]string{ 16 | 0b00000001: "top", 17 | 0b00000010: "middle1", 18 | 0b00000011: "middle2", 19 | 0b00000100: "middle3", 20 | 0b00000101: "bottom", 21 | 0b00000110: "swing", 22 | 0b00000111: "auto", 23 | } 24 | 25 | VerticalFixationStatusesInvert = map[string]int{ 26 | "top": 0b00000001, 27 | "middle1": 0b00000010, 28 | "middle2": 0b00000011, 29 | "middle3": 0b00000100, 30 | "bottom": 0b00000101, 31 | "swing": 0b00000110, 32 | "auto": 0b00000111, 33 | } 34 | 35 | //horizontalFixationStatuses = map[int]string{ 36 | // 2: "LEFT_FIX", 37 | // 1: "LEFT_FLAP", 38 | // 7: "LEFT_RIGHT_FIX", 39 | // 0: "LEFT_RIGHT_FLAP", 40 | // 6: "RIGHT_FIX", 41 | // 5: "RIGHT_FLAP", 42 | // 0: "ON", 43 | // 1: "OFF", 44 | //} 45 | 46 | FanStatuses = map[int]string{ 47 | 0b00000011: "low", 48 | 0b00000010: "medium", 49 | 0b00000001: "high", 50 | 0b00000101: "auto", 51 | 0b00000000: "none", 52 | } 53 | 54 | FanStatusesInvert = map[string]int{ 55 | "low": 0b00000011, 56 | "medium": 0b00000010, 57 | "high": 0b00000001, 58 | "auto": 0b00000101, 59 | "none": 0b00000000, 60 | } 61 | 62 | ModeStatuses = map[int]string{ 63 | 0b00000001: "cool", 64 | 0b00000010: "dry", 65 | 0b00000100: "heat", 66 | 0b00000000: "auto", 67 | 0b00000110: "fan_only", 68 | } 69 | 70 | ModeStatusesInvert = map[string]int{ 71 | "cool": 0b00000001, 72 | "dry": 0b00000010, 73 | "heat": 0b00000100, 74 | "auto": 0b00000000, 75 | "fan_only": 0b00000110, 76 | } 77 | ) 78 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= 2 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o= 4 | github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk= 5 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 6 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 7 | github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= 8 | github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= 9 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 10 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 11 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 12 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 13 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 14 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= 20 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [released] 4 | 5 | env: 6 | # Use docker.io for Docker Hub if empty 7 | REGISTRY: ghcr.io 8 | # github.repository as / 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | # This is used to complete the identity challenge 18 | # with sigstore/fulcio when running outside of PRs. 19 | id-token: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Docker buildx 26 | uses: docker/setup-buildx-action@v1 27 | 28 | - name: Creating and using multi-builder 29 | run: docker buildx create --use --name multi-builder 30 | 31 | # Login against a Docker registry except on PR 32 | # https://github.com/docker/login-action 33 | - name: Log into registry ${{ env.REGISTRY }} 34 | if: github.event_name != 'pull_request' 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ${{ env.REGISTRY }} 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Docker meta 42 | id: meta 43 | uses: docker/metadata-action@v5 44 | with: 45 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 46 | flavor: latest=true 47 | tags: | 48 | type=ref,event=branch 49 | type=semver,pattern={{version}} 50 | 51 | - name: Build and push 52 | uses: docker/build-push-action@v5 53 | with: 54 | context: . 55 | push: ${{ github.event_name != 'pull_request' }} 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | platforms: linux/amd64,linux/arm64 59 | builder: multi-builder 60 | -------------------------------------------------------------------------------- /app/mqtt/mqtt.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "log/slog" 8 | "net/url" 9 | "os" 10 | "time" 11 | 12 | "github.com/ArtemVladimirov/broadlinkac2mqtt/config" 13 | paho "github.com/eclipse/paho.mqtt.golang" 14 | ) 15 | 16 | func NewMqttConfig(logger *slog.Logger, cfg config.Mqtt) (*paho.ClientOptions, error) { 17 | //Configure MQTT Client 18 | uri, err := url.Parse(cfg.Broker) 19 | if err != nil { 20 | message := "URL address is incorrect" 21 | logger.Error(message) 22 | return nil, errors.New(message) 23 | } 24 | 25 | opts := paho.NewClientOptions().AddBroker(uri.String()).SetClientID(cfg.ClientId) 26 | 27 | if cfg.User != nil { 28 | opts.SetUsername(*cfg.User) 29 | } 30 | if cfg.Password != nil { 31 | opts.SetPassword(*cfg.Password) 32 | } 33 | 34 | opts.SetKeepAlive(30 * time.Second) 35 | opts.SetPingTimeout(10 * time.Second) 36 | opts.SetAutoReconnect(true) 37 | opts.SetCleanSession(false) 38 | opts.SetConnectionLostHandler(func(client paho.Client, err error) { 39 | logger.Error("MQTT connection lost", slog.Any("err", err)) 40 | }) 41 | opts.SetOnConnectHandler(func(client paho.Client) { 42 | logger.Info("Connected to MQTT") 43 | }) 44 | 45 | if uri.Scheme == "mqtts" || uri.Scheme == "ssl" { 46 | tlsConfig := &tls.Config{} 47 | 48 | if cfg.CertificateClient != nil && cfg.KeyClient != nil { 49 | cert, err := tls.LoadX509KeyPair(*cfg.CertificateClient, *cfg.KeyClient) 50 | if err != nil { 51 | logger.Error("Failed to load the client key pair", slog.Any("err", err)) 52 | return nil, err 53 | } 54 | tlsConfig.Certificates = []tls.Certificate{cert} 55 | } 56 | 57 | if cfg.CertificateAuthority != nil { 58 | caCert, err := os.ReadFile(*cfg.CertificateAuthority) 59 | if err != nil { 60 | logger.Error("Failed to load the authority certificate", slog.Any("err", err)) 61 | return nil, err 62 | } 63 | 64 | // Create a certificate pool and add the CA certificate 65 | caCertPool := x509.NewCertPool() 66 | caCertPool.AppendCertsFromPEM(caCert) 67 | 68 | tlsConfig.RootCAs = caCertPool 69 | } 70 | 71 | tlsConfig.InsecureSkipVerify = cfg.SkipCertCnCheck 72 | 73 | opts.SetTLSConfig(tlsConfig) 74 | } 75 | 76 | return opts, err 77 | } 78 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/ilyakaznacheev/cleanenv" 9 | ) 10 | 11 | type ( 12 | Config struct { 13 | Service Service `yaml:"service" json:"service"` 14 | Mqtt Mqtt `yaml:"mqtt" json:"mqtt"` 15 | Devices []Devices `yaml:"devices" json:"devices"` 16 | } 17 | 18 | Service struct { 19 | UpdateInterval int `env-default:"10" yaml:"update_interval" json:"update_interval"` 20 | LogLevel string `env-default:"error" yaml:"log_level" json:"log_level"` 21 | } 22 | 23 | Mqtt struct { 24 | Broker string `env-required:"true" yaml:"broker" json:"broker"` 25 | User *string `yaml:"user" json:"user"` 26 | Password *string `yaml:"password" json:"password"` 27 | ClientId string `env-default:"broadlinkac" yaml:"client_id" json:"client_id"` 28 | TopicPrefix string `env-default:"airac" yaml:"topic_prefix" json:"topic_prefix"` 29 | AutoDiscoveryTopic *string `yaml:"auto_discovery_topic" json:"auto_discovery_topic"` 30 | AutoDiscoveryTopicRetain bool `env-default:"true" yaml:"auto_discovery_topic_retain" json:"auto_discovery_topic_retain"` 31 | CertificateAuthority *string `yaml:"certificate_authority" json:"certificate_authority"` 32 | SkipCertCnCheck bool `env-default:"true" yaml:"skip_cert_cn_check" json:"skip_cert_cn_check"` 33 | CertificateClient *string `yaml:"certificate_client" json:"certificate_client"` 34 | KeyClient *string `yaml:"key-client" json:"key_client"` 35 | } 36 | 37 | Devices struct { 38 | Ip string `env-required:"true" yaml:"ip" json:"ip"` 39 | Mac string `env-required:"true" yaml:"mac" json:"mac"` 40 | Name string `env-required:"true" yaml:"name" json:"name"` 41 | Port uint16 `env-required:"true" yaml:"port" json:"port"` 42 | // TemperatureUnit defines the temperature unit of the device, C or F. 43 | // If this is not set, the temperature unit is Celsius. 44 | TemperatureUnit string `env-default:"C" yaml:"temperature_unit" json:"temperature_unit"` // BUG cleanenv env-default is not working 45 | } 46 | ) 47 | 48 | // NewConfig returns app config. 49 | func NewConfig(logger *slog.Logger) (*Config, error) { 50 | cfg := &Config{} 51 | 52 | files := [...]string{ 53 | "./config/config.yml", 54 | "./config/config.json", 55 | "/data/options.json", // hassio 56 | } 57 | 58 | for i := range files { 59 | if _, err := os.Stat(files[i]); err == nil { 60 | err := cleanenv.ReadConfig(files[i], cfg) 61 | if err != nil { 62 | logger.Error("failed to read config", slog.Any("err", err)) 63 | return nil, err 64 | } 65 | return cfg, nil 66 | } 67 | } 68 | 69 | return nil, errors.New("config file is not found") 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BroadlinkAC2MQTT 2 | Control your broadlink-based air conditioner using Home Assistant 3 | 4 | ![Image](image.png) 5 | 6 | ## Advantages 7 | 8 | * Small application size (~10.2 Mb docker, ~8.2 Mb Windows Standalone) 9 | * Easy to install and use 10 | * Support all platforms 11 | * Parallel independent air conditioning support. 12 | If one air conditioner is offline, it will not affect the rest! 13 | 14 | ## Configuration 15 | 16 | You must specify the mqtt and air conditioner settings in the config.yml or config.json (less priority) file in the config folder. 17 | 18 | Example of config.yml 19 | 20 | ``` 21 | service: 22 | update_interval: 10 # In seconds. Default: 10 23 | log_level: error # Supported: info, disabled, fatal, debug, error. Default: error 24 | 25 | mqtt: 26 | broker: "mqtt://192.168.1.10:1883" # Required. Use mqtts:// for ssl support 27 | user: admin # Optional 28 | password: password # Optional 29 | client_id: airac # Default: broadlinkac 30 | topic_prefix: aircon # Default: airac 31 | auto_discovery_topic: homeassistant # Optional 32 | auto_discovery_topic_retain: false # Default: true 33 | certificate_authority: "./config/cert/ca.crt" # Optional. CA certificate in CRT format. 34 | skip_cert_cn_check: false # Default: true. Don’t verify if the common name in the server certificate matches the value of broker. 35 | certificate_client: "./config/cert/client.crt" # Optional. Authorization using client certificates 36 | key-client: "./config/cert/client.key" # Optional. Authorization using client certificates 37 | 38 | devices: 39 | - ip: 192.168.1.12 40 | mac: 34ea345b0fd4 # Only this format is supported 41 | name: Childroom AC 42 | port: 80 43 | - ip: 192.168.1.18 44 | mac: 34ea346b0mks # Only this format is supported 45 | name: Bedroom AC 46 | port: 80 47 | # Temperature Unit defines the temperature unit of the device, C or F. 48 | # If this is not set, the temperature unit is Celsius. 49 | temperature_unit: C 50 | 51 | ``` 52 | 53 | ## Installation 54 | 55 | ### Home Assistant Add-on 56 | 57 | Install Add-On: 58 | 59 | * Settings > Add-ons > Plus > Repositories > Add `https://github.com/ArtemVladimirov/hassio-add-ons` 60 | * broadlinkac2mqtt > Install > Start 61 | 62 | ### Docker Compose 63 | 64 | ``` 65 | version: '3.5' 66 | services: 67 | broadlinkac2mqtt: 68 | image: "ghcr.io/artemvladimirov/broadlinkac2mqtt:latest" 69 | container_name: "broadlinkac2mqtt" 70 | restart: "on-failure" 71 | volumes: 72 | - /PATH_TO_YOUR_CONFIG:/config 73 | 74 | ``` 75 | 76 | ### Docker 77 | 78 | ``` 79 | docker run -d --name="broadlinkac2mqtt" -v /PATH_TO_YOUR_CONFIG:/config --restart always ghcr.io/artemvladimirov/broadlinkac2mqtt:latest 80 | ``` 81 | 82 | ### Standalone application 83 | 84 | Download application from releases or build it with command "go build". Then you can run a program. The config folder must be located in the program folder 85 | 86 | ## Known issues 87 | 88 | ### Checksum is incorrect 89 | 90 | 91 | > [@cHunter789](https://github.com/ArtemVladimirov/broadlinkac2mqtt/issues/6#issuecomment-2308999367) wrote: 92 | > 93 | > if there is a problem with checksum you have to remove the device from ac freedom app, reset wifi and after the wifi is connected once again (in the ac freedom app) just cancel and you will get connection. If it's still 94 | > not working, just take a look at [a hardware approach](https://github.com/GrKoR/esphome_aux_ac_component/blob/06388ebb2c2792098e93dd844c3c812440a06288/README-EN.md#esphome-aux-air-conditioner-custom-component-aux_ac) 95 | 96 | OR your device is not supported. 97 | 98 | ## Support 99 | 100 | To motivate the developer, click on the STAR ⭐. I will be very happy! 101 | -------------------------------------------------------------------------------- /app/repository/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Device struct { 8 | Config DeviceConfig 9 | Auth *DeviceAuth 10 | DeviceStatus DeviceStatus 11 | DeviceStatusRaw *DeviceStatusRaw 12 | MqttLastMessage MqttStatus 13 | } 14 | 15 | type DeviceConfig struct { 16 | Mac string 17 | Ip string 18 | Name string 19 | Port uint16 20 | TemperatureUnit string 21 | } 22 | 23 | type DeviceAuth struct { 24 | LastMessageId int 25 | DevType int 26 | Id [4]byte 27 | Key []byte 28 | Iv []byte 29 | } 30 | 31 | type DeviceStatusRaw struct { 32 | UpdatedAt time.Time 33 | Temperature float32 34 | Power byte 35 | FixationVertical byte 36 | Mode byte 37 | Sleep byte 38 | Display byte 39 | Mildew byte 40 | Health byte 41 | FixationHorizontal byte 42 | FanSpeed byte 43 | IFeel byte 44 | Mute byte 45 | Turbo byte 46 | Clean byte 47 | } 48 | 49 | type DeviceStatus struct { 50 | Availability *string 51 | AmbientTemp *float32 52 | } 53 | 54 | type MqttStatus struct { 55 | FanMode *MqttFanModeMessage 56 | SwingMode *MqttSwingModeMessage 57 | Mode *MqttModeMessage 58 | Temperature *MqttTemperatureMessage 59 | DisplaySwitch *MqttDisplaySwitchMessage 60 | } 61 | 62 | type ReadDeviceConfigInput struct { 63 | Mac string 64 | } 65 | 66 | type ReadDeviceConfigReturn struct { 67 | Config DeviceConfig 68 | } 69 | 70 | type UpsertDeviceConfigInput struct { 71 | Config DeviceConfig 72 | } 73 | 74 | type ReadDeviceAuthInput struct { 75 | Mac string 76 | } 77 | 78 | type ReadDeviceAuthReturn struct { 79 | Auth DeviceAuth 80 | } 81 | 82 | type UpsertDeviceAuthInput struct { 83 | Mac string 84 | Auth DeviceAuth 85 | } 86 | 87 | type UpsertAmbientTempInput struct { 88 | Mac string 89 | Temperature float32 90 | } 91 | 92 | type ReadAmbientTempInput struct { 93 | Mac string 94 | } 95 | 96 | type ReadAmbientTempReturn struct { 97 | Temperature float32 98 | } 99 | 100 | type ReadDeviceStatusRawInput struct { 101 | Mac string 102 | } 103 | 104 | type ReadDeviceStatusRawReturn struct { 105 | Status DeviceStatusRaw 106 | } 107 | 108 | type UpsertDeviceStatusRawInput struct { 109 | Mac string 110 | Status DeviceStatusRaw 111 | } 112 | 113 | type MqttModeMessage struct { 114 | UpdatedAt time.Time 115 | Mode string 116 | } 117 | 118 | type UpsertMqttModeMessageInput struct { 119 | Mac string 120 | Mode MqttModeMessage 121 | } 122 | 123 | type MqttFanModeMessage struct { 124 | UpdatedAt time.Time 125 | FanMode string 126 | } 127 | 128 | type UpsertMqttFanModeMessageInput struct { 129 | Mac string 130 | FanMode MqttFanModeMessage 131 | } 132 | 133 | type MqttDisplaySwitchMessage struct { 134 | UpdatedAt time.Time 135 | IsDisplayOn bool 136 | } 137 | 138 | type UpsertMqttDisplaySwitchMessageInput struct { 139 | Mac string 140 | DisplaySwitch MqttDisplaySwitchMessage 141 | } 142 | 143 | type MqttSwingModeMessage struct { 144 | UpdatedAt time.Time 145 | SwingMode string 146 | } 147 | 148 | type UpsertMqttSwingModeMessageInput struct { 149 | Mac string 150 | SwingMode MqttSwingModeMessage 151 | } 152 | 153 | type MqttTemperatureMessage struct { 154 | UpdatedAt time.Time 155 | Temperature float32 156 | } 157 | 158 | type UpsertMqttTemperatureMessageInput struct { 159 | Mac string 160 | Temperature MqttTemperatureMessage 161 | } 162 | 163 | type ReadMqttMessageInput struct { 164 | Mac string 165 | } 166 | 167 | type ReadMqttMessageReturn struct { 168 | Temperature *MqttTemperatureMessage 169 | SwingMode *MqttSwingModeMessage 170 | FanMode *MqttFanModeMessage 171 | Mode *MqttModeMessage 172 | IsDisplayOn *MqttDisplaySwitchMessage 173 | } 174 | 175 | type UpsertDeviceAvailabilityInput struct { 176 | Mac string 177 | Availability string 178 | } 179 | 180 | type ReadDeviceAvailabilityInput struct { 181 | Mac string 182 | } 183 | 184 | type ReadDeviceAvailabilityReturn struct { 185 | Availability string 186 | } 187 | 188 | type ReadAuthedDevicesReturn struct { 189 | Macs []string 190 | } 191 | -------------------------------------------------------------------------------- /app/mqtt/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | DeviceClassClimate string = "climate" 5 | DeviceClassSwitch string = "switch" 6 | ) 7 | 8 | type ConfigMqtt struct { 9 | Broker string 10 | User *string 11 | Password *string 12 | ClientId string 13 | TopicPrefix string 14 | AutoDiscoveryTopic *string 15 | AutoDiscoveryTopicRetain bool 16 | } 17 | 18 | type ClimateDiscoveryTopic struct { 19 | FanModeCommandTopic string `json:"fan_mode_command_topic" example:"aircon/34ea345b0fd4/fan_mode/set"` 20 | SwingModeCommandTopic string `json:"swing_mode_command_topic" example:"aircon/34ea345b0fd4/swing_mode/set"` 21 | SwingModes []string `json:"swing_modes"` // 'on' 'off' 22 | TempStep float32 `json:"temp_step" example:"0.5"` 23 | TemperatureStateTopic string `json:"temperature_state_topic" example:"aircon/34ea345b0fd4/temp/value"` 24 | TemperatureCommandTopic string `json:"temperature_command_topic" example:"aircon/34ea345b0fd4/temp/set"` 25 | Precision float32 `json:"precision" example:"0.5"` 26 | CurrentTemperatureTopic string `json:"current_temperature_topic" example:"aircon/34ea345b0fd4/current_temp/value"` // Temperature in the room 27 | Device DiscoveryTopicDevice `json:"device"` 28 | ModeCommandTopic string `json:"mode_command_topic" example:"aircon/34ea345b0fd4/mode/set"` 29 | ModeStateTopic string `json:"mode_state_topic" example:"aircon/34ea345b0fd4/mode/value"` 30 | Modes []string `json:"modes"` // [“auto”, “off”, “cool”, “heat”, “dry”, “fan_only”] 31 | Name *string `json:"name"` 32 | FanModes []string `json:"fan_modes"` // : [“auto”, “low”, “medium”, “high”] 33 | SwingModeStateTopic string `json:"swing_mode_state_topic" example:"aircon/34ea345b0fd4/swing_mode/value"` 34 | FanModeStateTopic string `json:"fan_mode_state_topic" example:"aircon/34ea345b0fd4/fan_mode/value"` 35 | UniqueId string `json:"unique_id" example:"34ea345b0fd4"` 36 | MaxTemp float32 `json:"max_temp" example:"32.0"` 37 | MinTemp float32 `json:"min_temp" example:"16.0"` 38 | Availability DiscoveryTopicAvailability `json:"availability"` 39 | Icon string `json:"icon"` 40 | TemperatureUnit string `json:"temperature_unit"` // C or F 41 | } 42 | 43 | type SwitchDiscoveryTopic struct { 44 | Device DiscoveryTopicDevice `json:"device"` 45 | Name string `json:"name" example:"childroom"` 46 | UniqueId string `json:"unique_id" example:"34ea345b0fd4"` 47 | StateTopic string `json:"state_topic" example:"aircon/34ea345b0fd4/display/switch"` 48 | CommandTopic string `json:"command_topic" example:"aircon/34ea345b0fd4/display/switch/set"` 49 | Availability DiscoveryTopicAvailability `json:"availability"` 50 | Icon string `json:"icon"` 51 | } 52 | 53 | type DiscoveryTopicDevice struct { 54 | Model string `json:"model" example:"Aircon"` 55 | Mf string `json:"mf" example:"Broadlink"` 56 | Sw string `json:"sw" example:"1.1.3"` 57 | Ids string `json:"ids" example:"34ea345b0fd4"` 58 | Name string `json:"name" example:"childroom"` 59 | } 60 | 61 | type DiscoveryTopicAvailability struct { 62 | PayloadAvailable string `json:"payload_available" example:"online"` 63 | PayloadNotAvailable string `json:"payload_not_available" example:"offline"` 64 | Topic string `json:"topic" example:"aircon/34ea345b0fd4/availability/value"` 65 | } 66 | 67 | type PublishClimateDiscoveryTopicInput struct { 68 | Topic ClimateDiscoveryTopic 69 | } 70 | 71 | type PublishSwitchDiscoveryTopicInput struct { 72 | Topic SwitchDiscoveryTopic 73 | } 74 | 75 | type PublishAmbientTempInput struct { 76 | Mac string 77 | Temperature float32 78 | } 79 | 80 | type PublishTemperatureInput struct { 81 | Mac string 82 | Temperature float32 83 | } 84 | 85 | type PublishModeInput struct { 86 | Mac string 87 | Mode string 88 | } 89 | 90 | type PublishSwingModeInput struct { 91 | Mac string 92 | SwingMode string 93 | } 94 | 95 | type PublishFanModeInput struct { 96 | Mac string 97 | FanMode string 98 | } 99 | 100 | type PublishAvailabilityInput struct { 101 | Mac string 102 | Availability string 103 | } 104 | type PublishDisplaySwitchInput struct { 105 | Mac string 106 | Status string 107 | } 108 | -------------------------------------------------------------------------------- /app/mqtt/publisher/publisher.go: -------------------------------------------------------------------------------- 1 | package publisher 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app" 10 | 11 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt/models" 12 | paho "github.com/eclipse/paho.mqtt.golang" 13 | ) 14 | 15 | type mqttPublisher struct { 16 | logger *slog.Logger 17 | mqttConfig models.ConfigMqtt 18 | client paho.Client 19 | } 20 | 21 | func NewMqttSender(logger *slog.Logger, mqttConfig models.ConfigMqtt, client paho.Client) app.MqttPublisher { 22 | return &mqttPublisher{ 23 | logger: logger, 24 | mqttConfig: mqttConfig, 25 | client: client, 26 | } 27 | } 28 | 29 | func (m *mqttPublisher) PublishClimateDiscoveryTopic(ctx context.Context, input models.PublishClimateDiscoveryTopicInput) error { 30 | if m.mqttConfig.AutoDiscoveryTopic == nil { 31 | return nil 32 | } 33 | 34 | payload, err := json.Marshal(input.Topic) 35 | if err != nil { 36 | m.logger.ErrorContext(ctx, "Failed to marshal discovery topic", slog.Any("input", input.Topic), slog.Any("err", err)) 37 | return err 38 | } 39 | 40 | topic := *m.mqttConfig.AutoDiscoveryTopic + "/" + models.DeviceClassClimate + "/" + input.Topic.UniqueId + "/config" 41 | 42 | token := m.client.Publish(topic, 0, m.mqttConfig.AutoDiscoveryTopicRetain, string(payload)) 43 | select { 44 | case <-ctx.Done(): 45 | return nil 46 | case <-token.Done(): 47 | return token.Error() 48 | } 49 | } 50 | 51 | func (m *mqttPublisher) PublishSwitchDiscoveryTopic(ctx context.Context, input models.PublishSwitchDiscoveryTopicInput) error { 52 | if m.mqttConfig.AutoDiscoveryTopic == nil { 53 | return nil 54 | } 55 | 56 | payload, err := json.Marshal(input.Topic) 57 | if err != nil { 58 | m.logger.ErrorContext(ctx, "Failed to marshal discovery topic", slog.Any("input", input.Topic), slog.Any("err", err)) 59 | return err 60 | } 61 | 62 | topic := *m.mqttConfig.AutoDiscoveryTopic + "/" + models.DeviceClassSwitch + "/" + input.Topic.UniqueId + "/config" 63 | 64 | token := m.client.Publish(topic, 0, m.mqttConfig.AutoDiscoveryTopicRetain, string(payload)) 65 | select { 66 | case <-ctx.Done(): 67 | return nil 68 | case <-token.Done(): 69 | return token.Error() 70 | } 71 | } 72 | 73 | func (m *mqttPublisher) PublishAmbientTemp(ctx context.Context, input *models.PublishAmbientTempInput) error { 74 | topic := m.mqttConfig.TopicPrefix + "/" + input.Mac + "/current_temp/value" 75 | 76 | token := m.client.Publish(topic, 0, false, fmt.Sprintf("%.1f", input.Temperature)) 77 | select { 78 | case <-ctx.Done(): 79 | return nil 80 | case <-token.Done(): 81 | return token.Error() 82 | } 83 | } 84 | 85 | func (m *mqttPublisher) PublishTemperature(ctx context.Context, input *models.PublishTemperatureInput) error { 86 | topic := m.mqttConfig.TopicPrefix + "/" + input.Mac + "/temp/value" 87 | 88 | token := m.client.Publish(topic, 0, false, fmt.Sprintf("%.1f", input.Temperature)) 89 | select { 90 | case <-ctx.Done(): 91 | return nil 92 | case <-token.Done(): 93 | return token.Error() 94 | } 95 | } 96 | 97 | func (m *mqttPublisher) PublishMode(ctx context.Context, input *models.PublishModeInput) error { 98 | topic := m.mqttConfig.TopicPrefix + "/" + input.Mac + "/mode/value" 99 | 100 | token := m.client.Publish(topic, 0, false, input.Mode) 101 | select { 102 | case <-ctx.Done(): 103 | return nil 104 | case <-token.Done(): 105 | return token.Error() 106 | } 107 | } 108 | 109 | func (m *mqttPublisher) PublishSwingMode(ctx context.Context, input *models.PublishSwingModeInput) error { 110 | topic := m.mqttConfig.TopicPrefix + "/" + input.Mac + "/swing_mode/value" 111 | 112 | token := m.client.Publish(topic, 0, false, input.SwingMode) 113 | select { 114 | case <-ctx.Done(): 115 | return nil 116 | case <-token.Done(): 117 | return token.Error() 118 | } 119 | } 120 | 121 | func (m *mqttPublisher) PublishFanMode(ctx context.Context, input *models.PublishFanModeInput) error { 122 | topic := m.mqttConfig.TopicPrefix + "/" + input.Mac + "/fan_mode/value" 123 | 124 | token := m.client.Publish(topic, 0, false, input.FanMode) 125 | select { 126 | case <-ctx.Done(): 127 | return nil 128 | case <-token.Done(): 129 | return token.Error() 130 | } 131 | } 132 | 133 | func (m *mqttPublisher) PublishAvailability(ctx context.Context, input *models.PublishAvailabilityInput) error { 134 | topic := m.mqttConfig.TopicPrefix + "/" + input.Mac + "/availability/value" 135 | 136 | token := m.client.Publish(topic, 0, false, input.Availability) 137 | select { 138 | case <-ctx.Done(): 139 | return nil 140 | case <-token.Done(): 141 | return token.Error() 142 | } 143 | } 144 | 145 | func (m *mqttPublisher) PublishDisplaySwitch(ctx context.Context, input *models.PublishDisplaySwitchInput) error { 146 | topic := m.mqttConfig.TopicPrefix + "/" + input.Mac + "/display/switch/value" 147 | 148 | token := m.client.Publish(topic, 0, false, input.Status) 149 | select { 150 | case <-ctx.Done(): 151 | return nil 152 | case <-token.Done(): 153 | return token.Error() 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/domain.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | 6 | modelsMqtt "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt/models" 7 | modelsCache "github.com/ArtemVladimirov/broadlinkac2mqtt/app/repository/models" 8 | modelsService "github.com/ArtemVladimirov/broadlinkac2mqtt/app/service/models" 9 | modelsWeb "github.com/ArtemVladimirov/broadlinkac2mqtt/app/webClient/models" 10 | mqtt "github.com/eclipse/paho.mqtt.golang" 11 | ) 12 | 13 | type MqttSubscriber interface { 14 | UpdateFanModeCommandTopic(ctx context.Context) mqtt.MessageHandler 15 | UpdateSwingModeCommandTopic(ctx context.Context) mqtt.MessageHandler 16 | UpdateModeCommandTopic(ctx context.Context) mqtt.MessageHandler 17 | UpdateTemperatureCommandTopic(ctx context.Context) mqtt.MessageHandler 18 | UpdateDisplaySwitchCommandTopic(ctx context.Context) mqtt.MessageHandler 19 | 20 | GetStatesOnHomeAssistantRestart(ctx context.Context) mqtt.MessageHandler 21 | } 22 | 23 | type MqttPublisher interface { 24 | PublishClimateDiscoveryTopic(ctx context.Context, input modelsMqtt.PublishClimateDiscoveryTopicInput) error 25 | PublishSwitchDiscoveryTopic(ctx context.Context, input modelsMqtt.PublishSwitchDiscoveryTopicInput) error 26 | PublishAmbientTemp(ctx context.Context, input *modelsMqtt.PublishAmbientTempInput) error 27 | PublishTemperature(ctx context.Context, input *modelsMqtt.PublishTemperatureInput) error 28 | PublishMode(ctx context.Context, input *modelsMqtt.PublishModeInput) error 29 | PublishSwingMode(ctx context.Context, input *modelsMqtt.PublishSwingModeInput) error 30 | PublishFanMode(ctx context.Context, input *modelsMqtt.PublishFanModeInput) error 31 | PublishAvailability(ctx context.Context, input *modelsMqtt.PublishAvailabilityInput) error 32 | PublishDisplaySwitch(ctx context.Context, input *modelsMqtt.PublishDisplaySwitchInput) error 33 | } 34 | 35 | type Service interface { 36 | PublishDiscoveryTopic(ctx context.Context, input *modelsService.PublishDiscoveryTopicInput) error 37 | CreateDevice(ctx context.Context, input *modelsService.CreateDeviceInput) error 38 | AuthDevice(ctx context.Context, input *modelsService.AuthDeviceInput) error 39 | GetDeviceAmbientTemperature(ctx context.Context, input *modelsService.GetDeviceAmbientTemperatureInput) error 40 | GetDeviceStates(ctx context.Context, input *modelsService.GetDeviceStatesInput) error 41 | 42 | UpdateFanMode(ctx context.Context, input *modelsService.UpdateFanModeInput) error 43 | UpdateMode(ctx context.Context, input *modelsService.UpdateModeInput) error 44 | UpdateSwingMode(ctx context.Context, input *modelsService.UpdateSwingModeInput) error 45 | UpdateTemperature(ctx context.Context, input *modelsService.UpdateTemperatureInput) error 46 | UpdateDisplaySwitch(ctx context.Context, input *modelsService.UpdateDisplaySwitchInput) error 47 | 48 | UpdateDeviceAvailability(ctx context.Context, input *modelsService.UpdateDeviceAvailabilityInput) error 49 | 50 | StartDeviceMonitoring(ctx context.Context, input *modelsService.StartDeviceMonitoringInput) error 51 | 52 | PublishStatesOnHomeAssistantRestart(ctx context.Context, input *modelsService.PublishStatesOnHomeAssistantRestartInput) error 53 | } 54 | 55 | type WebClient interface { 56 | SendCommand(ctx context.Context, input *modelsWeb.SendCommandInput) (*modelsWeb.SendCommandReturn, error) 57 | } 58 | 59 | type Cache interface { 60 | UpsertDeviceConfig(ctx context.Context, input *modelsCache.UpsertDeviceConfigInput) error 61 | ReadDeviceConfig(ctx context.Context, input *modelsCache.ReadDeviceConfigInput) (*modelsCache.ReadDeviceConfigReturn, error) 62 | 63 | UpsertDeviceAuth(ctx context.Context, input *modelsCache.UpsertDeviceAuthInput) error 64 | ReadDeviceAuth(ctx context.Context, input *modelsCache.ReadDeviceAuthInput) (*modelsCache.ReadDeviceAuthReturn, error) 65 | 66 | UpsertAmbientTemp(ctx context.Context, input *modelsCache.UpsertAmbientTempInput) error 67 | ReadAmbientTemp(ctx context.Context, input *modelsCache.ReadAmbientTempInput) (*modelsCache.ReadAmbientTempReturn, error) 68 | 69 | UpsertDeviceStatusRaw(ctx context.Context, input *modelsCache.UpsertDeviceStatusRawInput) error 70 | ReadDeviceStatusRaw(ctx context.Context, input *modelsCache.ReadDeviceStatusRawInput) (*modelsCache.ReadDeviceStatusRawReturn, error) 71 | 72 | UpsertMqttModeMessage(ctx context.Context, input *modelsCache.UpsertMqttModeMessageInput) error 73 | UpsertMqttSwingModeMessage(ctx context.Context, input *modelsCache.UpsertMqttSwingModeMessageInput) error 74 | UpsertMqttFanModeMessage(ctx context.Context, input *modelsCache.UpsertMqttFanModeMessageInput) error 75 | UpsertMqttTemperatureMessage(ctx context.Context, input *modelsCache.UpsertMqttTemperatureMessageInput) error 76 | UpsertMqttDisplaySwitchMessage(ctx context.Context, input *modelsCache.UpsertMqttDisplaySwitchMessageInput) error 77 | 78 | ReadMqttMessage(ctx context.Context, input *modelsCache.ReadMqttMessageInput) (*modelsCache.ReadMqttMessageReturn, error) 79 | 80 | UpsertDeviceAvailability(ctx context.Context, input *modelsCache.UpsertDeviceAvailabilityInput) error 81 | ReadDeviceAvailability(ctx context.Context, input *modelsCache.ReadDeviceAvailabilityInput) (*modelsCache.ReadDeviceAvailabilityReturn, error) 82 | 83 | ReadAuthedDevices(ctx context.Context) (*modelsCache.ReadAuthedDevicesReturn, error) 84 | } 85 | -------------------------------------------------------------------------------- /app/service/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | type Device struct { 9 | Config DeviceConfig 10 | Auth DeviceAuth 11 | } 12 | 13 | type DeviceConfig struct { 14 | Mac string 15 | Ip string 16 | Name string 17 | Port uint16 18 | TemperatureUnit string 19 | } 20 | 21 | func (input *DeviceConfig) Validate() error { 22 | if len(input.Mac) != 12 { 23 | return errors.New("mac address is wrong") 24 | } 25 | 26 | if input.TemperatureUnit != Celsius && input.TemperatureUnit != Fahrenheit { 27 | return errors.New("unknown temperature unit") 28 | } 29 | 30 | return nil 31 | } 32 | 33 | type DeviceAuth struct { 34 | LastMessageId int 35 | DevType int 36 | Id [4]byte 37 | Key []byte 38 | Iv []byte 39 | } 40 | 41 | type DeviceStatusHass struct { 42 | FanMode string 43 | SwingMode string 44 | Mode string 45 | Temperature float32 46 | DisplaySwitch string 47 | } 48 | 49 | type DeviceStatusRaw struct { 50 | UpdatedAt time.Time 51 | Temperature float32 52 | Power byte 53 | FixationVertical byte 54 | Mode byte 55 | Sleep byte 56 | Display byte 57 | Mildew byte 58 | Health byte 59 | FixationHorizontal byte 60 | FanSpeed byte 61 | IFeel byte 62 | Mute byte 63 | Turbo byte 64 | Clean byte 65 | } 66 | 67 | func (raw DeviceStatusRaw) ConvertToDeviceStatusHass() (mqttStatus DeviceStatusHass) { 68 | var deviceStatusMqtt DeviceStatusHass 69 | 70 | // Temperature 71 | deviceStatusMqtt.Temperature = raw.Temperature 72 | 73 | // Modes 74 | if raw.Power == StatusOff { 75 | deviceStatusMqtt.Mode = "off" 76 | } else { 77 | status, ok := ModeStatuses[int(raw.Mode)] 78 | if ok { 79 | deviceStatusMqtt.Mode = status 80 | } else { 81 | deviceStatusMqtt.Mode = "error" 82 | } 83 | } 84 | 85 | // Fan Status 86 | fanStatus, ok := FanStatuses[int(raw.FanSpeed)] 87 | if ok { 88 | deviceStatusMqtt.FanMode = fanStatus 89 | } else { 90 | deviceStatusMqtt.FanMode = "error" 91 | } 92 | 93 | if raw.Mute == StatusOn { 94 | deviceStatusMqtt.FanMode = "mute" 95 | } 96 | 97 | if raw.Turbo == StatusOn { 98 | deviceStatusMqtt.FanMode = "turbo" 99 | } 100 | 101 | // Swing Modes 102 | verticalFixationStatus, ok := VerticalFixationStatuses[int(raw.FixationVertical)] 103 | if ok { 104 | deviceStatusMqtt.SwingMode = verticalFixationStatus 105 | } 106 | 107 | // Display Status 108 | // Attention. Inverted logic 109 | // Byte 0 - turn ON, Byte 1 - turn OFF 110 | if raw.Display == 1 { 111 | deviceStatusMqtt.DisplaySwitch = "OFF" 112 | } else { 113 | deviceStatusMqtt.DisplaySwitch = "ON" 114 | } 115 | 116 | return deviceStatusMqtt 117 | } 118 | 119 | type CreateDeviceInput struct { 120 | Config DeviceConfig 121 | } 122 | 123 | type CreateDeviceReturn struct { 124 | Device Device 125 | } 126 | 127 | type AuthDeviceInput struct { 128 | Mac string 129 | } 130 | 131 | type SendCommandInput struct { 132 | Command byte 133 | Payload []byte 134 | Mac string 135 | } 136 | 137 | type SendCommandReturn struct { 138 | Payload []byte 139 | } 140 | 141 | type GetDeviceAmbientTemperatureInput struct { 142 | Mac string 143 | } 144 | 145 | type GetDeviceStatesInput struct { 146 | Mac string 147 | } 148 | 149 | type PublishDiscoveryTopicInput struct { 150 | Device DeviceConfig 151 | } 152 | 153 | type UpdateFanModeInput struct { 154 | Mac string 155 | FanMode string 156 | } 157 | 158 | func (input *UpdateFanModeInput) Validate() error { 159 | var fanModes = []string{"auto", "low", "medium", "high", "turbo", "mute"} 160 | 161 | for _, fanMode := range fanModes { 162 | if fanMode == input.FanMode { 163 | return nil 164 | } 165 | } 166 | 167 | return ErrorInvalidParameterFanMode 168 | } 169 | 170 | type UpdateModeInput struct { 171 | Mac string 172 | Mode string 173 | } 174 | 175 | func (input UpdateModeInput) Validate() error { 176 | var modes = []string{"auto", "off", "cool", "heat", "dry", "fan_only"} 177 | 178 | for _, mode := range modes { 179 | if mode == input.Mode { 180 | return nil 181 | } 182 | } 183 | 184 | return ErrorInvalidParameterMode 185 | } 186 | 187 | type UpdateSwingModeInput struct { 188 | Mac string 189 | SwingMode string 190 | } 191 | 192 | func (input *UpdateSwingModeInput) Validate() error { 193 | _, ok := VerticalFixationStatusesInvert[input.SwingMode] 194 | if !ok { 195 | return ErrorInvalidParameterSwingMode 196 | } 197 | 198 | return nil 199 | 200 | } 201 | 202 | type UpdateTemperatureInput struct { 203 | Mac string 204 | Temperature float32 205 | } 206 | 207 | func (input UpdateTemperatureInput) Validate() error { 208 | if input.Temperature > 32 || input.Temperature < 16 { 209 | return ErrorInvalidParameterTemperature 210 | } 211 | return nil 212 | } 213 | 214 | type UpdateDeviceStatesInput struct { 215 | Mac string 216 | FanMode *string 217 | SwingMode *string 218 | Mode *string 219 | Temperature *float32 220 | IsDisplayOn *bool 221 | } 222 | 223 | type CreateCommandPayloadReturn struct { 224 | Payload []byte 225 | } 226 | 227 | type UpdateDeviceAvailabilityInput struct { 228 | Mac string 229 | Availability string 230 | } 231 | 232 | type StartDeviceMonitoringInput struct { 233 | Mac string 234 | } 235 | 236 | type PublishStatesOnHomeAssistantRestartInput struct { 237 | Status string 238 | } 239 | 240 | type UpdateDisplaySwitchInput struct { 241 | Mac string 242 | Status string 243 | } 244 | 245 | func (input *UpdateDisplaySwitchInput) Validate() error { 246 | if input.Status != "ON" && input.Status != "OFF" { 247 | return ErrorInvalidParameterDisplayStatus 248 | } 249 | return nil 250 | } 251 | -------------------------------------------------------------------------------- /app/mqtt/subscriber/subscriber.go: -------------------------------------------------------------------------------- 1 | package subscriber 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app" 10 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt/models" 11 | modelsservice "github.com/ArtemVladimirov/broadlinkac2mqtt/app/service/models" 12 | mqtt "github.com/eclipse/paho.mqtt.golang" 13 | ) 14 | 15 | type mqttSubscriber struct { 16 | logger *slog.Logger 17 | mqttConfig models.ConfigMqtt 18 | service app.Service 19 | } 20 | 21 | func NewMqttReceiver(logger *slog.Logger, service app.Service, mqttConfig models.ConfigMqtt) app.MqttSubscriber { 22 | return &mqttSubscriber{ 23 | logger: logger, 24 | mqttConfig: mqttConfig, 25 | service: service, 26 | } 27 | } 28 | 29 | func (m *mqttSubscriber) UpdateFanModeCommandTopic(ctx context.Context) mqtt.MessageHandler { 30 | return func(c mqtt.Client, msg mqtt.Message) { 31 | mac := strings.TrimPrefix(strings.TrimSuffix(msg.Topic(), "/fan_mode/set"), m.mqttConfig.TopicPrefix+"/") 32 | 33 | m.logger.DebugContext(ctx, "new update fan mode message", 34 | slog.String("device", mac), 35 | slog.String("payload", string(msg.Payload())), 36 | slog.String("topic", msg.Topic())) 37 | 38 | updateFanModeInput := &modelsservice.UpdateFanModeInput{ 39 | Mac: mac, 40 | FanMode: string(msg.Payload()), 41 | } 42 | 43 | err := m.service.UpdateFanMode(ctx, updateFanModeInput) 44 | if err != nil { 45 | m.logger.ErrorContext(ctx, "failed to update fan mode", slog.Any("input", updateFanModeInput)) 46 | return 47 | } 48 | } 49 | } 50 | 51 | func (m *mqttSubscriber) UpdateSwingModeCommandTopic(ctx context.Context) mqtt.MessageHandler { 52 | return func(c mqtt.Client, msg mqtt.Message) { 53 | mac := strings.TrimPrefix(strings.TrimSuffix(msg.Topic(), "/swing_mode/set"), m.mqttConfig.TopicPrefix+"/") 54 | 55 | m.logger.DebugContext(ctx, "new update swing mode message", 56 | slog.String("device", mac), 57 | slog.String("payload", string(msg.Payload())), 58 | slog.String("topic", msg.Topic())) 59 | 60 | updateSwingModeInput := &modelsservice.UpdateSwingModeInput{ 61 | Mac: mac, 62 | SwingMode: string(msg.Payload()), 63 | } 64 | 65 | err := m.service.UpdateSwingMode(ctx, updateSwingModeInput) 66 | if err != nil { 67 | m.logger.ErrorContext(ctx, "failed to update swing mode", slog.Any("input", updateSwingModeInput)) 68 | return 69 | } 70 | } 71 | } 72 | 73 | func (m *mqttSubscriber) UpdateModeCommandTopic(ctx context.Context) mqtt.MessageHandler { 74 | return func(c mqtt.Client, msg mqtt.Message) { 75 | mac := strings.TrimPrefix(strings.TrimSuffix(msg.Topic(), "/mode/set"), m.mqttConfig.TopicPrefix+"/") 76 | 77 | m.logger.DebugContext(ctx, "new update mode message", 78 | slog.String("device", mac), 79 | slog.String("payload", string(msg.Payload())), 80 | slog.String("topic", msg.Topic())) 81 | 82 | updateModeInput := &modelsservice.UpdateModeInput{ 83 | Mac: mac, 84 | Mode: string(msg.Payload()), 85 | } 86 | 87 | err := m.service.UpdateMode(ctx, updateModeInput) 88 | if err != nil { 89 | m.logger.ErrorContext(ctx, "failed to update mode", slog.Any("input", updateModeInput)) 90 | return 91 | } 92 | } 93 | } 94 | 95 | func (m *mqttSubscriber) UpdateTemperatureCommandTopic(ctx context.Context) mqtt.MessageHandler { 96 | return func(c mqtt.Client, msg mqtt.Message) { 97 | mac := strings.TrimPrefix(strings.TrimSuffix(msg.Topic(), "/temp/set"), m.mqttConfig.TopicPrefix+"/") 98 | 99 | m.logger.DebugContext(ctx, "new update temperature mode message", 100 | slog.String("device", mac), 101 | slog.String("payload", string(msg.Payload())), 102 | slog.String("topic", msg.Topic())) 103 | 104 | temperature, err := strconv.ParseFloat(string(msg.Payload()), 32) 105 | if err != nil { 106 | m.logger.ErrorContext(ctx, "failed to parse temperature", slog.Any("err", err), slog.String("input", string(msg.Payload()))) 107 | return 108 | } 109 | 110 | updateTemperatureInput := &modelsservice.UpdateTemperatureInput{ 111 | Mac: mac, 112 | Temperature: float32(temperature), 113 | } 114 | 115 | err = m.service.UpdateTemperature(ctx, updateTemperatureInput) 116 | if err != nil { 117 | m.logger.ErrorContext(ctx, "failed to update temperature", slog.Any("input", updateTemperatureInput)) 118 | return 119 | } 120 | } 121 | } 122 | 123 | func (m *mqttSubscriber) GetStatesOnHomeAssistantRestart(ctx context.Context) mqtt.MessageHandler { 124 | return func(c mqtt.Client, msg mqtt.Message) { 125 | m.logger.DebugContext(ctx, "new home assistant LWT message", 126 | slog.String("payload", string(msg.Payload())), 127 | slog.String("topic", msg.Topic())) 128 | 129 | getStatesOnHomeAssistantRestartInput := &modelsservice.PublishStatesOnHomeAssistantRestartInput{ 130 | Status: string(msg.Payload()), 131 | } 132 | 133 | err := m.service.PublishStatesOnHomeAssistantRestart(ctx, getStatesOnHomeAssistantRestartInput) 134 | if err != nil { 135 | m.logger.ErrorContext(ctx, "failed to get states", slog.Any("input", getStatesOnHomeAssistantRestartInput)) 136 | return 137 | } 138 | } 139 | } 140 | 141 | func (m *mqttSubscriber) UpdateDisplaySwitchCommandTopic(ctx context.Context) mqtt.MessageHandler { 142 | return func(c mqtt.Client, msg mqtt.Message) { 143 | mac := strings.TrimPrefix(strings.TrimSuffix(msg.Topic(), "/display/switch/set"), m.mqttConfig.TopicPrefix+"/") 144 | 145 | m.logger.DebugContext(ctx, "new update display status message", 146 | slog.String("device", mac), 147 | slog.String("payload", string(msg.Payload())), 148 | slog.String("topic", msg.Topic())) 149 | 150 | updateDisplaySwitchInput := &modelsservice.UpdateDisplaySwitchInput{ 151 | Mac: mac, 152 | Status: string(msg.Payload()), 153 | } 154 | 155 | err := m.service.UpdateDisplaySwitch(ctx, updateDisplaySwitchInput) 156 | if err != nil { 157 | m.logger.ErrorContext(ctx, "failed to update display switch", slog.Any("input", updateDisplaySwitchInput)) 158 | return 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app" 14 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt" 15 | workspaceMqttModels "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt/models" 16 | workspaceMqttSender "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt/publisher" 17 | workspaceMqttReceiver "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt/subscriber" 18 | workspaceCache "github.com/ArtemVladimirov/broadlinkac2mqtt/app/repository/cache" 19 | workspaceService "github.com/ArtemVladimirov/broadlinkac2mqtt/app/service" 20 | workspaceServiceModels "github.com/ArtemVladimirov/broadlinkac2mqtt/app/service/models" 21 | workspaceWebClient "github.com/ArtemVladimirov/broadlinkac2mqtt/app/webClient" 22 | "github.com/ArtemVladimirov/broadlinkac2mqtt/config" 23 | paho "github.com/eclipse/paho.mqtt.golang" 24 | "golang.org/x/sync/errgroup" 25 | ) 26 | 27 | type App struct { 28 | devices []workspaceServiceModels.DeviceConfig 29 | autoDiscoveryTopic *string 30 | topicPrefix string 31 | logLevel string 32 | wsBroadLinkReceiver app.WebClient 33 | wsMqttReceiver app.MqttSubscriber 34 | wsService app.Service 35 | client paho.Client 36 | } 37 | 38 | func NewApp(logger *slog.Logger) (*App, error) { 39 | // Configuration 40 | cfg, err := config.NewConfig(logger) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | // MQTT 46 | mqttConfig := workspaceMqttModels.ConfigMqtt{ 47 | Broker: cfg.Mqtt.Broker, 48 | User: cfg.Mqtt.User, 49 | Password: cfg.Mqtt.Password, 50 | ClientId: cfg.Mqtt.ClientId, 51 | TopicPrefix: cfg.Mqtt.TopicPrefix, 52 | AutoDiscoveryTopic: cfg.Mqtt.AutoDiscoveryTopic, 53 | AutoDiscoveryTopicRetain: cfg.Mqtt.AutoDiscoveryTopicRetain, 54 | } 55 | 56 | opts, _ := mqtt.NewMqttConfig(logger, cfg.Mqtt) 57 | client := paho.NewClient(opts) 58 | 59 | //Configure MQTT Sender Layer 60 | mqttSender := workspaceMqttSender.NewMqttSender( 61 | logger, 62 | mqttConfig, 63 | client, 64 | ) 65 | 66 | //Configure Service Layer 67 | service := workspaceService.NewService( 68 | logger, 69 | cfg.Mqtt.TopicPrefix, 70 | cfg.Service.UpdateInterval, 71 | mqttSender, 72 | workspaceWebClient.NewWebClient(logger), 73 | workspaceCache.NewCache(logger), 74 | ) 75 | //Configure MQTT Receiver Layer 76 | mqttReceiver := workspaceMqttReceiver.NewMqttReceiver( 77 | logger, 78 | service, 79 | mqttConfig, 80 | ) 81 | 82 | devices := make([]workspaceServiceModels.DeviceConfig, 0, len(cfg.Devices)) 83 | for _, device := range cfg.Devices { 84 | if len(device.TemperatureUnit) == 0 { 85 | device.TemperatureUnit = "C" 86 | } 87 | 88 | dev := workspaceServiceModels.DeviceConfig{ 89 | Ip: device.Ip, 90 | Mac: strings.ToLower(device.Mac), 91 | Name: device.Name, 92 | Port: device.Port, 93 | TemperatureUnit: strings.ToUpper(device.TemperatureUnit), 94 | } 95 | 96 | err = dev.Validate() 97 | if err != nil { 98 | logger.Error("device config is incorrect", slog.String("device", device.Mac), slog.Any("err", err)) 99 | return nil, err 100 | } 101 | 102 | devices = append(devices, dev) 103 | } 104 | 105 | application := &App{ 106 | wsMqttReceiver: mqttReceiver, 107 | client: client, 108 | devices: devices, 109 | wsService: service, 110 | topicPrefix: cfg.Mqtt.TopicPrefix, 111 | autoDiscoveryTopic: cfg.Mqtt.AutoDiscoveryTopic, 112 | logLevel: cfg.Service.LogLevel, 113 | } 114 | 115 | return application, nil 116 | } 117 | 118 | func (app *App) Run(ctx context.Context, logger *slog.Logger) error { 119 | // Run MQTT 120 | if token := app.client.Connect(); token.Wait() && token.Error() != nil { 121 | err := token.Error() 122 | if err != nil { 123 | logger.ErrorContext(ctx, "failed to connect mqtt", 124 | slog.Any("err", err)) 125 | return err 126 | } 127 | } 128 | 129 | if app.autoDiscoveryTopic != nil { 130 | if token := app.client.Subscribe(*app.autoDiscoveryTopic+"/status", 0, app.wsMqttReceiver.GetStatesOnHomeAssistantRestart(ctx)); token.Wait() && token.Error() != nil { 131 | err := token.Error() 132 | if err != nil { 133 | logger.ErrorContext(ctx, "failed to subscribe on LWT", 134 | slog.Any("err", err)) 135 | 136 | return err 137 | } 138 | } 139 | } 140 | 141 | // Create Device 142 | for _, device := range app.devices { 143 | err := app.wsService.CreateDevice(ctx, &workspaceServiceModels.CreateDeviceInput{ 144 | Config: workspaceServiceModels.DeviceConfig{ 145 | Mac: device.Mac, 146 | Ip: device.Ip, 147 | Name: device.Name, 148 | Port: device.Port, 149 | TemperatureUnit: device.TemperatureUnit, 150 | }}) 151 | if err != nil { 152 | logger.ErrorContext(ctx, "failed to create the device", 153 | slog.Any("err", err)) 154 | return err 155 | } 156 | } 157 | 158 | for _, device := range app.devices { 159 | device := device 160 | go func() { 161 | for { 162 | err := app.wsService.AuthDevice(ctx, &workspaceServiceModels.AuthDeviceInput{Mac: device.Mac}) 163 | if err == nil { 164 | break 165 | } 166 | logger.ErrorContext(ctx, "failed to Auth device "+device.Mac+". Reconnect in 3 seconds...", 167 | slog.Any("err", err)) 168 | time.Sleep(time.Second * 3) 169 | } 170 | 171 | // Subscribe on MQTT handlers 172 | workspaceMqttReceiver.Routers(ctx, logger, device.Mac, app.topicPrefix, app.client, app.wsMqttReceiver) 173 | 174 | //Publish Discovery Topic 175 | if app.autoDiscoveryTopic != nil { 176 | err := app.wsService.PublishDiscoveryTopic(ctx, &workspaceServiceModels.PublishDiscoveryTopicInput{Device: device}) 177 | if err != nil { 178 | return 179 | } 180 | } 181 | 182 | err := app.wsService.StartDeviceMonitoring(ctx, &workspaceServiceModels.StartDeviceMonitoringInput{Mac: device.Mac}) 183 | if err != nil { 184 | return 185 | } 186 | }() 187 | } 188 | 189 | // Graceful shutdown 190 | interrupt := make(chan os.Signal, 1) 191 | signal.Notify(interrupt, syscall.SIGKILL, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT) 192 | killSignal := <-interrupt 193 | switch killSignal { 194 | case syscall.SIGKILL: 195 | logger.Info("Got SIGKILL...") 196 | case syscall.SIGQUIT: 197 | logger.Info("Got SIGQUIT...") 198 | case syscall.SIGTERM: 199 | logger.Info("Got SIGTERM...") 200 | case syscall.SIGINT: 201 | logger.Info("Got SIGINT...") 202 | default: 203 | logger.Info("Undefined killSignal...") 204 | } 205 | // Publish offline states for devices 206 | g := new(errgroup.Group) 207 | for _, device := range app.devices { 208 | device := device 209 | g.Go(func() error { 210 | err := app.wsService.UpdateDeviceAvailability(ctx, &workspaceServiceModels.UpdateDeviceAvailabilityInput{ 211 | Mac: device.Mac, 212 | Availability: "offline", 213 | }) 214 | if err != nil { 215 | logger.ErrorContext(ctx, "failed to update availability", 216 | slog.String("device", device.Mac), 217 | slog.Any("err", err)) 218 | return err 219 | } 220 | return nil 221 | }) 222 | } 223 | if err := g.Wait(); err != nil { 224 | return err 225 | } 226 | // Disconnect MQTT 227 | app.client.Disconnect(100) 228 | 229 | return nil 230 | } 231 | 232 | func main() { 233 | ctx, cancel := context.WithCancel(context.Background()) 234 | defer cancel() 235 | 236 | logLevel := &slog.LevelVar{} 237 | logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 238 | AddSource: true, 239 | Level: logLevel, 240 | })) 241 | 242 | application, err := NewApp(logger) 243 | if err != nil { 244 | logger.ErrorContext(ctx, "failed to get a new App", slog.Any("err", err)) 245 | return 246 | } 247 | 248 | switch application.logLevel { 249 | case "error": 250 | logLevel.Set(slog.LevelError) 251 | case "debug": 252 | logLevel.Set(slog.LevelDebug) 253 | case "disabled": 254 | logger = slog.New(slog.NewJSONHandler(io.Discard, nil)) 255 | case "info": 256 | logLevel.Set(slog.LevelInfo) 257 | default: 258 | logLevel.Set(slog.LevelError) 259 | } 260 | 261 | // Run 262 | err = application.Run(ctx, logger) 263 | if err != nil { 264 | logger.ErrorContext(ctx, "failed to get a new App", slog.Any("err", err)) 265 | return 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /app/repository/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "sync" 7 | 8 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app" 9 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app/repository/models" 10 | ) 11 | 12 | type cache struct { 13 | devices map[string]models.Device 14 | mutex *sync.RWMutex 15 | logger *slog.Logger 16 | } 17 | 18 | func NewCache(logger *slog.Logger) app.Cache { 19 | return &cache{ 20 | devices: make(map[string]models.Device), 21 | mutex: new(sync.RWMutex), 22 | logger: logger, 23 | } 24 | } 25 | 26 | func (c *cache) UpsertDeviceConfig(ctx context.Context, input *models.UpsertDeviceConfigInput) error { 27 | c.mutex.Lock() 28 | defer c.mutex.Unlock() 29 | 30 | device := c.devices[input.Config.Mac] 31 | device.Config = input.Config 32 | c.devices[input.Config.Mac] = device 33 | return nil 34 | } 35 | 36 | func (c *cache) ReadDeviceConfig(ctx context.Context, input *models.ReadDeviceConfigInput) (*models.ReadDeviceConfigReturn, error) { 37 | c.mutex.RLock() 38 | defer c.mutex.RUnlock() 39 | 40 | device, ok := c.devices[input.Mac] 41 | if !ok { 42 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 43 | return nil, models.ErrorDeviceNotFound 44 | } 45 | 46 | return &models.ReadDeviceConfigReturn{Config: device.Config}, nil 47 | } 48 | 49 | func (c *cache) UpsertDeviceAuth(ctx context.Context, input *models.UpsertDeviceAuthInput) error { 50 | c.mutex.Lock() 51 | defer c.mutex.Unlock() 52 | 53 | device, ok := c.devices[input.Mac] 54 | if !ok { 55 | return models.ErrorDeviceNotFound 56 | } 57 | 58 | device.Auth = &input.Auth 59 | c.devices[input.Mac] = device 60 | return nil 61 | } 62 | 63 | func (c *cache) ReadDeviceAuth(ctx context.Context, input *models.ReadDeviceAuthInput) (*models.ReadDeviceAuthReturn, error) { 64 | c.mutex.RLock() 65 | defer c.mutex.RUnlock() 66 | 67 | device, ok := c.devices[input.Mac] 68 | if !ok { 69 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 70 | return nil, models.ErrorDeviceNotFound 71 | } 72 | 73 | if device.Auth == nil { 74 | return nil, models.ErrorDeviceAuthNotFound 75 | } 76 | 77 | return &models.ReadDeviceAuthReturn{Auth: *device.Auth}, nil 78 | } 79 | 80 | func (c *cache) UpsertAmbientTemp(ctx context.Context, input *models.UpsertAmbientTempInput) error { 81 | c.mutex.Lock() 82 | defer c.mutex.Unlock() 83 | 84 | device, ok := c.devices[input.Mac] 85 | if !ok { 86 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 87 | return models.ErrorDeviceNotFound 88 | } 89 | 90 | device.DeviceStatus.AmbientTemp = &input.Temperature 91 | c.devices[input.Mac] = device 92 | return nil 93 | } 94 | 95 | func (c *cache) ReadAmbientTemp(ctx context.Context, input *models.ReadAmbientTempInput) (*models.ReadAmbientTempReturn, error) { 96 | c.mutex.RLock() 97 | defer c.mutex.RUnlock() 98 | 99 | device, ok := c.devices[input.Mac] 100 | if !ok { 101 | message := "device is not found in cache" 102 | c.logger.ErrorContext(ctx, message, slog.Any("input", input)) 103 | return nil, models.ErrorDeviceNotFound 104 | } 105 | 106 | if device.DeviceStatus.AmbientTemp == nil { 107 | return nil, models.ErrorDeviceStatusAmbientTempNotFound 108 | } 109 | 110 | return &models.ReadAmbientTempReturn{Temperature: *device.DeviceStatus.AmbientTemp}, nil 111 | } 112 | 113 | func (c *cache) UpsertDeviceStatusRaw(ctx context.Context, input *models.UpsertDeviceStatusRawInput) error { 114 | c.mutex.Lock() 115 | defer c.mutex.Unlock() 116 | 117 | device, ok := c.devices[input.Mac] 118 | if !ok { 119 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 120 | return models.ErrorDeviceNotFound 121 | } 122 | 123 | device.DeviceStatusRaw = &input.Status 124 | c.devices[input.Mac] = device 125 | return nil 126 | } 127 | 128 | func (c *cache) ReadDeviceStatusRaw(ctx context.Context, input *models.ReadDeviceStatusRawInput) (*models.ReadDeviceStatusRawReturn, error) { 129 | c.mutex.RLock() 130 | defer c.mutex.RUnlock() 131 | 132 | device, ok := c.devices[input.Mac] 133 | if !ok { 134 | message := "device is not found in cache" 135 | c.logger.ErrorContext(ctx, message, slog.Any("input", input)) 136 | return nil, models.ErrorDeviceNotFound 137 | } 138 | 139 | if device.DeviceStatusRaw == nil { 140 | return nil, models.ErrorDeviceStatusRawNotFound 141 | } 142 | 143 | return &models.ReadDeviceStatusRawReturn{Status: *device.DeviceStatusRaw}, nil 144 | } 145 | 146 | func (c *cache) UpsertMqttModeMessage(ctx context.Context, input *models.UpsertMqttModeMessageInput) error { 147 | c.mutex.Lock() 148 | defer c.mutex.Unlock() 149 | 150 | device, ok := c.devices[input.Mac] 151 | if !ok { 152 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 153 | return models.ErrorDeviceNotFound 154 | } 155 | 156 | device.MqttLastMessage.Mode = &input.Mode 157 | c.devices[input.Mac] = device 158 | return nil 159 | } 160 | 161 | func (c *cache) UpsertMqttSwingModeMessage(ctx context.Context, input *models.UpsertMqttSwingModeMessageInput) error { 162 | c.mutex.Lock() 163 | defer c.mutex.Unlock() 164 | 165 | device, ok := c.devices[input.Mac] 166 | if !ok { 167 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 168 | return models.ErrorDeviceNotFound 169 | } 170 | 171 | device.MqttLastMessage.SwingMode = &input.SwingMode 172 | c.devices[input.Mac] = device 173 | return nil 174 | } 175 | 176 | func (c *cache) UpsertMqttFanModeMessage(ctx context.Context, input *models.UpsertMqttFanModeMessageInput) error { 177 | c.mutex.Lock() 178 | defer c.mutex.Unlock() 179 | 180 | device, ok := c.devices[input.Mac] 181 | if !ok { 182 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 183 | return models.ErrorDeviceNotFound 184 | } 185 | 186 | device.MqttLastMessage.FanMode = &input.FanMode 187 | c.devices[input.Mac] = device 188 | return nil 189 | } 190 | 191 | func (c *cache) UpsertMqttTemperatureMessage(ctx context.Context, input *models.UpsertMqttTemperatureMessageInput) error { 192 | c.mutex.Lock() 193 | defer c.mutex.Unlock() 194 | 195 | device, ok := c.devices[input.Mac] 196 | if !ok { 197 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 198 | return models.ErrorDeviceNotFound 199 | } 200 | 201 | device.MqttLastMessage.Temperature = &input.Temperature 202 | c.devices[input.Mac] = device 203 | return nil 204 | } 205 | 206 | func (c *cache) ReadMqttMessage(ctx context.Context, input *models.ReadMqttMessageInput) (*models.ReadMqttMessageReturn, error) { 207 | c.mutex.RLock() 208 | defer c.mutex.RUnlock() 209 | 210 | device, ok := c.devices[input.Mac] 211 | if !ok { 212 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 213 | return nil, models.ErrorDeviceNotFound 214 | } 215 | 216 | return &models.ReadMqttMessageReturn{ 217 | Temperature: device.MqttLastMessage.Temperature, 218 | SwingMode: device.MqttLastMessage.SwingMode, 219 | FanMode: device.MqttLastMessage.FanMode, 220 | Mode: device.MqttLastMessage.Mode, 221 | IsDisplayOn: device.MqttLastMessage.DisplaySwitch, 222 | }, nil 223 | } 224 | 225 | func (c *cache) UpsertDeviceAvailability(ctx context.Context, input *models.UpsertDeviceAvailabilityInput) error { 226 | c.mutex.Lock() 227 | defer c.mutex.Unlock() 228 | 229 | device, ok := c.devices[input.Mac] 230 | if !ok { 231 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 232 | return models.ErrorDeviceNotFound 233 | } 234 | 235 | device.DeviceStatus.Availability = &input.Availability 236 | c.devices[input.Mac] = device 237 | 238 | return nil 239 | } 240 | 241 | func (c *cache) ReadDeviceAvailability(ctx context.Context, input *models.ReadDeviceAvailabilityInput) (*models.ReadDeviceAvailabilityReturn, error) { 242 | c.mutex.RLock() 243 | defer c.mutex.RUnlock() 244 | 245 | device, ok := c.devices[input.Mac] 246 | if !ok { 247 | message := "device is not found in cache" 248 | c.logger.ErrorContext(ctx, message, slog.Any("input", input)) 249 | return nil, models.ErrorDeviceNotFound 250 | } 251 | 252 | if device.DeviceStatus.Availability == nil { 253 | c.logger.ErrorContext(ctx, "device status ambient temp is not found in cache", slog.Any("input", input)) 254 | return nil, models.ErrorDeviceStatusAvailabilityNotFound 255 | } 256 | 257 | return &models.ReadDeviceAvailabilityReturn{Availability: *device.DeviceStatus.Availability}, nil 258 | } 259 | 260 | func (c *cache) ReadAuthedDevices(ctx context.Context) (*models.ReadAuthedDevicesReturn, error) { 261 | c.mutex.RLock() 262 | defer c.mutex.RUnlock() 263 | 264 | macs := make([]string, 0, len(c.devices)) 265 | for mac := range c.devices { 266 | macs = append(macs, mac) 267 | } 268 | 269 | return &models.ReadAuthedDevicesReturn{Macs: macs}, nil 270 | } 271 | 272 | func (c *cache) UpsertMqttDisplaySwitchMessage(ctx context.Context, input *models.UpsertMqttDisplaySwitchMessageInput) error { 273 | c.mutex.Lock() 274 | defer c.mutex.Unlock() 275 | 276 | device, ok := c.devices[input.Mac] 277 | if !ok { 278 | c.logger.ErrorContext(ctx, "device is not found in cache", slog.Any("input", input)) 279 | return models.ErrorDeviceNotFound 280 | } 281 | 282 | device.MqttLastMessage.DisplaySwitch = &input.DisplaySwitch 283 | c.devices[input.Mac] = device 284 | return nil 285 | } 286 | -------------------------------------------------------------------------------- /app/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | "math/rand" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app" 12 | modelsMqtt "github.com/ArtemVladimirov/broadlinkac2mqtt/app/mqtt/models" 13 | modelsRepo "github.com/ArtemVladimirov/broadlinkac2mqtt/app/repository/models" 14 | "github.com/ArtemVladimirov/broadlinkac2mqtt/app/service/models" 15 | modelsWeb "github.com/ArtemVladimirov/broadlinkac2mqtt/app/webClient/models" 16 | "github.com/ArtemVladimirov/broadlinkac2mqtt/pkg/coder" 17 | "github.com/ArtemVladimirov/broadlinkac2mqtt/pkg/converter" 18 | "golang.org/x/sync/errgroup" 19 | ) 20 | 21 | type service struct { 22 | updateInterval int 23 | topicPrefix string 24 | mqtt app.MqttPublisher 25 | webClient app.WebClient 26 | cache app.Cache 27 | logger *slog.Logger 28 | } 29 | 30 | func NewService(logger *slog.Logger, topicPrefix string, updateInterval int, mqtt app.MqttPublisher, webClient app.WebClient, cache app.Cache) app.Service { 31 | return &service{ 32 | logger: logger, 33 | topicPrefix: topicPrefix, 34 | updateInterval: updateInterval, 35 | mqtt: mqtt, 36 | webClient: webClient, 37 | cache: cache, 38 | } 39 | } 40 | 41 | func (s *service) CreateDevice(ctx context.Context, input *models.CreateDeviceInput) error { 42 | key := []byte{0x09, 0x76, 0x28, 0x34, 0x3f, 0xe9, 0x9e, 0x23, 0x76, 0x5c, 0x15, 0x13, 0xac, 0xcf, 0x8b, 0x02} 43 | iv := []byte{0x56, 0x2e, 0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58} 44 | 45 | // Store device information in the repository 46 | upsertDeviceConfigInput := &modelsRepo.UpsertDeviceConfigInput{ 47 | Config: modelsRepo.DeviceConfig(input.Config), 48 | } 49 | err := s.cache.UpsertDeviceConfig(ctx, upsertDeviceConfigInput) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | auth := modelsRepo.DeviceAuth{ 55 | LastMessageId: rand.Intn(0xffff), 56 | DevType: 0x4E2a, 57 | Id: [4]byte{0, 0, 0, 0}, 58 | Key: key, 59 | Iv: iv, 60 | } 61 | 62 | // Store device information in the repository 63 | upsertDeviceAuthInput := &modelsRepo.UpsertDeviceAuthInput{ 64 | Mac: input.Config.Mac, 65 | Auth: auth, 66 | } 67 | err = s.cache.UpsertDeviceAuth(ctx, upsertDeviceAuthInput) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | return nil 73 | } 74 | 75 | /* 76 | AuthDevice 77 | 78 | Request 79 | 80 | 0000 34 ea 34 da da c8 e0 d5 5e 68 9e 3e 08 00 45 00 81 | 0010 00 a4 16 d4 00 00 80 11 00 00 c0 a8 01 24 c0 a8 82 | 0020 01 13 f9 a1 00 50 00 90 84 29 5a a5 aa 55 5a a5 83 | 0030 aa 55 00 00 00 00 00 00 00 00 00 00 00 00 00 00 84 | 0040 00 00 00 00 00 00 00 00 00 00 de f0 00 00 2a 4e 85 | 0050 65 00 63 f7 34 ea 34 da da c8 00 00 00 00 a1 c3 86 | 0060 00 00 45 34 52 e7 f9 2e da 95 83 44 93 08 35 ef 87 | 0070 9a 6d fb 69 2d c3 70 b9 04 43 ac 5c d6 3f bb 53 88 | 0080 ad fa 08 81 4c a7 f8 cf 41 71 00 32 8e 57 0c 3b 89 | 0090 86 c9 4d 05 70 84 49 a3 89 e2 9a e1 04 54 36 a0 90 | 00a0 5b dd dc 02 c1 61 af 13 25 e8 7e 19 b0 f7 d1 ce 91 | 00b0 06 8d 92 | 93 | Response 94 | 95 | 0000 e0 d5 5e 68 9e 3e 34 ea 34 da da c8 08 00 45 00 96 | 0010 00 74 56 1e 00 00 40 11 a0 d3 c0 a8 01 13 c0 a8 97 | 0020 01 24 00 50 f9 a1 00 60 18 82 5a a5 aa 55 5a a5 98 | 0030 aa 55 00 00 00 00 00 00 00 00 00 00 00 00 00 00 99 | 0040 00 00 00 00 00 00 00 00 00 00 28 dc 00 00 2a 4e 100 | 0050 e9 03 63 f7 34 ea 34 da da c8 00 00 00 00 c1 c7 101 | 0060 00 00 bb 6c bb bb 34 58 5c d4 42 b9 cf bb db 30 102 | 0070 3e ea 55 af e0 62 cd d6 38 16 4b 81 cc 38 40 84 103 | 0080 ef 9e 104 | */ 105 | func (s *service) AuthDevice(ctx context.Context, input *models.AuthDeviceInput) error { 106 | payload := [0x50]byte{} 107 | payload[0x04] = 0x31 108 | payload[0x05] = 0x31 109 | payload[0x06] = 0x31 110 | payload[0x07] = 0x31 111 | payload[0x08] = 0x31 112 | payload[0x09] = 0x31 113 | payload[0x0a] = 0x31 114 | payload[0x0b] = 0x31 115 | payload[0x0c] = 0x31 116 | payload[0x0d] = 0x31 117 | payload[0x0e] = 0x31 118 | payload[0x0f] = 0x31 119 | payload[0x10] = 0x31 120 | payload[0x11] = 0x31 121 | payload[0x12] = 0x31 122 | payload[0x1e] = 0x01 123 | payload[0x2d] = 0x01 124 | payload[0x30] = byte('T') 125 | payload[0x31] = byte('e') 126 | payload[0x32] = byte('s') 127 | payload[0x33] = byte('t') 128 | payload[0x34] = byte(' ') 129 | payload[0x35] = byte(' ') 130 | payload[0x36] = byte('1') 131 | 132 | sendCommandInput := &models.SendCommandInput{ 133 | Command: 0x65, 134 | Payload: payload[:], 135 | Mac: input.Mac, 136 | } 137 | response, err := s.sendCommand(ctx, sendCommandInput) 138 | if err != nil { 139 | s.logger.ErrorContext(ctx, "failed to send command", slog.Any("err", err), slog.Any("input", input)) 140 | return err 141 | } 142 | 143 | // Decode message 144 | if len(response.Payload) >= 0x38 { 145 | response.Payload = response.Payload[0x38:] 146 | } else { 147 | const msg = "response is too short" 148 | s.logger.ErrorContext(ctx, msg, slog.Any("input", input), slog.Any("payload", response.Payload)) 149 | return errors.New(msg) 150 | } 151 | 152 | // Read the saved value in repo if no 153 | readDeviceAuthInput := &modelsRepo.ReadDeviceAuthInput{ 154 | Mac: input.Mac, 155 | } 156 | readDeviceAuthReturn, err := s.cache.ReadDeviceAuth(ctx, readDeviceAuthInput) 157 | if err != nil { 158 | s.logger.ErrorContext(ctx, "device not found", slog.Any("err", err), slog.Any("input", input)) 159 | return err 160 | } 161 | auth := readDeviceAuthReturn.Auth 162 | 163 | response.Payload, err = coder.Decrypt(auth.Key, auth.Iv, response.Payload) 164 | if err != nil { 165 | s.logger.ErrorContext(ctx, "failed to decode response", slog.Any("err", err), slog.Any("input", input)) 166 | return err 167 | } 168 | 169 | auth = modelsRepo.DeviceAuth{ 170 | LastMessageId: auth.LastMessageId, 171 | DevType: auth.DevType, 172 | Id: [4]byte{response.Payload[0], response.Payload[1], response.Payload[2], response.Payload[3]}, 173 | Key: response.Payload[0x04:0x14], 174 | Iv: auth.Iv, 175 | } 176 | 177 | // Update the last message id in the cache 178 | upsertDeviceAuthInput := &modelsRepo.UpsertDeviceAuthInput{ 179 | Mac: input.Mac, 180 | Auth: auth, 181 | } 182 | return s.cache.UpsertDeviceAuth(ctx, upsertDeviceAuthInput) 183 | } 184 | 185 | /* 186 | GetDeviceAmbientTemperature 187 | 188 | Request 189 | 190 | 0000 34 ea 34 da da c8 e0 d5 5e 68 9e 3e 08 00 45 00 191 | 0010 00 64 16 d6 00 00 80 11 00 00 c0 a8 01 24 c0 a8 192 | 0020 01 13 e0 dc 00 50 00 50 83 e9// 5a a5 aa 55 5a a5 193 | 0030 aa 55 00 00 00 00 00 00 00 00 00 00 00 00 00 00 194 | 0040 00 00 00 00 00 00 00 00 00 00 a1 d1 00 00 2a 4e 195 | 0050 6a 00 90 7c 34 ea 34 da da c8 01 00 00 00 b9 c0 196 | 0060 00 00 3d 19 77 32 16 2c b4 f5 f9 e1 8a ca 7b 1b 197 | 0070 ff 13 198 | 199 | Response 200 | 201 | 0000 e0 d5 5e 68 9e 3e 34 ea 34 da da c8 08 00 45 00 202 | 0010 00 84 56 ab 00 00 40 11 a0 36 c0 a8 01 13 c0 a8 203 | 0020 01 24 00 50 e0 dc 00 70 08 12 5a a5 aa 55 5a a5 204 | 0030 aa 55 00 00 00 00 00 00 00 00 00 00 00 00 00 00 205 | 0040 00 00 00 00 00 00 00 00 00 00 40 e3 00 00 2a 4e 206 | 0050 ee 03 90 7c 34 ea 34 da da c8 01 00 00 00 cf c1 207 | 0060 00 00 2c 4f a6 c5 65 f7 8b 46 82 92 20 a3 6f bf 208 | 0070 65 24 a6 8a 04 97 eb 37 ef e6 a6 42 2a 4f 6b 8a 209 | 0080 ed 81 d1 67 c3 8d b2 69 c5 0a e4 e2 91 05 bc 52 210 | 0090 5e 60 211 | */ 212 | func (s *service) GetDeviceAmbientTemperature(ctx context.Context, input *models.GetDeviceAmbientTemperatureInput) error { 213 | sendCommandInput := &models.SendCommandInput{ 214 | Command: 0x6a, 215 | Payload: []byte{12, 0, 187, 0, 6, 128, 0, 0, 2, 0, 33, 1, 27, 126, 0, 0}, 216 | Mac: input.Mac, 217 | } 218 | response, err := s.sendCommand(ctx, sendCommandInput) 219 | if err != nil { 220 | s.logger.ErrorContext(ctx, "failed to send a command", slog.Any("err", err), slog.Any("input", sendCommandInput)) 221 | return err 222 | } 223 | 224 | if uint16(response.Payload[0x22])|(uint16(response.Payload[0x23])<<8) != 0 { 225 | s.logger.ErrorContext(ctx, "Checksum is incorrect", slog.Any("input", sendCommandInput)) 226 | return models.ErrorInvalidResultPacket 227 | } 228 | 229 | // Decode message 230 | if len(response.Payload) >= 0x38 { 231 | response.Payload = response.Payload[0x38:] 232 | } else { 233 | s.logger.ErrorContext(ctx, "response is too short", slog.Any("input", sendCommandInput)) 234 | return models.ErrorInvalidResultPacketLength 235 | } 236 | 237 | // Read the saved value in repo if no 238 | readDeviceAuthInput := &modelsRepo.ReadDeviceAuthInput{ 239 | Mac: input.Mac, 240 | } 241 | readDeviceAuthReturn, err := s.cache.ReadDeviceAuth(ctx, readDeviceAuthInput) 242 | if err != nil { 243 | s.logger.ErrorContext(ctx, "device not found", slog.Any("err", err), slog.Any("input", readDeviceAuthInput)) 244 | return err 245 | } 246 | 247 | response.Payload, err = coder.Decrypt(readDeviceAuthReturn.Auth.Key, readDeviceAuthReturn.Auth.Iv, response.Payload) 248 | if err != nil { 249 | s.logger.ErrorContext(ctx, "failed to decrypt response", slog.Any("err", err), slog.Any("input", response.Payload)) 250 | return err 251 | } 252 | 253 | //Drop leading stuff as don't need 254 | response.Payload = response.Payload[2:] 255 | 256 | if len(response.Payload) < 40 { 257 | return models.ErrorInvalidResultPacketLength 258 | } 259 | 260 | ambientTemp := float32(response.Payload[15]-0b00100000) + (float32(response.Payload[31]) / 10) 261 | 262 | readAmbientTempInput := &modelsRepo.ReadAmbientTempInput{Mac: input.Mac} 263 | readAmbientTempReturn, err := s.cache.ReadAmbientTemp(ctx, readAmbientTempInput) 264 | if err != nil { 265 | switch err { 266 | case modelsRepo.ErrorDeviceStatusAmbientTempNotFound: 267 | err = nil 268 | default: 269 | s.logger.ErrorContext(ctx, "failed to read the ambient temperature", 270 | slog.Any("err", err), 271 | slog.Any("input", readAmbientTempInput)) 272 | return err 273 | } 274 | } 275 | 276 | if readAmbientTempReturn != nil { 277 | // Sometimes there is strange temperature 278 | if readAmbientTempReturn.Temperature-ambientTemp > 4 || ambientTemp-readAmbientTempReturn.Temperature > 4 { 279 | s.logger.ErrorContext(ctx, "failed to read the ambient temperature", slog.Any("input", readAmbientTempInput)) 280 | return models.ErrorInvalidParameterTemperature 281 | } 282 | } 283 | 284 | s.logger.DebugContext(ctx, "Ambient temperature", 285 | slog.Any("ambientTemp", ambientTemp), 286 | slog.String("device", input.Mac)) 287 | 288 | if readAmbientTempReturn == nil || readAmbientTempReturn.Temperature != ambientTemp { 289 | readDeviceConfigInput := &modelsRepo.ReadDeviceConfigInput{ 290 | Mac: input.Mac, 291 | } 292 | readDeviceConfigReturn, err := s.cache.ReadDeviceConfig(ctx, readDeviceConfigInput) 293 | if err != nil { 294 | s.logger.ErrorContext(ctx, "failed to read device config", 295 | slog.Any("err", err), 296 | slog.String("device", input.Mac), 297 | slog.Any("input", readDeviceConfigInput)) 298 | return err 299 | } 300 | 301 | // Sent temperature to MQTT 302 | publishAmbientTempInput := &modelsMqtt.PublishAmbientTempInput{ 303 | Mac: input.Mac, 304 | Temperature: converter.Temperature(models.Celsius, readDeviceConfigReturn.Config.TemperatureUnit, ambientTemp), 305 | } 306 | err = s.mqtt.PublishAmbientTemp(ctx, publishAmbientTempInput) 307 | if err != nil { 308 | s.logger.ErrorContext(ctx, "failed to publish ambient temperature", 309 | slog.Any("input", publishAmbientTempInput), 310 | slog.Any("err", err)) 311 | return err 312 | } 313 | 314 | // Save the new value in storage 315 | upsertAmbientTempInput := &modelsRepo.UpsertAmbientTempInput{Temperature: ambientTemp, Mac: input.Mac} 316 | err = s.cache.UpsertAmbientTemp(ctx, upsertAmbientTempInput) 317 | if err != nil { 318 | s.logger.ErrorContext(ctx, "failed to upsert the temperature", 319 | slog.Any("input", upsertAmbientTempInput), 320 | slog.Any("err", err)) 321 | return err 322 | } 323 | } 324 | 325 | return nil 326 | } 327 | 328 | // GetDeviceStates returns devices states 329 | func (s *service) GetDeviceStates(ctx context.Context, input *models.GetDeviceStatesInput) error { 330 | sendCommandInput := &models.SendCommandInput{ 331 | Command: 0x6a, 332 | Payload: []byte{12, 0, 187, 0, 6, 128, 0, 0, 2, 0, 17, 1, 43, 126, 0, 0}, 333 | Mac: input.Mac, 334 | } 335 | 336 | response, err := s.sendCommand(ctx, sendCommandInput) 337 | if err != nil { 338 | s.logger.ErrorContext(ctx, "failed to send the command to get states", 339 | slog.Any("input", sendCommandInput), 340 | slog.Any("err", err)) 341 | return err 342 | } 343 | 344 | //////////////////////////////////////////////////////////// 345 | // DECODE RESPONSE // 346 | //////////////////////////////////////////////////////////// 347 | 348 | if uint16(response.Payload[0x22])|(uint16(response.Payload[0x23])<<8) != 0 { 349 | s.logger.ErrorContext(ctx, "Checksum is incorrect", 350 | slog.Any("input", response.Payload)) 351 | return models.ErrorInvalidResultPacket 352 | } 353 | 354 | // Read the saved value in repo if no 355 | readDeviceAuthInput := &modelsRepo.ReadDeviceAuthInput{ 356 | Mac: input.Mac, 357 | } 358 | readDeviceAuthReturn, err := s.cache.ReadDeviceAuth(ctx, readDeviceAuthInput) 359 | if err != nil { 360 | s.logger.ErrorContext(ctx, "device not found", 361 | slog.Any("input", readDeviceAuthInput), 362 | slog.String("device", input.Mac), 363 | slog.Any("err", err)) 364 | return err 365 | } 366 | 367 | // Decode message 368 | if len(response.Payload) >= 0x38 { 369 | response.Payload = response.Payload[0x38:] 370 | } else { 371 | s.logger.ErrorContext(ctx, "response is too short", 372 | slog.String("device", input.Mac), 373 | slog.Any("input", response.Payload)) 374 | return models.ErrorInvalidResultPacketLength 375 | } 376 | 377 | response.Payload, err = coder.Decrypt(readDeviceAuthReturn.Auth.Key, readDeviceAuthReturn.Auth.Iv, response.Payload) 378 | if err != nil { 379 | s.logger.ErrorContext(ctx, "failed to decrypt the response", 380 | slog.Any("input", response.Payload), 381 | slog.String("device", input.Mac), 382 | slog.Any("err", err)) 383 | return err 384 | } 385 | 386 | if response.Payload[4] != 0x07 { 387 | s.logger.ErrorContext(ctx, "it is not a result packet", 388 | slog.String("device", input.Mac), 389 | slog.Any("input", response.Payload)) 390 | return models.ErrorInvalidResultPacket 391 | } 392 | 393 | if response.Payload[0] != 0x19 { 394 | s.logger.ErrorContext(ctx, "the length of the packet is incorrect. Must be 25", 395 | slog.String("device", input.Mac), 396 | slog.Any("input", response.Payload)) 397 | return models.ErrorInvalidResultPacketLength 398 | } 399 | 400 | //Drop leading stuff as don't need 401 | response.Payload = response.Payload[2:] 402 | 403 | var raw = models.DeviceStatusRaw{ 404 | UpdatedAt: time.Now(), 405 | Temperature: float32(8+(response.Payload[10]>>3)) + 0.5*float32(response.Payload[12]>>7), 406 | Power: response.Payload[18] >> 5 & 0b00000001, 407 | FixationVertical: response.Payload[10] & 0b00000111, 408 | Mode: response.Payload[15] >> 5 & 0b00001111, 409 | Sleep: response.Payload[15] >> 2 & 0b00000001, 410 | Display: response.Payload[20] >> 4 & 0b00000001, 411 | Mildew: response.Payload[20] >> 3 & 0b00000001, 412 | Health: response.Payload[18] >> 1 & 0b00000001, 413 | FixationHorizontal: response.Payload[10] & 0b00000111, 414 | FanSpeed: response.Payload[13] >> 5 & 0b00000111, 415 | IFeel: response.Payload[15] >> 3 & 0b00000001, 416 | Mute: response.Payload[14] >> 7 & 0b00000001, 417 | Turbo: response.Payload[14] >> 6 & 0b00000001, 418 | Clean: response.Payload[18] >> 2 & 0b00000001, 419 | } 420 | 421 | if raw.Temperature < 16.0 { 422 | s.logger.ErrorContext(ctx, "wrong temperature, skip package", 423 | slog.String("device", input.Mac), 424 | slog.Any("input", raw.Temperature)) 425 | return models.ErrorInvalidResultPacketLength 426 | } 427 | 428 | ////////////////////////////////////////////////////////////////// 429 | // Compare new statuses with old statuses and update MQTT // 430 | ////////////////////////////////////////////////////////////////// 431 | 432 | readDeviceStatusRawInput := &modelsRepo.ReadDeviceStatusRawInput{ 433 | Mac: input.Mac, 434 | } 435 | 436 | readDeviceStatusRawReturn, err := s.cache.ReadDeviceStatusRaw(ctx, readDeviceStatusRawInput) 437 | if err != nil { 438 | switch err { 439 | case modelsRepo.ErrorDeviceStatusRawNotFound: 440 | err = nil 441 | default: 442 | s.logger.ErrorContext(ctx, "failed to read the device status", 443 | slog.Any("err", err), 444 | slog.Any("input", readDeviceStatusRawInput)) 445 | return err 446 | } 447 | } 448 | 449 | deviceStatusHass := raw.ConvertToDeviceStatusHass() 450 | s.logger.DebugContext(ctx, "The converted current device status", 451 | slog.String("device", input.Mac)) 452 | 453 | g, gCtx := errgroup.WithContext(ctx) 454 | g.Go(func() error { 455 | if readDeviceStatusRawReturn == nil || 456 | readDeviceStatusRawReturn.Status.Temperature != raw.Temperature { 457 | 458 | readDeviceConfigInput := &modelsRepo.ReadDeviceConfigInput{ 459 | Mac: input.Mac, 460 | } 461 | 462 | readDeviceConfigReturn, err := s.cache.ReadDeviceConfig(gCtx, readDeviceConfigInput) 463 | if err != nil { 464 | s.logger.ErrorContext(gCtx, "failed to read device config", 465 | slog.Any("err", err), 466 | slog.String("device", input.Mac), 467 | slog.Any("input", readDeviceConfigInput)) 468 | return err 469 | } 470 | 471 | publishTemperatureInput := &modelsMqtt.PublishTemperatureInput{ 472 | Mac: input.Mac, 473 | Temperature: converter.Temperature(models.Celsius, readDeviceConfigReturn.Config.TemperatureUnit, deviceStatusHass.Temperature), 474 | } 475 | 476 | err = s.mqtt.PublishTemperature(gCtx, publishTemperatureInput) 477 | if err != nil { 478 | s.logger.ErrorContext(gCtx, "failed to publish the device set temperature", 479 | slog.Any("err", err), 480 | slog.Any("input", publishTemperatureInput)) 481 | return err 482 | } 483 | } 484 | return nil 485 | }) 486 | 487 | g.Go(func() error { 488 | if readDeviceStatusRawReturn == nil || 489 | readDeviceStatusRawReturn.Status.Mode != raw.Mode || 490 | readDeviceStatusRawReturn.Status.Power != raw.Power { 491 | 492 | publishModeInput := &modelsMqtt.PublishModeInput{ 493 | Mac: input.Mac, 494 | Mode: deviceStatusHass.Mode, 495 | } 496 | err = s.mqtt.PublishMode(gCtx, publishModeInput) 497 | if err != nil { 498 | s.logger.ErrorContext(ctx, "failed to publish the device mode", 499 | slog.Any("err", err), 500 | slog.Any("input", publishModeInput)) 501 | return err 502 | } 503 | } 504 | return nil 505 | }) 506 | 507 | g.Go(func() error { 508 | if readDeviceStatusRawReturn == nil || 509 | readDeviceStatusRawReturn.Status.FanSpeed != raw.FanSpeed || 510 | readDeviceStatusRawReturn.Status.Mute != raw.Mute || 511 | readDeviceStatusRawReturn.Status.Turbo != raw.Turbo { 512 | 513 | publishFanModeInput := &modelsMqtt.PublishFanModeInput{ 514 | Mac: input.Mac, 515 | FanMode: deviceStatusHass.FanMode, 516 | } 517 | 518 | err = s.mqtt.PublishFanMode(gCtx, publishFanModeInput) 519 | if err != nil { 520 | s.logger.ErrorContext(gCtx, "failed to publish the device fan mode", 521 | slog.Any("err", err), 522 | slog.Any("input", publishFanModeInput)) 523 | return err 524 | } 525 | } 526 | return nil 527 | }) 528 | 529 | g.Go(func() error { 530 | if readDeviceStatusRawReturn == nil || 531 | readDeviceStatusRawReturn.Status.FixationVertical != raw.FixationVertical { 532 | publishSwingModeInput := &modelsMqtt.PublishSwingModeInput{ 533 | Mac: input.Mac, 534 | SwingMode: deviceStatusHass.SwingMode, 535 | } 536 | 537 | err = s.mqtt.PublishSwingMode(gCtx, publishSwingModeInput) 538 | if err != nil { 539 | s.logger.ErrorContext(gCtx, "failed to publish the device swing mode", 540 | slog.Any("err", err), 541 | slog.Any("input", publishSwingModeInput)) 542 | return err 543 | } 544 | } 545 | return nil 546 | }) 547 | 548 | g.Go(func() error { 549 | if readDeviceStatusRawReturn == nil || 550 | readDeviceStatusRawReturn.Status.Display != raw.Display { 551 | publishDisplaySwitchInput := &modelsMqtt.PublishDisplaySwitchInput{ 552 | Mac: input.Mac, 553 | Status: deviceStatusHass.DisplaySwitch, 554 | } 555 | 556 | err = s.mqtt.PublishDisplaySwitch(gCtx, publishDisplaySwitchInput) 557 | if err != nil { 558 | s.logger.ErrorContext(gCtx, "failed to publish the display switch status", 559 | slog.Any("err", err), 560 | slog.Any("input", publishDisplaySwitchInput)) 561 | return err 562 | } 563 | } 564 | return nil 565 | }) 566 | 567 | // Wait for all HTTP fetches to complete. 568 | if err = g.Wait(); err != nil { 569 | return err 570 | } 571 | 572 | ////////////////////////////////////////////////////////////////// 573 | // Update device states in the database // 574 | ////////////////////////////////////////////////////////////////// 575 | 576 | upsertDeviceStatusRawInput := &modelsRepo.UpsertDeviceStatusRawInput{ 577 | Mac: input.Mac, 578 | Status: modelsRepo.DeviceStatusRaw(raw), 579 | } 580 | 581 | err = s.cache.UpsertDeviceStatusRaw(ctx, upsertDeviceStatusRawInput) 582 | if err != nil { 583 | s.logger.ErrorContext(ctx, "failed to upsert the raw device status", 584 | slog.Any("err", err), 585 | slog.Any("input", upsertDeviceStatusRawInput)) 586 | return err 587 | } 588 | return nil 589 | } 590 | 591 | func (s *service) sendCommand(ctx context.Context, input *models.SendCommandInput) (*models.SendCommandReturn, error) { 592 | // Read the saved value in repo if no 593 | readDeviceAuthInput := &modelsRepo.ReadDeviceAuthInput{ 594 | Mac: input.Mac, 595 | } 596 | readDeviceAuthReturn, err := s.cache.ReadDeviceAuth(ctx, readDeviceAuthInput) 597 | if err != nil { 598 | s.logger.ErrorContext(ctx, "device not found", 599 | slog.Any("err", err), 600 | slog.Any("input", readDeviceAuthInput)) 601 | return nil, err 602 | } 603 | 604 | auth := readDeviceAuthReturn.Auth 605 | 606 | auth.LastMessageId = (auth.LastMessageId + 1) & 0xffff 607 | 608 | macByteSlice := make([]byte, 0, len(input.Mac)/2) 609 | for i := 0; i < len(input.Mac); i = i + 2 { 610 | val, err := strconv.ParseUint(input.Mac[i:i+2], 16, 8) 611 | if err != nil { 612 | s.logger.ErrorContext(ctx, "mac address is not correct", 613 | slog.Any("err", err), 614 | slog.Any("input", input.Mac)) 615 | return nil, err 616 | } 617 | macByteSlice = append(macByteSlice, byte(val)) 618 | } 619 | 620 | var packet [0x38]byte 621 | 622 | // Insert body 623 | packet[0x00] = 0x5a 624 | packet[0x01] = 0xa5 625 | packet[0x02] = 0xaa 626 | packet[0x03] = 0x55 627 | packet[0x04] = 0x5a 628 | packet[0x05] = 0xa5 629 | packet[0x06] = 0xaa 630 | packet[0x07] = 0x55 631 | packet[0x24] = 0x2a 632 | packet[0x25] = 0x4e 633 | packet[0x26] = input.Command // command 634 | packet[0x28] = byte(auth.LastMessageId & 0xff) 635 | packet[0x29] = byte(auth.LastMessageId >> 8) 636 | packet[0x2a] = macByteSlice[0] 637 | packet[0x2b] = macByteSlice[1] 638 | packet[0x2c] = macByteSlice[2] 639 | packet[0x2d] = macByteSlice[3] 640 | packet[0x2e] = macByteSlice[4] 641 | packet[0x2f] = macByteSlice[5] 642 | packet[0x30] = auth.Id[0] 643 | packet[0x31] = auth.Id[1] 644 | packet[0x32] = auth.Id[2] 645 | packet[0x33] = auth.Id[3] 646 | 647 | checksum := 0xbeaf 648 | for i := range input.Payload { 649 | checksum += int(input.Payload[i]) 650 | checksum = checksum & 0xffff 651 | } 652 | 653 | input.Payload, err = coder.Encrypt(auth.Key, auth.Iv, input.Payload) 654 | if err != nil { 655 | s.logger.ErrorContext(ctx, "failed to encrypt payload", 656 | slog.Any("err", err), 657 | slog.String("device", input.Mac), 658 | slog.Any("input", input.Payload)) 659 | return nil, err 660 | } 661 | 662 | packet[0x34] = byte(checksum & 0xff) 663 | packet[0x35] = byte(checksum >> 8) 664 | 665 | var packetSlice = packet[:] 666 | 667 | packetSlice = append(packetSlice, input.Payload...) 668 | 669 | // Create and insert Checksum 670 | checksum = 0xbeaf 671 | for i := range packetSlice { 672 | checksum += int(packetSlice[i]) 673 | checksum = checksum & 0xffff 674 | } 675 | packetSlice[0x20] = byte(checksum & 0xff) 676 | packetSlice[0x21] = byte(checksum >> 8) 677 | 678 | // Update last message id in database 679 | upsertDeviceAuthInput := &modelsRepo.UpsertDeviceAuthInput{ 680 | Mac: input.Mac, 681 | Auth: auth, 682 | } 683 | err = s.cache.UpsertDeviceAuth(ctx, upsertDeviceAuthInput) 684 | if err != nil { 685 | return nil, err 686 | } 687 | 688 | s.logger.DebugContext(ctx, "packet", 689 | slog.Any("err", err), 690 | slog.String("device", input.Mac), 691 | slog.Any("input", packetSlice)) 692 | 693 | // Read config to get IP and Port 694 | readDeviceConfigInput := &modelsRepo.ReadDeviceConfigInput{ 695 | Mac: input.Mac, 696 | } 697 | 698 | readDeviceConfigReturn, err := s.cache.ReadDeviceConfig(ctx, readDeviceConfigInput) 699 | if err != nil { 700 | s.logger.ErrorContext(ctx, "failed to read device config", 701 | slog.Any("err", err), 702 | slog.String("device", input.Mac), 703 | slog.Any("input", readDeviceConfigInput)) 704 | return nil, err 705 | } 706 | 707 | // Send the packet 708 | sendCommandInput := &modelsWeb.SendCommandInput{ 709 | Payload: packetSlice, 710 | Ip: readDeviceConfigReturn.Config.Ip, 711 | Port: readDeviceConfigReturn.Config.Port, 712 | } 713 | 714 | sendCommandReturn, err := s.webClient.SendCommand(ctx, sendCommandInput) 715 | if err != nil { 716 | s.logger.ErrorContext(ctx, "failed to send a command", 717 | slog.Any("err", err), 718 | slog.String("device", input.Mac), 719 | slog.Any("input", sendCommandInput)) 720 | return nil, err 721 | } 722 | 723 | return &models.SendCommandReturn{Payload: sendCommandReturn.Payload}, nil 724 | } 725 | 726 | func (s *service) PublishDiscoveryTopic(ctx context.Context, input *models.PublishDiscoveryTopicInput) error { 727 | prefix := s.topicPrefix + "/" + input.Device.Mac 728 | 729 | device := modelsMqtt.DiscoveryTopicDevice{ 730 | Model: "AirCon", 731 | Mf: "broadlink", 732 | Sw: "v1.5.3", 733 | Ids: input.Device.Mac, 734 | Name: input.Device.Name, 735 | } 736 | 737 | availability := modelsMqtt.DiscoveryTopicAvailability{ 738 | PayloadAvailable: models.StatusOnline, 739 | PayloadNotAvailable: models.StatusOffline, 740 | Topic: prefix + "/availability/value", 741 | } 742 | 743 | swingModes := make([]string, 0, len(models.VerticalFixationStatusesInvert)) 744 | for swingMode := range models.VerticalFixationStatusesInvert { 745 | swingModes = append(swingModes, swingMode) 746 | } 747 | 748 | publishClimateDiscoveryTopicInput := modelsMqtt.PublishClimateDiscoveryTopicInput{ 749 | Topic: modelsMqtt.ClimateDiscoveryTopic{ 750 | FanModeCommandTopic: prefix + "/fan_mode/set", 751 | FanModes: []string{"auto", "low", "medium", "high", "turbo", "mute"}, 752 | FanModeStateTopic: prefix + "/fan_mode/value", 753 | ModeCommandTopic: prefix + "/mode/set", 754 | ModeStateTopic: prefix + "/mode/value", 755 | Modes: []string{"auto", "off", "cool", "heat", "dry", "fan_only"}, 756 | SwingModeCommandTopic: prefix + "/swing_mode/set", 757 | SwingModeStateTopic: prefix + "/swing_mode/value", 758 | SwingModes: swingModes, 759 | MinTemp: 16, 760 | MaxTemp: 32, 761 | TempStep: 0.5, 762 | TemperatureStateTopic: prefix + "/temp/value", 763 | TemperatureCommandTopic: prefix + "/temp/set", 764 | Precision: 0.1, 765 | Device: device, 766 | UniqueId: input.Device.Mac + "_ac", 767 | Availability: availability, 768 | CurrentTemperatureTopic: prefix + "/current_temp/value", 769 | Name: nil, 770 | Icon: "mdi:air-conditioner", 771 | TemperatureUnit: input.Device.TemperatureUnit, 772 | }, 773 | } 774 | err := s.mqtt.PublishClimateDiscoveryTopic(ctx, publishClimateDiscoveryTopicInput) 775 | if err != nil { 776 | return err 777 | } 778 | 779 | publishSwitchScreenDiscoveryTopicInput := modelsMqtt.PublishSwitchDiscoveryTopicInput{ 780 | Topic: modelsMqtt.SwitchDiscoveryTopic{ 781 | Device: device, 782 | Name: "Screen", 783 | UniqueId: input.Device.Mac + "_screen", 784 | StateTopic: prefix + "/display/switch/value", 785 | CommandTopic: prefix + "/display/switch/set", 786 | Availability: availability, 787 | Icon: "mdi:tablet-dashboard", 788 | }, 789 | } 790 | 791 | return s.mqtt.PublishSwitchDiscoveryTopic(ctx, publishSwitchScreenDiscoveryTopicInput) 792 | } 793 | 794 | func (s *service) UpdateFanMode(ctx context.Context, input *models.UpdateFanModeInput) error { 795 | err := input.Validate() 796 | if err != nil { 797 | s.logger.ErrorContext(ctx, "input data is not valid", 798 | slog.Any("err", err), 799 | slog.String("device", input.Mac), 800 | slog.Any("input", input)) 801 | return err 802 | } 803 | 804 | upsertMqttFanModeMessageInput := &modelsRepo.UpsertMqttFanModeMessageInput{ 805 | Mac: input.Mac, 806 | FanMode: modelsRepo.MqttFanModeMessage{ 807 | UpdatedAt: time.Now(), 808 | FanMode: input.FanMode, 809 | }, 810 | } 811 | 812 | err = s.cache.UpsertMqttFanModeMessage(ctx, upsertMqttFanModeMessageInput) 813 | if err != nil { 814 | s.logger.ErrorContext(ctx, "failed to save mqtt message to cache storage", 815 | slog.Any("err", err), 816 | slog.String("device", input.Mac), 817 | slog.Any("input", upsertMqttFanModeMessageInput)) 818 | return err 819 | } 820 | 821 | publishFanModeInput := &modelsMqtt.PublishFanModeInput{ 822 | Mac: input.Mac, 823 | FanMode: input.FanMode, 824 | } 825 | err = s.mqtt.PublishFanMode(ctx, publishFanModeInput) 826 | if err != nil { 827 | s.logger.ErrorContext(ctx, "failed to publish fan mode to mqtt", 828 | slog.Any("err", err), 829 | slog.String("device", input.Mac), 830 | slog.Any("input", publishFanModeInput)) 831 | return err 832 | } 833 | 834 | return nil 835 | } 836 | 837 | func (s *service) UpdateMode(ctx context.Context, input *models.UpdateModeInput) error { 838 | err := input.Validate() 839 | if err != nil { 840 | s.logger.ErrorContext(ctx, "input data is not valid", 841 | slog.Any("err", err), 842 | slog.String("device", input.Mac), 843 | slog.Any("input", input)) 844 | return err 845 | } 846 | 847 | upsertMqttModeMessageInput := &modelsRepo.UpsertMqttModeMessageInput{ 848 | Mac: input.Mac, 849 | Mode: modelsRepo.MqttModeMessage{ 850 | UpdatedAt: time.Now(), 851 | Mode: input.Mode, 852 | }, 853 | } 854 | 855 | err = s.cache.UpsertMqttModeMessage(ctx, upsertMqttModeMessageInput) 856 | if err != nil { 857 | s.logger.ErrorContext(ctx, "failed to save mqtt message to cache storage", 858 | slog.Any("err", err), 859 | slog.String("device", input.Mac), 860 | slog.Any("input", upsertMqttModeMessageInput)) 861 | return err 862 | } 863 | 864 | publishModeInput := &modelsMqtt.PublishModeInput{ 865 | Mac: input.Mac, 866 | Mode: input.Mode, 867 | } 868 | err = s.mqtt.PublishMode(ctx, publishModeInput) 869 | if err != nil { 870 | s.logger.ErrorContext(ctx, "failed to publish mode to mqtt", 871 | slog.Any("err", err), 872 | slog.String("device", input.Mac), 873 | slog.Any("input", publishModeInput)) 874 | return err 875 | } 876 | 877 | return nil 878 | } 879 | 880 | func (s *service) UpdateSwingMode(ctx context.Context, input *models.UpdateSwingModeInput) error { 881 | err := input.Validate() 882 | if err != nil { 883 | s.logger.ErrorContext(ctx, "input data is not valid", 884 | slog.Any("err", err), 885 | slog.String("device", input.Mac), 886 | slog.Any("input", input)) 887 | return err 888 | } 889 | 890 | upsertMqttSwingModeMessageInput := &modelsRepo.UpsertMqttSwingModeMessageInput{ 891 | Mac: input.Mac, 892 | SwingMode: modelsRepo.MqttSwingModeMessage{ 893 | UpdatedAt: time.Now(), 894 | SwingMode: input.SwingMode, 895 | }, 896 | } 897 | 898 | err = s.cache.UpsertMqttSwingModeMessage(ctx, upsertMqttSwingModeMessageInput) 899 | if err != nil { 900 | s.logger.ErrorContext(ctx, "failed to save mqtt message to cache storage", 901 | slog.Any("err", err), 902 | slog.String("device", input.Mac), 903 | slog.Any("input", upsertMqttSwingModeMessageInput)) 904 | return err 905 | } 906 | 907 | publishSwingModeInput := &modelsMqtt.PublishSwingModeInput{ 908 | Mac: input.Mac, 909 | SwingMode: input.SwingMode, 910 | } 911 | err = s.mqtt.PublishSwingMode(ctx, publishSwingModeInput) 912 | if err != nil { 913 | s.logger.ErrorContext(ctx, "failed to publish swing mode to mqtt", 914 | slog.Any("err", err), 915 | slog.String("device", input.Mac), 916 | slog.Any("input", publishSwingModeInput)) 917 | return err 918 | } 919 | 920 | return nil 921 | } 922 | 923 | func (s *service) UpdateTemperature(ctx context.Context, input *models.UpdateTemperatureInput) error { 924 | readDeviceConfigInput := &modelsRepo.ReadDeviceConfigInput{ 925 | Mac: input.Mac, 926 | } 927 | readDeviceConfigReturn, err := s.cache.ReadDeviceConfig(ctx, readDeviceConfigInput) 928 | if err != nil { 929 | s.logger.ErrorContext(ctx, "failed to read device config", 930 | slog.Any("err", err), 931 | slog.String("device", input.Mac), 932 | slog.Any("input", readDeviceConfigInput)) 933 | return err 934 | } 935 | 936 | input.Temperature = converter.Temperature(readDeviceConfigReturn.Config.TemperatureUnit, models.Celsius, input.Temperature) 937 | err = input.Validate() 938 | if err != nil { 939 | s.logger.ErrorContext(ctx, "input data is not valid", 940 | slog.Any("err", err), 941 | slog.String("device", input.Mac), 942 | slog.Any("input", input)) 943 | return err 944 | } 945 | 946 | upsertMqttTemperatureMessageInput := &modelsRepo.UpsertMqttTemperatureMessageInput{ 947 | Mac: input.Mac, 948 | Temperature: modelsRepo.MqttTemperatureMessage{ 949 | UpdatedAt: time.Now(), 950 | Temperature: input.Temperature, 951 | }, 952 | } 953 | 954 | err = s.cache.UpsertMqttTemperatureMessage(ctx, upsertMqttTemperatureMessageInput) 955 | if err != nil { 956 | s.logger.ErrorContext(ctx, "failed to save mqtt message to cache storage", 957 | slog.Any("err", err), 958 | slog.String("device", input.Mac), 959 | slog.Any("input", upsertMqttTemperatureMessageInput)) 960 | return err 961 | } 962 | 963 | return nil 964 | } 965 | 966 | func (s *service) UpdateDisplaySwitch(ctx context.Context, input *models.UpdateDisplaySwitchInput) error { 967 | err := input.Validate() 968 | if err != nil { 969 | s.logger.ErrorContext(ctx, "input data is not valid", 970 | slog.Any("err", err), 971 | slog.String("device", input.Mac), 972 | slog.Any("input", input)) 973 | return err 974 | } 975 | 976 | upsertDisplaySwitchMessageInput := &modelsRepo.UpsertMqttDisplaySwitchMessageInput{ 977 | Mac: input.Mac, 978 | DisplaySwitch: modelsRepo.MqttDisplaySwitchMessage{ 979 | UpdatedAt: time.Now(), 980 | IsDisplayOn: input.Status == "ON", 981 | }, 982 | } 983 | 984 | err = s.cache.UpsertMqttDisplaySwitchMessage(ctx, upsertDisplaySwitchMessageInput) 985 | if err != nil { 986 | s.logger.ErrorContext(ctx, "failed to save mqtt message to cache storage", 987 | slog.Any("err", err), 988 | slog.String("device", input.Mac), 989 | slog.Any("input", upsertDisplaySwitchMessageInput)) 990 | return err 991 | } 992 | 993 | return nil 994 | } 995 | 996 | func (s *service) UpdateDeviceStates(ctx context.Context, input *models.UpdateDeviceStatesInput) error { 997 | readDeviceStatusRawInput := &modelsRepo.ReadDeviceStatusRawInput{ 998 | Mac: input.Mac, 999 | } 1000 | 1001 | readDeviceStatusRawReturn, err := s.cache.ReadDeviceStatusRaw(ctx, readDeviceStatusRawInput) 1002 | if err != nil { 1003 | s.logger.ErrorContext(ctx, "failed to read device raw status", 1004 | slog.Any("err", err), 1005 | slog.String("device", input.Mac), 1006 | slog.Any("input", readDeviceStatusRawInput)) 1007 | return err 1008 | } 1009 | 1010 | // Convert Home Assistant to BroadLink types 1011 | // SWING MODE 1012 | var verticalFixation byte 1013 | if input.SwingMode != nil { 1014 | key, ok := models.VerticalFixationStatusesInvert[*input.SwingMode] 1015 | if !ok { 1016 | s.logger.ErrorContext(ctx, "Invalid parameter Swing mode", 1017 | slog.String("device", input.Mac), 1018 | slog.Any("input", *input.SwingMode)) 1019 | 1020 | return models.ErrorInvalidParameterSwingMode 1021 | } else { 1022 | verticalFixation = byte(key) 1023 | } 1024 | } else { 1025 | verticalFixation = readDeviceStatusRawReturn.Status.FixationVertical 1026 | } 1027 | 1028 | // TEMPERATURE 1029 | var temperature, temperature05 int 1030 | if input.Temperature != nil { 1031 | if *input.Temperature > 32 || *input.Temperature < 16 { 1032 | s.logger.ErrorContext(ctx, "Invalid parameter temperature", 1033 | slog.String("device", input.Mac), 1034 | slog.Any("input", *input.Temperature)) 1035 | 1036 | return models.ErrorInvalidParameterTemperature 1037 | } 1038 | 1039 | temperature = int(*input.Temperature) - 8 1040 | 1041 | if int(*input.Temperature*10)%(int(*input.Temperature)*10) == 5 { 1042 | temperature05 = 1 1043 | } 1044 | } else { 1045 | if readDeviceStatusRawReturn.Status.Temperature < 16 { 1046 | temperature = 16 - 8 1047 | } else if readDeviceStatusRawReturn.Status.Temperature > 32 { 1048 | temperature = 32 - 8 1049 | } else { 1050 | temperature = int(readDeviceStatusRawReturn.Status.Temperature) - 8 1051 | if readDeviceStatusRawReturn.Status.Temperature-float32(int(readDeviceStatusRawReturn.Status.Temperature)) != 0 { 1052 | temperature05 = 1 1053 | } 1054 | } 1055 | } 1056 | 1057 | // FAN MODE 1058 | var fanMode, turbo, mute byte 1059 | if input.FanMode != nil { 1060 | if *input.FanMode == "mute" { 1061 | mute = models.StatusOn 1062 | } else if *input.FanMode == "turbo" { 1063 | turbo = models.StatusOn 1064 | } else { 1065 | key, ok := models.FanStatusesInvert[*input.FanMode] 1066 | if !ok { 1067 | s.logger.ErrorContext(ctx, "Invalid parameter fan mode", 1068 | slog.String("device", input.Mac), 1069 | slog.Any("input", *input.FanMode)) 1070 | 1071 | return models.ErrorInvalidParameterFanMode 1072 | } else { 1073 | fanMode = byte(key) 1074 | } 1075 | } 1076 | } else { 1077 | fanMode = readDeviceStatusRawReturn.Status.FanSpeed 1078 | mute = readDeviceStatusRawReturn.Status.Mute 1079 | turbo = readDeviceStatusRawReturn.Status.Turbo 1080 | } 1081 | 1082 | // DISPLAY 1083 | // Attention. Inverted logic 1084 | // Byte 0 - turn ON, Byte 1 - turn OFF 1085 | var displaySwitch byte = 1 1086 | if input.IsDisplayOn != nil { 1087 | if *input.IsDisplayOn { 1088 | displaySwitch = 0 1089 | } 1090 | } else { 1091 | displaySwitch = readDeviceStatusRawReturn.Status.Display 1092 | } 1093 | 1094 | // MODE 1095 | var mode, power byte 1096 | if input.Mode != nil { 1097 | if *input.Mode == "off" { 1098 | mode = readDeviceStatusRawReturn.Status.Mode 1099 | power = models.StatusOff 1100 | } else { 1101 | key, ok := models.ModeStatusesInvert[*input.Mode] 1102 | if !ok { 1103 | s.logger.ErrorContext(ctx, "Invalid parameter mode", 1104 | slog.String("device", input.Mac), 1105 | slog.Any("input", *input.Mode)) 1106 | 1107 | return models.ErrorInvalidParameterMode 1108 | } 1109 | 1110 | mode = byte(key) 1111 | power = models.StatusOn 1112 | } 1113 | } else { 1114 | power = readDeviceStatusRawReturn.Status.Power 1115 | mode = readDeviceStatusRawReturn.Status.Mode 1116 | } 1117 | 1118 | // Insert values in payload 1119 | var payload [23]byte 1120 | payload[0] = 0xbb 1121 | payload[1] = 0x00 1122 | payload[2] = 0x06 1123 | payload[3] = 0x80 1124 | payload[4] = 0x00 1125 | payload[5] = 0x00 1126 | payload[6] = 0x0f 1127 | payload[7] = 0x00 1128 | payload[8] = 0x01 1129 | payload[9] = 0x01 1130 | payload[10] = 0b00000000 | byte(temperature)<<3 | verticalFixation 1131 | payload[11] = 0b00000000 | readDeviceStatusRawReturn.Status.FixationHorizontal<<5 1132 | payload[12] = 0b00001111 | byte(temperature05)<<7 1133 | payload[13] = 0b00000000 | fanMode<<5 1134 | payload[14] = 0b00000000 | turbo<<6 | mute<<7 1135 | payload[15] = 0b00000000 | mode<<5 | readDeviceStatusRawReturn.Status.Sleep<<2 1136 | payload[16] = 0b00000000 1137 | payload[17] = 0x00 1138 | payload[18] = 0b00000000 | power<<5 | readDeviceStatusRawReturn.Status.Health<<1 | readDeviceStatusRawReturn.Status.Clean<<2 1139 | payload[19] = 0x00 1140 | payload[20] = 0b00000000 | displaySwitch<<4 | readDeviceStatusRawReturn.Status.Mildew<<3 1141 | payload[21] = 0b00000000 1142 | payload[22] = 0b00000000 1143 | 1144 | // Add checksum 1145 | var payloadChecksum [32]byte 1146 | payloadChecksum[0] = byte(len(payload) + 2) 1147 | 1148 | copy(payloadChecksum[2:], payload[:]) 1149 | 1150 | var checksum int 1151 | for i := 0; i < len(payload); i += 2 { 1152 | checksum += int(payload[i])<<8 + int(append(payload[:], byte(0))[i+1]) 1153 | } 1154 | checksum = (checksum >> 16) + (checksum & 0xFFFF) 1155 | checksum = ^checksum & 0xFFFF 1156 | 1157 | payloadChecksum[len(payload)+2] = byte((checksum >> 8) & 0xFF) 1158 | payloadChecksum[len(payload)+3] = byte(checksum & 0xFF) 1159 | 1160 | sendCommandInput := &models.SendCommandInput{ 1161 | Command: 0x6a, 1162 | Payload: payloadChecksum[:], 1163 | Mac: input.Mac, 1164 | } 1165 | _, err = s.sendCommand(ctx, sendCommandInput) 1166 | if err != nil { 1167 | s.logger.ErrorContext(ctx, "failed to send a set command", 1168 | slog.Any("err", err), 1169 | slog.String("device", input.Mac), 1170 | slog.Any("input", sendCommandInput)) 1171 | return err 1172 | } 1173 | 1174 | return nil 1175 | } 1176 | 1177 | func (s *service) UpdateDeviceAvailability(ctx context.Context, input *models.UpdateDeviceAvailabilityInput) error { 1178 | upsertDeviceAvailabilityInput := &modelsRepo.UpsertDeviceAvailabilityInput{ 1179 | Mac: input.Mac, 1180 | Availability: input.Availability, 1181 | } 1182 | err := s.cache.UpsertDeviceAvailability(ctx, upsertDeviceAvailabilityInput) 1183 | if err != nil { 1184 | s.logger.ErrorContext(ctx, "failed to upsert device availability", 1185 | slog.Any("err", err), 1186 | slog.String("device", input.Mac), 1187 | slog.Any("input", upsertDeviceAvailabilityInput)) 1188 | return err 1189 | } 1190 | 1191 | publishAvailabilityInput := &modelsMqtt.PublishAvailabilityInput{ 1192 | Mac: input.Mac, 1193 | Availability: input.Availability, 1194 | } 1195 | err = s.mqtt.PublishAvailability(ctx, publishAvailabilityInput) 1196 | if err != nil { 1197 | s.logger.ErrorContext(ctx, "failed to create command payload", 1198 | slog.Any("err", err), 1199 | slog.String("device", input.Mac), 1200 | slog.Any("input", publishAvailabilityInput)) 1201 | return err 1202 | } 1203 | 1204 | return nil 1205 | } 1206 | 1207 | func (s *service) StartDeviceMonitoring(ctx context.Context, input *models.StartDeviceMonitoringInput) error { 1208 | var ( 1209 | modeUpdatedTime, swingModeUpdatedTime, fanModeUpdatedTime, temperatureUpdatedTime time.Time 1210 | isDisplayOnUpdatedTime time.Time 1211 | lastGetDeviceState, lastGetAmbientTemp time.Time 1212 | 1213 | isDeviceAvailable bool 1214 | ) 1215 | 1216 | for { 1217 | select { 1218 | case <-ctx.Done(): 1219 | return nil 1220 | default: 1221 | if time.Now().Sub(lastGetAmbientTemp).Seconds() > 180 { 1222 | err := s.GetDeviceAmbientTemperature(ctx, &models.GetDeviceAmbientTemperatureInput{Mac: input.Mac}) 1223 | if err != nil { 1224 | s.logger.ErrorContext(ctx, "failed to get ambient temperature", 1225 | slog.Any("err", err), 1226 | slog.String("device", input.Mac)) 1227 | 1228 | err = nil 1229 | continue 1230 | } 1231 | lastGetAmbientTemp = time.Now() 1232 | } else { 1233 | var ( 1234 | forcedUpdateDeviceState = false 1235 | mode, swingMode, fanMode *string 1236 | temperature *float32 1237 | isDisplayOn *bool 1238 | ) 1239 | 1240 | readMqttMessageInput := &modelsRepo.ReadMqttMessageInput{ 1241 | Mac: input.Mac, 1242 | } 1243 | message, err := s.cache.ReadMqttMessage(ctx, readMqttMessageInput) 1244 | if err != nil { 1245 | return err 1246 | } 1247 | 1248 | if message.Mode != nil { 1249 | if message.Mode.UpdatedAt != modeUpdatedTime { 1250 | forcedUpdateDeviceState = true 1251 | mode = &message.Mode.Mode 1252 | } 1253 | } 1254 | 1255 | if message.FanMode != nil { 1256 | if message.FanMode.UpdatedAt != fanModeUpdatedTime { 1257 | forcedUpdateDeviceState = true 1258 | fanMode = &message.FanMode.FanMode 1259 | } 1260 | } 1261 | 1262 | if message.SwingMode != nil { 1263 | if message.SwingMode.UpdatedAt != swingModeUpdatedTime { 1264 | forcedUpdateDeviceState = true 1265 | swingMode = &message.SwingMode.SwingMode 1266 | } 1267 | } 1268 | 1269 | if message.Temperature != nil { 1270 | if message.Temperature.UpdatedAt != temperatureUpdatedTime { 1271 | forcedUpdateDeviceState = true 1272 | temperature = &message.Temperature.Temperature 1273 | } 1274 | } 1275 | 1276 | if message.IsDisplayOn != nil { 1277 | if message.IsDisplayOn.UpdatedAt != isDisplayOnUpdatedTime { 1278 | forcedUpdateDeviceState = true 1279 | isDisplayOn = &message.IsDisplayOn.IsDisplayOn 1280 | } 1281 | } 1282 | 1283 | if forcedUpdateDeviceState || int(time.Now().Sub(lastGetDeviceState).Seconds()) > s.updateInterval { 1284 | for { 1285 | err = s.GetDeviceStates(ctx, &models.GetDeviceStatesInput{Mac: input.Mac}) 1286 | if err != nil { 1287 | s.logger.ErrorContext(ctx, "failed to get AC States", 1288 | slog.Any("err", err), 1289 | slog.String("device", input.Mac)) 1290 | 1291 | // If we cannot receive data from the air conditioner within three intervals, 1292 | // then we send the status that the air conditioner is unavailable 1293 | if time.Now().Sub(lastGetDeviceState).Seconds() > float64(s.updateInterval)*3 && isDeviceAvailable { 1294 | updateDeviceAvailabilityInput := &models.UpdateDeviceAvailabilityInput{ 1295 | Mac: input.Mac, 1296 | Availability: models.StatusOffline, 1297 | } 1298 | err = s.UpdateDeviceAvailability(ctx, updateDeviceAvailabilityInput) 1299 | if err != nil { 1300 | s.logger.ErrorContext(ctx, "failed to update device availability", 1301 | slog.Any("err", err), 1302 | slog.String("device", input.Mac), 1303 | slog.Any("input", updateDeviceAvailabilityInput)) 1304 | err = nil 1305 | } 1306 | isDeviceAvailable = false 1307 | } 1308 | err = nil 1309 | continue 1310 | } else { 1311 | lastGetDeviceState = time.Now() 1312 | if !isDeviceAvailable { 1313 | updateDeviceAvailabilityInput := &models.UpdateDeviceAvailabilityInput{ 1314 | Mac: input.Mac, 1315 | Availability: models.StatusOnline, 1316 | } 1317 | err = s.UpdateDeviceAvailability(ctx, updateDeviceAvailabilityInput) 1318 | if err != nil { 1319 | s.logger.ErrorContext(ctx, "failed to update device availability", 1320 | slog.Any("err", err), 1321 | slog.String("device", input.Mac), 1322 | slog.Any("input", updateDeviceAvailabilityInput)) 1323 | 1324 | err = nil 1325 | } 1326 | isDeviceAvailable = true 1327 | } 1328 | break 1329 | } 1330 | } 1331 | } 1332 | 1333 | if forcedUpdateDeviceState && isDeviceAvailable { 1334 | // A short pause before sending a new message to the air conditioner so that it does not hang 1335 | time.Sleep(time.Millisecond * 500) 1336 | 1337 | updateDeviceStatesInput := &models.UpdateDeviceStatesInput{ 1338 | Mac: input.Mac, 1339 | FanMode: fanMode, 1340 | SwingMode: swingMode, 1341 | Mode: mode, 1342 | Temperature: temperature, 1343 | IsDisplayOn: isDisplayOn, 1344 | } 1345 | err := s.UpdateDeviceStates(ctx, updateDeviceStatesInput) 1346 | if err != nil { 1347 | s.logger.ErrorContext(ctx, "failed to update device states", 1348 | slog.Any("err", err), 1349 | slog.String("device", input.Mac), 1350 | slog.Any("input", updateDeviceStatesInput)) 1351 | err = nil 1352 | continue 1353 | } 1354 | 1355 | // Reset the time of the last update to get fresh data from the air conditioner 1356 | lastGetDeviceState = time.UnixMicro(0) 1357 | 1358 | if message.Mode != nil { 1359 | modeUpdatedTime = message.Mode.UpdatedAt 1360 | } 1361 | if message.FanMode != nil { 1362 | fanModeUpdatedTime = message.FanMode.UpdatedAt 1363 | } 1364 | if message.SwingMode != nil { 1365 | swingModeUpdatedTime = message.SwingMode.UpdatedAt 1366 | } 1367 | if message.Temperature != nil { 1368 | temperatureUpdatedTime = message.Temperature.UpdatedAt 1369 | } 1370 | if message.IsDisplayOn != nil { 1371 | isDisplayOnUpdatedTime = message.IsDisplayOn.UpdatedAt 1372 | } 1373 | } 1374 | 1375 | time.Sleep(time.Millisecond * 500) 1376 | } 1377 | } 1378 | } 1379 | } 1380 | 1381 | func (s *service) PublishStatesOnHomeAssistantRestart(ctx context.Context, input *models.PublishStatesOnHomeAssistantRestartInput) error { 1382 | if input.Status != models.StatusOnline { 1383 | return nil 1384 | } 1385 | 1386 | readAuthedDevicesReturn, err := s.cache.ReadAuthedDevices(ctx) 1387 | if err != nil { 1388 | s.logger.ErrorContext(ctx, "failed to read authed devices", 1389 | slog.Any("err", err)) 1390 | return err 1391 | } 1392 | 1393 | eg, gCtx := errgroup.WithContext(ctx) 1394 | for _, mac := range readAuthedDevicesReturn.Macs { 1395 | eg.Go(func() error { 1396 | ///////////////////////////////// 1397 | // Read all states and configs // 1398 | ///////////////////////////////// 1399 | 1400 | readDeviceStatusRawInput := &modelsRepo.ReadDeviceStatusRawInput{ 1401 | Mac: mac, 1402 | } 1403 | readDeviceStatusRawReturn, err := s.cache.ReadDeviceStatusRaw(gCtx, readDeviceStatusRawInput) 1404 | if err != nil { 1405 | s.logger.ErrorContext(gCtx, "failed to read the device status", 1406 | slog.Any("err", err), 1407 | slog.Any("input", readDeviceStatusRawInput)) 1408 | return err 1409 | } 1410 | 1411 | hassStatus := models.DeviceStatusRaw(readDeviceStatusRawReturn.Status).ConvertToDeviceStatusHass() 1412 | 1413 | readAmbientTempInput := &modelsRepo.ReadAmbientTempInput{Mac: mac} 1414 | 1415 | readAmbientTempReturn, err := s.cache.ReadAmbientTemp(gCtx, readAmbientTempInput) 1416 | if err != nil { 1417 | s.logger.ErrorContext(gCtx, "failed to read the ambient temperature", 1418 | slog.Any("err", err), 1419 | slog.Any("input", readAmbientTempInput)) 1420 | return err 1421 | } 1422 | 1423 | readDeviceAvailabilityInput := &modelsRepo.ReadDeviceAvailabilityInput{Mac: mac} 1424 | 1425 | readDeviceAvailabilityReturn, err := s.cache.ReadDeviceAvailability(gCtx, readDeviceAvailabilityInput) 1426 | if err != nil { 1427 | s.logger.ErrorContext(gCtx, "failed to read the device availability", 1428 | slog.Any("err", err), 1429 | slog.Any("input", readDeviceAvailabilityInput)) 1430 | return err 1431 | } 1432 | 1433 | readDeviceConfigInput := &modelsRepo.ReadDeviceConfigInput{ 1434 | Mac: mac, 1435 | } 1436 | readDeviceConfigReturn, err := s.cache.ReadDeviceConfig(gCtx, readDeviceConfigInput) 1437 | if err != nil { 1438 | s.logger.ErrorContext(gCtx, "failed to read device config", 1439 | slog.Any("err", err), 1440 | slog.Any("input", readDeviceConfigInput)) 1441 | return err 1442 | } 1443 | 1444 | ///////////////////////////////// 1445 | // Publish all topics // 1446 | ///////////////////////////////// 1447 | 1448 | err = s.PublishDiscoveryTopic(gCtx, &models.PublishDiscoveryTopicInput{Device: models.DeviceConfig(readDeviceConfigReturn.Config)}) 1449 | if err != nil { 1450 | s.logger.ErrorContext(gCtx, "failed to publish the discovery topic", 1451 | slog.Any("err", err), 1452 | slog.Any("input", readDeviceConfigReturn.Config)) 1453 | return err 1454 | } 1455 | 1456 | time.Sleep(time.Millisecond * 500) 1457 | 1458 | publishAvailabilityInput := &modelsMqtt.PublishAvailabilityInput{ 1459 | Mac: mac, 1460 | Availability: readDeviceAvailabilityReturn.Availability, 1461 | } 1462 | err = s.mqtt.PublishAvailability(gCtx, publishAvailabilityInput) 1463 | if err != nil { 1464 | s.logger.ErrorContext(gCtx, "failed to publish device availability", 1465 | slog.Any("err", err), 1466 | slog.Any("input", publishAvailabilityInput)) 1467 | return err 1468 | } 1469 | 1470 | // Send temperature to MQTT 1471 | publishAmbientTempInput := &modelsMqtt.PublishAmbientTempInput{ 1472 | Mac: mac, 1473 | Temperature: converter.Temperature(models.Celsius, readDeviceConfigReturn.Config.TemperatureUnit, readAmbientTempReturn.Temperature), 1474 | } 1475 | err = s.mqtt.PublishAmbientTemp(gCtx, publishAmbientTempInput) 1476 | if err != nil { 1477 | s.logger.ErrorContext(gCtx, "failed to publish ambient temperature", 1478 | slog.Any("err", err), 1479 | slog.Any("input", publishAmbientTempInput)) 1480 | return err 1481 | } 1482 | 1483 | publishTemperatureInput := &modelsMqtt.PublishTemperatureInput{ 1484 | Mac: mac, 1485 | Temperature: converter.Temperature(models.Celsius, readDeviceConfigReturn.Config.TemperatureUnit, readDeviceStatusRawReturn.Status.Temperature), 1486 | } 1487 | err = s.mqtt.PublishTemperature(gCtx, publishTemperatureInput) 1488 | if err != nil { 1489 | s.logger.ErrorContext(gCtx, "failed to publish the device set temperature", 1490 | slog.Any("err", err), 1491 | slog.Any("input", publishTemperatureInput)) 1492 | return err 1493 | } 1494 | 1495 | publishModeInput := &modelsMqtt.PublishModeInput{ 1496 | Mac: mac, 1497 | Mode: hassStatus.Mode, 1498 | } 1499 | err = s.mqtt.PublishMode(gCtx, publishModeInput) 1500 | if err != nil { 1501 | s.logger.ErrorContext(gCtx, "failed to publish the device mode", 1502 | slog.Any("err", err), 1503 | slog.Any("input", publishModeInput)) 1504 | return err 1505 | } 1506 | 1507 | publishFanModeInput := &modelsMqtt.PublishFanModeInput{ 1508 | Mac: mac, 1509 | FanMode: hassStatus.FanMode, 1510 | } 1511 | err = s.mqtt.PublishFanMode(gCtx, publishFanModeInput) 1512 | if err != nil { 1513 | s.logger.ErrorContext(gCtx, "failed to publish the device fan mode", 1514 | slog.Any("err", err), 1515 | slog.Any("input", publishFanModeInput)) 1516 | return err 1517 | } 1518 | 1519 | publishSwingModeInput := &modelsMqtt.PublishSwingModeInput{ 1520 | Mac: mac, 1521 | SwingMode: hassStatus.SwingMode, 1522 | } 1523 | err = s.mqtt.PublishSwingMode(gCtx, publishSwingModeInput) 1524 | if err != nil { 1525 | s.logger.ErrorContext(gCtx, "failed to publish the device swing mode", 1526 | slog.Any("err", err), 1527 | slog.Any("input", publishSwingModeInput)) 1528 | return err 1529 | } 1530 | 1531 | publishDisplaySwitchInput := &modelsMqtt.PublishDisplaySwitchInput{ 1532 | Mac: mac, 1533 | Status: hassStatus.DisplaySwitch, 1534 | } 1535 | err = s.mqtt.PublishDisplaySwitch(gCtx, publishDisplaySwitchInput) 1536 | if err != nil { 1537 | s.logger.ErrorContext(gCtx, "failed to publish the display switch status", 1538 | slog.Any("err", err), 1539 | slog.Any("input", publishDisplaySwitchInput)) 1540 | return err 1541 | } 1542 | return nil 1543 | }) 1544 | } 1545 | 1546 | return eg.Wait() 1547 | } 1548 | --------------------------------------------------------------------------------