├── .gitignore ├── renovate.json ├── src ├── system.erl └── glome │ ├── homeassistant │ ├── entity_selector.gleam │ ├── environment.gleam │ ├── service.gleam │ ├── state_change_event.gleam │ ├── entity_id.gleam │ ├── domain.gleam │ ├── attributes.gleam │ └── state.gleam │ ├── core │ ├── loops.gleam │ ├── serde.gleam │ ├── util.gleam │ ├── ha_client.gleam │ ├── authentication.gleam │ └── error.gleam │ └── homeassistant.gleam ├── test └── glome_test.gleam ├── README.md ├── .github └── workflows │ └── test.yml ├── gleam.toml ├── LICENSE └── manifest.toml /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | *.env -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/system.erl: -------------------------------------------------------------------------------- 1 | -module(system). 2 | -export([get_var/1]). 3 | 4 | get_var(Name) -> 5 | case os:getenv(binary_to_list(Name)) of 6 | false -> {error, nil}; 7 | Value -> {ok, list_to_binary(Value)} 8 | end. -------------------------------------------------------------------------------- /test/glome_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /src/glome/homeassistant/entity_selector.gleam: -------------------------------------------------------------------------------- 1 | import glome/homeassistant/domain.{Domain} 2 | 3 | pub type EntitySelector { 4 | EntitySelector(domain: Domain, object_id: Selector) 5 | } 6 | 7 | pub type Selector { 8 | ObjectId(String) 9 | All 10 | Regex(String) 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # glome 2 | 3 | A Gleam project 4 | 5 | ## Quick start 6 | 7 | ```sh 8 | gleam run # Run the project 9 | gleam test # Run the tests 10 | gleam shell # Run an Erlang shell 11 | ``` 12 | 13 | ## Installation 14 | 15 | If available on Hex this package can be added to your Gleam project. 16 | 17 | ```sh 18 | gleam add glome 19 | ``` 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2.0.0 15 | - uses: gleam-lang/setup-erlang@v1.1.3 16 | with: 17 | otp-version: 23.2 18 | - uses: gleam-lang/setup-gleam@v1.0.2 19 | with: 20 | gleam-version: 0.21.0 21 | - run: gleam deps download 22 | - run: gleam test 23 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "glome" 2 | version = "0.3.0" 3 | 4 | # Fill out these fields if you intend to generate HTML documentation or publish 5 | # your project to the Hex package manager. 6 | # 7 | licences = ["Apache-2.0"] 8 | description = "A Gleam library to write and run Home Assistant automations." 9 | repository = { type = "github", user = "dennisschroeder", repo = "glome" } 10 | links = [{ title = "Website", href = "https://gleam.run" }] 11 | 12 | [dependencies] 13 | gleam_stdlib = "~> 0.22" 14 | nerf = "~> 0.1" 15 | gleam_otp = "~> 0.3" 16 | gleam_httpc = "~> 1.1" 17 | gleam_erlang = "~> 0.5" 18 | gleam_json = "~> 0.4" 19 | 20 | [dev-dependencies] 21 | gleeunit = "~> 0.5" 22 | -------------------------------------------------------------------------------- /src/glome/homeassistant/environment.gleam: -------------------------------------------------------------------------------- 1 | import gleam/result 2 | import gleam/int 3 | import gleam/option.{Option} 4 | import glome/core/authentication.{AccessToken} 5 | 6 | pub type Configuration { 7 | Configuration(host: String, port: Int, access_token: AccessToken) 8 | } 9 | 10 | pub fn get_host() -> Option(String) { 11 | get_env("HOST") 12 | |> option.from_result 13 | } 14 | 15 | pub fn get_port() -> Option(Int) { 16 | get_env("PORT") 17 | |> result.then(int.parse) 18 | |> option.from_result 19 | } 20 | 21 | pub fn get_access_token() -> Option(AccessToken) { 22 | get_env("ACCESS_TOKEN") 23 | |> option.from_result 24 | |> option.map(AccessToken) 25 | } 26 | 27 | pub fn get_ha_supervisor_token() -> Option(AccessToken) { 28 | get_env("SUPERVISOR_TOKEN") 29 | |> option.from_result 30 | |> option.map(AccessToken) 31 | } 32 | 33 | pub external fn get_env(String) -> Result(String, Nil) = 34 | "system" "get_var" 35 | -------------------------------------------------------------------------------- /src/glome/homeassistant/service.gleam: -------------------------------------------------------------------------------- 1 | import gleam/option.{Option} 2 | import gleam/http.{Post} 3 | import glome/homeassistant/domain.{Domain} 4 | import glome/homeassistant/entity_id.{EntityId} 5 | import glome/homeassistant/environment.{Configuration} 6 | import glome/core/error.{GlomeError} 7 | import glome/core/ha_client 8 | 9 | pub type Service { 10 | Service(String) 11 | } 12 | 13 | pub type Target { 14 | Entity(EntityId) 15 | Area(String) 16 | Device(String) 17 | } 18 | 19 | pub type ServiceData { 20 | ServiceData(target: Target) 21 | } 22 | 23 | pub fn call( 24 | config: Configuration, 25 | domain: Domain, 26 | service: Service, 27 | service_data: Option(String), 28 | ) -> Result(String, GlomeError) { 29 | let Service(service_value) = service 30 | ha_client.send_ha_rest_api_request( 31 | config.host, 32 | config.port, 33 | config.access_token, 34 | Post, 35 | ["/services", "/", domain.to_string(domain), "/", service_value], 36 | service_data, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dennis Schröder 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 | -------------------------------------------------------------------------------- /src/glome/core/loops.gleam: -------------------------------------------------------------------------------- 1 | import glome/core/error.{GlomeError, LoopNil} 2 | import gleam/result 3 | import gleam/io 4 | 5 | pub fn start_state_change_event_publisher( 6 | loop: fn() -> Result(Nil, GlomeError), 7 | ) -> Nil { 8 | let _ = 9 | loop() 10 | |> result.map_error(fn(error) { 11 | case error { 12 | LoopNil -> Nil 13 | other -> { 14 | io.println("") 15 | io.println("### Error in publisher loop ###") 16 | io.println("") 17 | io.debug(other) 18 | io.println("") 19 | io.println("### Error in publisher loop ###") 20 | Nil 21 | } 22 | } 23 | }) 24 | start_state_change_event_publisher(loop) 25 | } 26 | 27 | pub fn start_state_change_event_receiver( 28 | loop: fn() -> Result(Nil, GlomeError), 29 | ) -> Nil { 30 | let _ = 31 | loop() 32 | |> result.map_error(fn(error) { 33 | case error { 34 | LoopNil -> Nil 35 | other -> { 36 | io.println("### Error in receiver loop ###") 37 | io.debug(other) 38 | Nil 39 | } 40 | } 41 | }) 42 | start_state_change_event_receiver(loop) 43 | } 44 | -------------------------------------------------------------------------------- /src/glome/homeassistant/state_change_event.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{Dynamic, field, optional} 2 | import gleam/option.{Option} 3 | import gleam/result 4 | import glome/core/serde 5 | import glome/core/error.{GlomeError} 6 | import glome/homeassistant/entity_id.{EntityId} 7 | import glome/homeassistant/state.{State} 8 | 9 | pub type StateChangeEvent { 10 | StateChangeEvent( 11 | entity_id: EntityId, 12 | old_state: Option(State), 13 | new_state: Option(State), 14 | ) 15 | } 16 | 17 | pub fn decode(json_string: String) -> Result(StateChangeEvent, GlomeError) { 18 | json_string 19 | |> serde.decode_to_dynamic 20 | |> result.then(serde.get_field_by_path(_, "event.data")) 21 | |> result.then(decoder) 22 | } 23 | 24 | fn decoder(data: Dynamic) -> Result(StateChangeEvent, GlomeError) { 25 | let entity_id_decoder = field("entity_id", entity_id.decoder) 26 | try entity_id = 27 | entity_id_decoder(data) 28 | |> error.map_decode_errors 29 | 30 | data 31 | |> dynamic.decode3( 32 | StateChangeEvent, 33 | entity_id_decoder, 34 | field("new_state", optional(state.decode(_, entity_id.domain))), 35 | field("old_state", optional(state.decode(_, entity_id.domain))), 36 | ) 37 | |> error.map_decode_errors 38 | } 39 | -------------------------------------------------------------------------------- /src/glome/core/serde.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{Dynamic, dynamic, string} 2 | import gleam/result 3 | import gleam/string 4 | import glome/core/error.{GlomeError} 5 | import gleam/json 6 | 7 | pub fn decode_to_dynamic(json: String) -> Result(Dynamic, GlomeError) { 8 | json 9 | |> json.decode(dynamic) 10 | |> result.map_error(error.json_decode_to_dynamic_decode_error) 11 | |> error.map_decode_errors 12 | } 13 | 14 | pub fn string_field( 15 | data: String, 16 | field_name: String, 17 | ) -> Result(String, GlomeError) { 18 | json.decode(from: data, using: dynamic.field(field_name, string)) 19 | |> result.map_error(error.json_decode_to_dynamic_decode_error) 20 | |> error.map_decode_errors 21 | } 22 | 23 | pub fn get_field_by_path( 24 | data: Dynamic, 25 | path: String, 26 | ) -> Result(Dynamic, GlomeError) { 27 | case string.split(path, ".") { 28 | [x] -> 29 | data 30 | |> dynamic.field(x, dynamic) 31 | |> error.map_decode_errors 32 | [x, ..xs] -> 33 | data 34 | |> dynamic.field(x, dynamic) 35 | |> error.map_decode_errors 36 | |> result.then(get_field_by_path(_, string.join(xs, "."))) 37 | } 38 | } 39 | 40 | pub fn get_field_as_string(value: Dynamic) -> Result(String, GlomeError) { 41 | dynamic.string(value) 42 | |> error.map_decode_errors 43 | } 44 | -------------------------------------------------------------------------------- /src/glome/homeassistant/entity_id.gleam: -------------------------------------------------------------------------------- 1 | import gleam/string 2 | import gleam/result 3 | import gleam/dynamic.{DecodeError, Dynamic, string} 4 | import glome/homeassistant/domain.{Domain} 5 | 6 | pub type EntityId { 7 | EntityId(domain: Domain, object_id: String) 8 | } 9 | 10 | pub fn decoder(data: Dynamic) -> Result(EntityId, List(DecodeError)) { 11 | data 12 | |> string 13 | |> result.then(decode_entity_id) 14 | |> result.map(map_to_entity_id) 15 | } 16 | 17 | fn decode_entity_id( 18 | entity_id: String, 19 | ) -> Result(#(String, String), List(DecodeError)) { 20 | case string.split(entity_id, ".") { 21 | [_] -> 22 | Error(DecodeError( 23 | expected: "domain.object_id", 24 | found: entity_id, 25 | path: [], 26 | )) 27 | [domain, object_id] -> Ok(#(domain, object_id)) 28 | [_, _, ..] -> 29 | Error(DecodeError( 30 | expected: "domain.object_id", 31 | found: entity_id, 32 | path: [], 33 | )) 34 | } 35 | |> result.map_error(fn(error) { [error] }) 36 | } 37 | 38 | pub fn to_string(entity_id: EntityId) -> String { 39 | string.concat([domain.to_string(entity_id.domain), ".", entity_id.object_id]) 40 | } 41 | 42 | fn map_to_entity_id(entity_id_parts: #(String, String)) -> EntityId { 43 | EntityId(domain.from_string(entity_id_parts.0), entity_id_parts.1) 44 | } 45 | -------------------------------------------------------------------------------- /src/glome/core/util.gleam: -------------------------------------------------------------------------------- 1 | import gleam/list.{contains, fold_right, map} 2 | import gleam/pair 3 | 4 | /// A Group ist a Tuple with a key and a List of values 5 | pub type Group(k, v) = 6 | #(k, List(v)) 7 | 8 | /// A List of Groups 9 | pub type Groups(k, v) = 10 | List(Group(k, v)) 11 | 12 | fn insert_to_group(groups: Groups(k, v), pair: #(k, v)) -> Groups(k, v) { 13 | let append_if_key_matches = fn(group: Group(k, v)) { 14 | case pair.0 == group.0 { 15 | True -> #(group.0, [pair.1, ..group.1]) 16 | False -> group 17 | } 18 | } 19 | 20 | let is_key_known = 21 | map(groups, pair.first) 22 | |> contains(pair.0) 23 | 24 | case is_key_known { 25 | True -> map(groups, append_if_key_matches) 26 | False -> [#(pair.0, [pair.1]), ..groups] 27 | } 28 | } 29 | 30 | /// Takes a lists and groups the values by a key 31 | /// which is build from a key_selector function 32 | /// and the values are stored in a new List. 33 | /// 34 | /// ## Examples 35 | /// 36 | /// ```gleam 37 | /// > [Ok(3), Error("Wrong"), Ok(200), Ok(73)] 38 | /// |> group(with: fn(i) { 39 | /// case i { 40 | /// Ok(_) -> "Successful" 41 | /// Error(_) -> "Failed" 42 | /// } 43 | /// }) 44 | /// 45 | /// [ 46 | /// #("Failed", [Error("Wrong")]), 47 | /// #("Successful", [Ok(3), Ok(200), Ok(73)]) 48 | /// ] 49 | /// 50 | /// > group(from: [1,2,3,4,5], with: fn(i) {fn(i) { i - i / 3 * 3 }}) 51 | /// [#(0, [3]), #(1, [1, 4]), #(2, [2, 5])] 52 | /// ``` 53 | /// 54 | pub fn group(from list: List(v), with key_selector: fn(v) -> k) -> Groups(k, v) { 55 | map(list, fn(x) { #(key_selector(x), x) }) 56 | |> fold_right([], insert_to_group) 57 | } 58 | -------------------------------------------------------------------------------- /src/glome/homeassistant/domain.gleam: -------------------------------------------------------------------------------- 1 | pub type Domain { 2 | AlarmControlPanel 3 | BinarySensor 4 | Button 5 | Calendar 6 | Camera 7 | Climate 8 | Cover 9 | DeviceTracker 10 | Fan 11 | GeoLocation 12 | Group 13 | Humidifier 14 | ImageProcessing 15 | InputBoolean 16 | Light 17 | Lock 18 | MediaPlayer 19 | Notifiy 20 | Number 21 | Person 22 | Remote 23 | Scene 24 | Select 25 | Sensor 26 | STT 27 | Sun 28 | Switch 29 | TTS 30 | Vacuum 31 | WaterHeater 32 | Weather 33 | Zone 34 | Domain(name: String) 35 | } 36 | 37 | pub fn from_string(domain: String) -> Domain { 38 | case domain { 39 | "alarm_control_panel" -> AlarmControlPanel 40 | "binary_sensor" -> BinarySensor 41 | "button" -> Button 42 | "calendar" -> Calendar 43 | "camera" -> Camera 44 | "climate" -> Climate 45 | "cover" -> Cover 46 | "device_tracker" -> DeviceTracker 47 | "fan" -> Fan 48 | "group" -> Group 49 | "humidifier" -> Humidifier 50 | "input_boolean" -> InputBoolean 51 | "light" -> Light 52 | "lock" -> Lock 53 | "media_player" -> MediaPlayer 54 | "number" -> Number 55 | "person" -> Person 56 | "remote" -> Remote 57 | "select" -> Select 58 | "sensor" -> Sensor 59 | "sun" -> Sun 60 | "switch" -> Switch 61 | "vacuum" -> Vacuum 62 | "water_heater" -> WaterHeater 63 | "weather" -> Weather 64 | "zone" -> Zone 65 | domain -> Domain(domain) 66 | } 67 | } 68 | 69 | pub fn to_string(domain: Domain) -> String { 70 | case domain { 71 | Domain(value) -> value 72 | value -> do_to_string(value) 73 | } 74 | } 75 | 76 | external fn do_to_string(Domain) -> String = 77 | "erlang" "atom_to_binary" 78 | -------------------------------------------------------------------------------- /src/glome/core/ha_client.gleam: -------------------------------------------------------------------------------- 1 | import gleam/result 2 | import gleam/option.{None, Option, Some} 3 | import gleam/io 4 | import gleam/string 5 | import gleam/httpc 6 | import gleam/http.{Get, Http, Method, Post} 7 | import glome/core/authentication.{AccessToken} 8 | import glome/core/error.{ 9 | BadRequest, CallServiceError, GlomeError, NotAllowedHttpMethod, NotFound, 10 | } 11 | 12 | pub fn send_ha_rest_api_request( 13 | host: String, 14 | port: Int, 15 | access_token: AccessToken, 16 | method: Method, 17 | path_elements: List(String), 18 | body: Option(String), 19 | ) -> Result(String, GlomeError) { 20 | try method = ensure_post_or_get(method) 21 | let req = 22 | http.default_req() 23 | |> http.set_scheme(Http) 24 | |> http.set_host(host) 25 | |> http.set_port(port) 26 | |> http.prepend_req_header("accept", "application/json") 27 | |> http.prepend_req_header( 28 | "Authorization", 29 | string.append("Bearer ", access_token.value), 30 | ) 31 | |> http.set_method(method) 32 | |> http.set_path(string.concat(["/api", ..path_elements])) 33 | 34 | let req = case body { 35 | Some(data) -> http.set_req_body(req, data) 36 | None -> req 37 | } 38 | 39 | io.debug(req) 40 | 41 | try resp = 42 | httpc.send(req) 43 | |> result.map_error(fn(error) { 44 | io.debug(error) 45 | CallServiceError("Error calling service") 46 | }) 47 | 48 | case resp.status { 49 | 200 -> Ok(resp.body) 50 | 400 -> Error(BadRequest(resp.body)) 51 | 404 -> Error(NotFound(resp.body)) 52 | } 53 | } 54 | 55 | fn ensure_post_or_get(method: Method) { 56 | case method { 57 | Post | Get -> Ok(method) 58 | _ -> Error(NotAllowedHttpMethod) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/glome/core/authentication.gleam: -------------------------------------------------------------------------------- 1 | import gleam/result 2 | import nerf/websocket.{Connection, Text} 3 | import glome/core/error.{AuthenticationError, GlomeError} 4 | import glome/core/serde 5 | import gleam/json.{object, string} 6 | 7 | pub type AccessToken { 8 | AccessToken(value: String) 9 | } 10 | 11 | pub fn authenticate( 12 | connection: Connection, 13 | access_token: AccessToken, 14 | ) -> Result(String, GlomeError) { 15 | try _ = authentication_phase_started(connection) 16 | let auth_message = 17 | object([ 18 | #("type", string("auth")), 19 | #("access_token", string(access_token.value)), 20 | ]) 21 | |> json.to_string 22 | websocket.send(connection, auth_message) 23 | 24 | try Text(auth_response) = 25 | websocket.receive(connection, 500) 26 | |> result.map_error(fn(_) { 27 | AuthenticationError( 28 | "authentication failed! Auth result message not received!", 29 | ) 30 | }) 31 | 32 | try type_field = 33 | serde.string_field(auth_response, "type") 34 | |> result.map_error(fn(_) { 35 | AuthenticationError( 36 | "authentication failed! Auth result message has no field [ type ]!", 37 | ) 38 | }) 39 | 40 | case type_field { 41 | "auth_ok" -> Ok("Authenticated connection established") 42 | "auth_invalid" -> Error(AuthenticationError("Invalid authentication")) 43 | } 44 | } 45 | 46 | fn authentication_phase_started( 47 | connection: Connection, 48 | ) -> Result(String, GlomeError) { 49 | try Text(initial_message) = 50 | websocket.receive(connection, 500) 51 | |> result.map_error(fn(_) { 52 | AuthenticationError( 53 | "could not start auth phase! Auth message not received!", 54 | ) 55 | }) 56 | 57 | try auth_required = 58 | serde.string_field(initial_message, "type") 59 | |> result.map_error(fn(_) { 60 | AuthenticationError( 61 | "could not start auth phase! Auth message has no field [ type ]!", 62 | ) 63 | }) 64 | 65 | case auth_required { 66 | "auth_required" -> Ok(auth_required) 67 | _ -> 68 | Error(AuthenticationError( 69 | "Something went wrong. Authentication phase not started!", 70 | )) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "cowlib", version = "2.7.3", build_tools = ["rebar3"], requirements = [], otp_app = "cowlib", source = "hex", outer_checksum = "1E1A3D176D52DAEBBECBBCDFD27C27726076567905C2A9D7398C54DA9D225761" }, 6 | { name = "gleam_erlang", version = "0.9.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "15D0B66DA08D63C16EDDF32A70F6EB49E44DC83DB0370B7A45F1E954F8A23174" }, 7 | { name = "gleam_http", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2DF02A2BD551B590D92ADA90A67CDEE60EB4BAD48B5EE10A9AB4CE180CBCE36" }, 8 | { name = "gleam_httpc", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_http"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "D0289C268CA37E2560A3878CB555E2BC13C435E59B5FEF53DFB112E728FB577C" }, 9 | { name = "gleam_json", version = "0.4.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "116B182F6AE852622C9D634270D8E71F9D7D3AAAFD7CB9311DD0338D7C24D086" }, 10 | { name = "gleam_otp", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "3F05090CEDFBA5B6DE2AEE20868F92E815D287E71807DAD5663DAA1B566953C2" }, 11 | { name = "gleam_stdlib", version = "0.20.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "E5715F0CDCB3AB1BB73C6C35E46DA223997F680550E17CE46BC88B710E061CCE" }, 12 | { name = "gleeunit", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "5BF486C3E135B7F5ED8C054925FC48E5B2C79016A39F416FD8CF2E860520EE55" }, 13 | { name = "gun", version = "1.3.3", build_tools = ["rebar3"], requirements = ["cowlib"], otp_app = "gun", source = "hex", outer_checksum = "3106CE167F9C9723F849E4FB54EA4A4D814E3996AE243A1C828B256E749041E0" }, 14 | { name = "nerf", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_http", "gun", "gleam_erlang", "gleam_stdlib"], otp_app = "nerf", source = "hex", outer_checksum = "717FE58747F7E44E183D21D28288A48A934318CF49CE9BCE32909AB0896709A9" }, 15 | { name = "thoas", version = "0.2.0", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "630AAEE57FB3FDE201578E787259E15E788A27733D49DE8DCCE1354DB1885B8D" }, 16 | ] 17 | 18 | [requirements] 19 | gleam_erlang = "~> 0.5" 20 | gleam_httpc = "~> 1.1" 21 | gleam_json = "~> 0.4" 22 | gleam_otp = "~> 0.3" 23 | gleam_stdlib = "~> 0.22" 24 | gleeunit = "~> 0.5" 25 | nerf = "~> 0.1" 26 | -------------------------------------------------------------------------------- /src/glome/core/error.gleam: -------------------------------------------------------------------------------- 1 | import gleam/string 2 | import gleam/dynamic.{DecodeError, DecodeErrors} 3 | import gleam/result 4 | import gleam/list 5 | import gleam/int 6 | import gleam/list 7 | import nerf/websocket.{ConnectError, ConnectionFailed, ConnectionRefused} 8 | import gleam/json.{ 9 | UnexpectedByte, UnexpectedEndOfInput, UnexpectedFormat, UnexpectedSequence, 10 | } 11 | 12 | pub type GlomeError { 13 | WebsocketConnectionError(reason: String) 14 | JsonDecodeError(reason: String) 15 | AuthenticationError(reason: String) 16 | DeserializationError(cause: GlomeError, reason: String) 17 | EntityIdFormatError(reason: String) 18 | CallServiceError(reason: String) 19 | NotAllowedHttpMethod 20 | BadRequest(message: String) 21 | NotFound(message: String) 22 | LoopNil 23 | } 24 | 25 | pub fn json_decode_to_dynamic_decode_error( 26 | json_error: json.DecodeError, 27 | ) -> List(dynamic.DecodeError) { 28 | case json_error { 29 | UnexpectedEndOfInput -> [ 30 | dynamic.DecodeError( 31 | expected: "more input", 32 | found: "end of input", 33 | path: [], 34 | ), 35 | ] 36 | UnexpectedByte(byte, position) -> [ 37 | dynamic.DecodeError( 38 | expected: "other byte", 39 | found: byte, 40 | path: [int.to_string(position)], 41 | ), 42 | ] 43 | UnexpectedSequence(byte, position) -> [ 44 | dynamic.DecodeError( 45 | expected: "other byte", 46 | found: byte, 47 | path: [int.to_string(position)], 48 | ), 49 | ] 50 | UnexpectedFormat(list) -> list 51 | } 52 | } 53 | 54 | pub fn stringify_decode_error(error: DecodeError) -> String { 55 | string.concat([ 56 | "expected: ", 57 | error.expected, 58 | " got instead: ", 59 | error.found, 60 | " at path: ", 61 | string.join(error.path, "."), 62 | ]) 63 | } 64 | 65 | pub fn json_decode_error(reason: String) -> GlomeError { 66 | JsonDecodeError(string.concat([ 67 | "could not decode json due to: [ ", 68 | reason, 69 | " ]", 70 | ])) 71 | } 72 | 73 | pub fn map_decode_errors( 74 | errors: Result(a, DecodeErrors), 75 | ) -> Result(a, GlomeError) { 76 | result.map_error( 77 | errors, 78 | fn(decode_errors: DecodeErrors) { 79 | list.map(decode_errors, stringify_decode_error) 80 | |> string.join("ln") 81 | |> json_decode_error 82 | }, 83 | ) 84 | } 85 | 86 | pub fn map_decode_error(error: Result(a, DecodeError)) -> Result(a, GlomeError) { 87 | result.map_error( 88 | error, 89 | fn(decode_error: DecodeError) { 90 | stringify_decode_error(decode_error) 91 | |> json_decode_error 92 | }, 93 | ) 94 | } 95 | 96 | pub fn map_connection_error( 97 | error: Result(a, ConnectError), 98 | ) -> Result(a, GlomeError) { 99 | result.map_error( 100 | error, 101 | fn(conn_error: ConnectError) { 102 | case conn_error { 103 | ConnectionRefused(status, _) -> 104 | string.concat([ 105 | "connection from homeassistant refused,\n", 106 | "server responded with status [ ", 107 | int.to_string(status), 108 | " ]", 109 | ]) 110 | |> WebsocketConnectionError 111 | 112 | ConnectionFailed(_) -> 113 | string.concat([ 114 | "connection to homeassistant failed,\n", "due to: [ ", "unknown connection error", 115 | " ]", 116 | ]) 117 | |> WebsocketConnectionError 118 | } 119 | }, 120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /src/glome/homeassistant/attributes.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{DecodeError, Dynamic, field, optional, string} 2 | import gleam/result 3 | import gleam/option.{Option} 4 | import glome/core/serde 5 | import glome/core/error.{GlomeError} 6 | import glome/homeassistant/domain.{BinarySensor, Domain, MediaPlayer, Sensor} 7 | 8 | pub type DeviceClass { 9 | //MediaPlayer 10 | TV 11 | Speaker 12 | MediaReceiver 13 | 14 | GasSensor 15 | BatterySensor 16 | PowerSensor 17 | 18 | // BinarySensor 19 | BatteryCharging 20 | ColdSensor 21 | ConnectivitySensor 22 | DoorSensor 23 | GarageDoorSensor 24 | HeatSensor 25 | LightSensor 26 | LockSensor 27 | MoistureSensor 28 | MotionSensor 29 | MovingSensor 30 | OccupancySensor 31 | OpeningSensor 32 | PlugSensor 33 | 34 | PresenceSensor 35 | ProblemSensor 36 | RunningSensor 37 | SafetySensor 38 | SmokeSensor 39 | SoundSensor 40 | TamperSensor 41 | UpdateSensor 42 | VibrationSensor 43 | WindowSensor 44 | 45 | //Sensor 46 | AQISensor 47 | CarbonDioxideSensor 48 | CarbonMonoxideSensor 49 | CurrentSensor 50 | DateSensor 51 | EnergySensor 52 | FrequencySensor 53 | HumiditySensor 54 | IlluminanceSensor 55 | MonetarySensor 56 | NitrogenDioxideSensor 57 | NitrogenMonoxideSensor 58 | NitrousOxideSensor 59 | OzoneSensor 60 | PM1Sensor 61 | PM10Sensor 62 | PM25Sensor 63 | PowerFactorSensor 64 | PressureSensor 65 | SignalStrengthSensor 66 | SulphurDioxideSensor 67 | TemperatureSensor 68 | TimestampSensor 69 | VolatileOrganicCompoundsSensor 70 | VoltageSensor 71 | //Default 72 | UnknownDeviceClass 73 | DeviceClass(String) 74 | } 75 | 76 | pub type Attributes { 77 | Attributes( 78 | friendly_name: Option(String), 79 | device_class: DeviceClass, 80 | raw: Dynamic, 81 | ) 82 | } 83 | 84 | pub fn decoder( 85 | data: Dynamic, 86 | domain: Domain, 87 | ) -> Result(Attributes, List(DecodeError)) { 88 | let device_class = 89 | field("device_class", string)(data) 90 | |> result.unwrap("unknown_device_class") 91 | |> map_device_class_by_domain(domain) 92 | 93 | try friendly_name = field("friendly_name", optional(string))(data) 94 | 95 | Attributes( 96 | friendly_name: friendly_name, 97 | device_class: device_class, 98 | raw: data, 99 | ) 100 | |> Ok 101 | } 102 | 103 | pub fn from_dynamic_and_domain( 104 | attributes_message: Dynamic, 105 | domain: Domain, 106 | ) -> Result(Attributes, GlomeError) { 107 | let device_class_value = 108 | serde.get_field_by_path(attributes_message, "device_class") 109 | |> result.then(serde.get_field_as_string) 110 | |> result.unwrap("unknown_device_class") 111 | 112 | let device_class = map_device_class_by_domain(device_class_value, domain) 113 | 114 | let friendly_name = 115 | serde.get_field_by_path(attributes_message, "friendly_name") 116 | |> result.then(serde.get_field_as_string) 117 | |> option.from_result 118 | 119 | Ok(Attributes( 120 | device_class: device_class, 121 | friendly_name: friendly_name, 122 | raw: attributes_message, 123 | )) 124 | } 125 | 126 | fn map_device_class_by_domain( 127 | device_class_value: String, 128 | domain: Domain, 129 | ) -> DeviceClass { 130 | case device_class_value, domain { 131 | //MediaPlayer 132 | "tv", MediaPlayer -> TV 133 | "speaker", MediaPlayer -> Speaker 134 | "receiver", MediaPlayer -> MediaReceiver 135 | 136 | "battery", Sensor -> BatterySensor 137 | "battery", BinarySensor -> BatterySensor 138 | 139 | // BinarySensor 140 | "battery_charging", BinarySensor -> BatteryCharging 141 | "cold", BinarySensor -> ColdSensor 142 | "connectivity", BinarySensor -> ConnectivitySensor 143 | "door", BinarySensor -> DoorSensor 144 | "garage_door", BinarySensor -> GarageDoorSensor 145 | "gas", BinarySensor -> GasSensor 146 | "heat", BinarySensor -> HeatSensor 147 | "light", BinarySensor -> LightSensor 148 | "lock", BinarySensor -> LockSensor 149 | "moisture", BinarySensor -> MoistureSensor 150 | "motion", BinarySensor -> MotionSensor 151 | "moving", BinarySensor -> MovingSensor 152 | "occupancy", BinarySensor -> OccupancySensor 153 | "opening", BinarySensor -> OpeningSensor 154 | "plug", BinarySensor -> PlugSensor 155 | "power", BinarySensor -> PowerSensor 156 | "presence", BinarySensor -> PresenceSensor 157 | "problem", BinarySensor -> ProblemSensor 158 | "running", BinarySensor -> RunningSensor 159 | "safety", BinarySensor -> SafetySensor 160 | "smoke", BinarySensor -> SmokeSensor 161 | "sound", BinarySensor -> SoundSensor 162 | "tamper", BinarySensor -> TamperSensor 163 | "update", BinarySensor -> UpdateSensor 164 | "vibration", BinarySensor -> VibrationSensor 165 | "window", BinarySensor -> WindowSensor 166 | 167 | //Sensor 168 | "aqi", Sensor -> AQISensor 169 | "carbon_dioxide", Sensor -> CarbonDioxideSensor 170 | "carbon_monoxide", Sensor -> CarbonMonoxideSensor 171 | "current", Sensor -> CurrentSensor 172 | "date", Sensor -> DateSensor 173 | "energy", Sensor -> EnergySensor 174 | "frequency", Sensor -> FrequencySensor 175 | "gas", Sensor -> GasSensor 176 | "humidity", Sensor -> HumiditySensor 177 | "illuminance", Sensor -> IlluminanceSensor 178 | "monetary", Sensor -> MonetarySensor 179 | "nitrogen_dioxide", Sensor -> NitrogenDioxideSensor 180 | "nitrogen_monoxide", Sensor -> NitrogenMonoxideSensor 181 | "nitrous_oxide", Sensor -> NitrousOxideSensor 182 | "ozone", Sensor -> OzoneSensor 183 | "pm1", Sensor -> PM1Sensor 184 | "pm10", Sensor -> PM10Sensor 185 | "pm25", Sensor -> PM25Sensor 186 | "power_factor", Sensor -> PowerFactorSensor 187 | "power", Sensor -> PowerSensor 188 | "pressure", Sensor -> PressureSensor 189 | "signal_strength", Sensor -> SignalStrengthSensor 190 | "sulphur_dioxide", Sensor -> SulphurDioxideSensor 191 | "temperature", Sensor -> TemperatureSensor 192 | "timestamp", Sensor -> TimestampSensor 193 | "volatile_organic_compounds", Sensor -> VolatileOrganicCompoundsSensor 194 | "voltage", Sensor -> VoltageSensor 195 | 196 | "unknown_device_class", _ -> UnknownDeviceClass 197 | value, _ -> DeviceClass(value) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/glome/homeassistant.gleam: -------------------------------------------------------------------------------- 1 | import gleam/option.{Option, Some} 2 | import gleam/result 3 | import gleam/io 4 | import gleam/list 5 | import gleam/otp/process.{Sender} 6 | import gleam/otp/process 7 | import gleam/regex 8 | import gleam/json.{array, object, string} 9 | import nerf/websocket.{Connection, Text} 10 | import glome/core/authentication 11 | import glome/core/loops 12 | import glome/core/util 13 | import glome/core/error.{GlomeError, LoopNil} 14 | import glome/homeassistant/state.{State} 15 | import glome/homeassistant/state_change_event.{StateChangeEvent} 16 | import glome/homeassistant/entity_id.{EntityId} 17 | import glome/homeassistant/entity_selector.{All, EntitySelector, ObjectId, Regex} 18 | import glome/homeassistant/domain.{Domain} 19 | import glome/homeassistant/environment.{Configuration} 20 | import glome/homeassistant/service.{Area, Device, Entity, Service, Target} 21 | 22 | pub opaque type HomeAssistant { 23 | HomeAssistant(handlers: StateChangeHandlers, config: Configuration) 24 | } 25 | 26 | // PUBLIC API 27 | pub type StateChangeHandler = 28 | fn(StateChangeEvent, HomeAssistant) -> Result(Nil, GlomeError) 29 | 30 | pub type StateChangeFilter = 31 | fn(StateChangeEvent, HomeAssistant) -> Bool 32 | 33 | pub type StateChangeHandlersEntry { 34 | StateChangeHandlersEntry( 35 | entity_selector: EntitySelector, 36 | handler: StateChangeHandler, 37 | filter: StateChangeFilter, 38 | ) 39 | } 40 | 41 | pub type StateChangeHandlers = 42 | List(StateChangeHandlersEntry) 43 | 44 | pub fn connect( 45 | config: Configuration, 46 | conn_handler: fn(HomeAssistant) -> HomeAssistant, 47 | ) -> Result(Nil, GlomeError) { 48 | let ha_api_path = case config.host { 49 | "supervisor" -> "/core/websocket" 50 | _ -> "/api/websocket" 51 | } 52 | let #(sender, receiver) = process.new_channel() 53 | 54 | process.start(fn() { 55 | assert Ok(connection) = 56 | websocket.connect(config.host, ha_api_path, config.port, []) 57 | |> error.map_connection_error 58 | 59 | assert Ok(_) = authentication.authenticate(connection, config.access_token) 60 | assert Ok(_) = start_state_loop(connection, sender) 61 | }) 62 | 63 | let home_assistant = HomeAssistant(handlers: list.new(), config: config) 64 | 65 | let handlers: StateChangeHandlers = conn_handler(home_assistant).handlers 66 | 67 | loops.start_state_change_event_receiver(fn() { 68 | let state_changed_event: StateChangeEvent = 69 | process.receive_forever(receiver) 70 | 71 | list.filter( 72 | handlers, 73 | fn(entry: StateChangeHandlersEntry) { 74 | entry.entity_selector.domain == state_changed_event.entity_id.domain && case entry.entity_selector.object_id { 75 | ObjectId(object_id) -> 76 | object_id == state_changed_event.entity_id.object_id 77 | All -> True 78 | Regex(pattern) -> 79 | regex.from_string(pattern) 80 | |> result.map(regex.check( 81 | _, 82 | state_changed_event.entity_id.object_id, 83 | )) 84 | |> result.unwrap(or: False) 85 | } 86 | }, 87 | ) 88 | |> list.filter(fn(entry: StateChangeHandlersEntry) { 89 | entry.filter(state_changed_event, home_assistant) 90 | }) 91 | |> list.map(fn(entry: StateChangeHandlersEntry) { 92 | process.start(fn() { 93 | let result = entry.handler(state_changed_event, home_assistant) 94 | case result { 95 | Ok(Nil) -> Nil 96 | Error(error) -> { 97 | io.debug(error) 98 | Nil 99 | } 100 | } 101 | }) 102 | }) 103 | Ok(Nil) 104 | }) 105 | Ok(Nil) 106 | } 107 | 108 | pub fn add_handler( 109 | to home_assistant: HomeAssistant, 110 | for entity_selector: EntitySelector, 111 | handler handler: StateChangeHandler, 112 | ) -> HomeAssistant { 113 | let handlers = 114 | do_add_handler_with_filter( 115 | home_assistant.handlers, 116 | entity_selector, 117 | handler, 118 | fn(_, _) { True }, 119 | ) 120 | HomeAssistant(..home_assistant, handlers: handlers) 121 | } 122 | 123 | pub fn add_constrained_handler( 124 | to home_assistant: HomeAssistant, 125 | for entity_selector: EntitySelector, 126 | handler handler: StateChangeHandler, 127 | constraint filter: StateChangeFilter, 128 | ) -> HomeAssistant { 129 | let handlers = 130 | do_add_handler_with_filter( 131 | home_assistant.handlers, 132 | entity_selector, 133 | handler, 134 | filter, 135 | ) 136 | HomeAssistant(..home_assistant, handlers: handlers) 137 | } 138 | 139 | pub fn call_service( 140 | home_assistant: HomeAssistant, 141 | domain: Domain, 142 | service: Service, 143 | targets: Option(List(Target)), 144 | data: Option(String), 145 | ) -> Result(String, GlomeError) { 146 | let extract_target_value = fn(target: Target) { 147 | case target { 148 | Entity(value) -> entity_id.to_string(value) 149 | Area(value) -> value 150 | Device(value) -> value 151 | } 152 | } 153 | let convert_target = fn(item: #(String, List(Target))) { 154 | #(item.0, array(list.map(item.1, extract_target_value), string)) 155 | } 156 | 157 | let targets_json = 158 | option.then( 159 | targets, 160 | fn(value: List(Target)) { 161 | util.group( 162 | value, 163 | with: fn(item) { 164 | case item { 165 | Entity(_) -> "entity_id" 166 | Area(_) -> "area_id" 167 | Device(_) -> "device_id" 168 | } 169 | }, 170 | ) 171 | |> list.map(convert_target) 172 | |> json.object 173 | |> Some 174 | }, 175 | ) 176 | 177 | let service_data = 178 | option.then( 179 | targets_json, 180 | fn(value) { 181 | value 182 | |> json.to_string 183 | |> Some 184 | }, 185 | ) 186 | 187 | service.call(home_assistant.config, domain, service, service_data) 188 | } 189 | 190 | pub fn get_state( 191 | from home_assistant: HomeAssistant, 192 | of entity_id: EntityId, 193 | ) -> Result(State, GlomeError) { 194 | state.get(home_assistant.config, entity_id) 195 | } 196 | 197 | // PRIVATE API 198 | fn do_add_handler_with_filter( 199 | in handlers: StateChangeHandlers, 200 | for entity_selector: EntitySelector, 201 | handler handler: StateChangeHandler, 202 | predicate filter: StateChangeFilter, 203 | ) -> StateChangeHandlers { 204 | list.append( 205 | handlers, 206 | [StateChangeHandlersEntry(entity_selector, handler, filter)], 207 | ) 208 | } 209 | 210 | fn start_state_loop( 211 | connection: Connection, 212 | sender: Sender(StateChangeEvent), 213 | ) -> Result(Nil, GlomeError) { 214 | let subscribe_state_change_events = 215 | json.to_string(object([ 216 | #("id", string("1")), 217 | #("type", string("subscribe_events")), 218 | #("event_type", string("state_changed")), 219 | ])) 220 | 221 | websocket.send(connection, subscribe_state_change_events) 222 | case websocket.receive(connection, 500) { 223 | Ok(Text(message)) -> Ok(io.debug(message)) 224 | Error(_) -> Error(LoopNil) 225 | } 226 | loops.start_state_change_event_publisher(fn() { 227 | case websocket.receive(connection, 500) { 228 | Ok(Text(message)) -> publish_state_change_event(sender, message) 229 | Error(_) -> Error(LoopNil) 230 | } 231 | }) 232 | Ok(Nil) 233 | } 234 | 235 | fn publish_state_change_event( 236 | sender: Sender(StateChangeEvent), 237 | message: String, 238 | ) -> Result(Nil, GlomeError) { 239 | try state_change_event = state_change_event.decode(message) 240 | process.send(sender, state_change_event) 241 | Ok(Nil) 242 | } 243 | -------------------------------------------------------------------------------- /src/glome/homeassistant/state.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{DecodeError, Dynamic, field, string} 2 | import gleam/result 3 | import gleam/option.{None} 4 | import gleam/http.{Get} 5 | import glome/core/error.{GlomeError} 6 | import glome/core/serde 7 | import glome/core/ha_client 8 | import glome/homeassistant/domain.{ 9 | BinarySensor, Cover, Domain, InputBoolean, Light, MediaPlayer, Sensor, 10 | } 11 | import glome/homeassistant/attributes.{ 12 | AQISensor, Attributes, BatteryCharging, BatterySensor, CarbonDioxideSensor, CarbonMonoxideSensor, 13 | ColdSensor, ConnectivitySensor, CurrentSensor, DateSensor, DoorSensor, EnergySensor, 14 | FrequencySensor, GarageDoorSensor, GasSensor, HeatSensor, HumiditySensor, IlluminanceSensor, 15 | LightSensor, LockSensor, MoistureSensor, MonetarySensor, MotionSensor, MovingSensor, 16 | NitrogenDioxideSensor, NitrogenMonoxideSensor, NitrousOxideSensor, OccupancySensor, 17 | OpeningSensor, OzoneSensor, PM10Sensor, PM1Sensor, PM25Sensor, PlugSensor, PowerFactorSensor, 18 | PowerSensor, PresenceSensor, PressureSensor, ProblemSensor, RunningSensor, SafetySensor, 19 | SignalStrengthSensor, SmokeSensor, SoundSensor, SulphurDioxideSensor, TV, TamperSensor, 20 | TemperatureSensor, TimestampSensor, UnknownDeviceClass, UpdateSensor, VibrationSensor, 21 | VolatileOrganicCompoundsSensor, VoltageSensor, WindowSensor, 22 | } 23 | import glome/homeassistant/entity_id.{EntityId} 24 | import glome/homeassistant/environment.{Configuration} 25 | 26 | pub type StateValue { 27 | //Sensor 28 | AirQualityIndex(value: String) 29 | Battery(value: String) 30 | CarbonDioxide(value: String) 31 | CarbonMonoxide(value: String) 32 | Current(value: String) 33 | Date(value: String) 34 | Energy(value: String) 35 | Frequency(value: String) 36 | Gas(value: String) 37 | Humidity(value: String) 38 | Illuminance(value: String) 39 | Monetary(value: String) 40 | NitrogenDioxide(value: String) 41 | NitrogenMonoxide(value: String) 42 | NitrousOxide(value: String) 43 | Ozone(value: String) 44 | PM1(value: String) 45 | PM10(value: String) 46 | PM25(value: String) 47 | PowerFactor(value: String) 48 | Power(value: String) 49 | Pressure(value: String) 50 | SignalStrength(value: String) 51 | SulphurDioxide(value: String) 52 | Temperature(value: String) 53 | Timestamp(value: String) 54 | VolatileOrganicCompounds(value: String) 55 | Voltage(value: String) 56 | //BinarySensor 57 | On 58 | Off 59 | Low 60 | Normal 61 | Open 62 | Closed 63 | Charging 64 | NotCharging 65 | Cold 66 | Connected 67 | Disconnected 68 | GasDetected 69 | NoGas 70 | Hot 71 | LightDetected 72 | NoLight 73 | Unlocked 74 | Locked 75 | MoistureDetected 76 | NoMoisture 77 | MotionDetected 78 | Moving 79 | NotMoving 80 | NoMotion 81 | Occupied 82 | NotOccupied 83 | PluggedIn 84 | Unplugged 85 | PowerDetected 86 | NoPower 87 | Home 88 | Away 89 | NotHome 90 | ProblemDetected 91 | NoProblem 92 | Running 93 | NotRunning 94 | Unsafe 95 | Safe 96 | SmokeDetected 97 | NoSmoke 98 | SoundDetected 99 | NoSound 100 | TamperingDetected 101 | NoTampering 102 | UpdateAvailable 103 | UpToDate 104 | VibrationDetected 105 | NoVibration 106 | Heat 107 | // MediaPlayer 108 | Idle 109 | Playing 110 | Paused 111 | Buffering 112 | 113 | Unavailable 114 | Unknown 115 | StateValue(value: String) 116 | } 117 | 118 | pub type State { 119 | State(value: StateValue, attributes: Attributes) 120 | } 121 | 122 | pub fn get( 123 | config: Configuration, 124 | entity_id: EntityId, 125 | ) -> Result(State, GlomeError) { 126 | ha_client.send_ha_rest_api_request( 127 | config.host, 128 | config.port, 129 | config.access_token, 130 | Get, 131 | ["/states", "/", entity_id.to_string(entity_id)], 132 | None, 133 | ) 134 | |> result.then(serde.decode_to_dynamic) 135 | |> result.map_error(fn(_) { [] }) 136 | |> result.then(decode(_, entity_id.domain)) 137 | |> error.map_decode_errors 138 | } 139 | 140 | pub fn decode(data: Dynamic, domain: Domain) -> Result(State, List(DecodeError)) { 141 | try state_value_string = field("state", string)(data) 142 | try attributes = field("attributes", attributes.decoder(_, domain))(data) 143 | 144 | case domain { 145 | MediaPlayer -> map_to_media_player_state(state_value_string, attributes) 146 | Cover -> map_to_open_closed_state(state_value_string, attributes) 147 | Light -> map_to_light_state(state_value_string, attributes) 148 | InputBoolean -> map_to_boolean_state(state_value_string, attributes) 149 | BinarySensor -> map_to_binary_sensor_state(state_value_string, attributes) 150 | Sensor -> map_to_sensor_state(state_value_string, attributes) 151 | _ -> State(StateValue(state_value_string), attributes) 152 | } 153 | |> Ok 154 | } 155 | 156 | fn map_to_media_player_state( 157 | state_value: String, 158 | attributes: Attributes, 159 | ) -> State { 160 | let mapped_state_value = case state_value, attributes.device_class { 161 | "on", TV -> On 162 | "off", TV -> Off 163 | "on", UnknownDeviceClass -> On 164 | "off", UnknownDeviceClass -> Off 165 | "idle", UnknownDeviceClass -> Idle 166 | "buffering", UnknownDeviceClass -> Buffering 167 | "playing", UnknownDeviceClass -> Playing 168 | "paused", UnknownDeviceClass -> Paused 169 | "unavailable", _ -> Unavailable 170 | "unknown", _ -> Unknown 171 | state, _ -> StateValue(state) 172 | } 173 | State(mapped_state_value, attributes) 174 | } 175 | 176 | fn map_to_open_closed_state( 177 | state_value: String, 178 | attributes: Attributes, 179 | ) -> State { 180 | let mapped_state_value = case state_value { 181 | "open" -> Open 182 | "closed" -> Closed 183 | "unavailable" -> Unavailable 184 | value -> StateValue(value) 185 | } 186 | 187 | State(mapped_state_value, attributes) 188 | } 189 | 190 | fn map_to_light_state(state_value: String, attributes: Attributes) -> State { 191 | let mapped_state_value = case state_value { 192 | "on" -> On 193 | "off" -> Off 194 | "unavailable" -> Unavailable 195 | value -> StateValue(value) 196 | } 197 | State(mapped_state_value, attributes) 198 | } 199 | 200 | fn map_to_boolean_state(state_value: String, attributes: Attributes) -> State { 201 | let mapped_state_value = case state_value { 202 | "on" -> On 203 | "off" -> Off 204 | value -> StateValue(value) 205 | } 206 | State(mapped_state_value, attributes) 207 | } 208 | 209 | fn map_to_binary_sensor_state( 210 | state_value: String, 211 | attributes: Attributes, 212 | ) -> State { 213 | let mapped_state_value = case state_value, attributes.device_class { 214 | "on", BatterySensor -> Low 215 | "on", BatteryCharging -> Charging 216 | "on", ColdSensor -> Cold 217 | "on", ConnectivitySensor -> Connected 218 | "on", DoorSensor -> Open 219 | "on", GarageDoorSensor -> Open 220 | "on", GasSensor -> GasDetected 221 | "on", HeatSensor -> Hot 222 | "on", LightSensor -> LightDetected 223 | "on", LockSensor -> Unlocked 224 | "on", MoistureSensor -> MoistureDetected 225 | "on", MotionSensor -> MotionDetected 226 | "on", MovingSensor -> Moving 227 | "on", OccupancySensor -> Occupied 228 | "on", OpeningSensor -> Open 229 | "on", PlugSensor -> PluggedIn 230 | "on", PowerSensor -> PluggedIn 231 | "on", PresenceSensor -> Home 232 | "on", ProblemSensor -> ProblemDetected 233 | "on", RunningSensor -> Running 234 | "on", SafetySensor -> Unsafe 235 | "on", SmokeSensor -> SmokeDetected 236 | "on", SoundSensor -> SoundDetected 237 | "on", TamperSensor -> TamperingDetected 238 | "on", UpdateSensor -> UpdateAvailable 239 | "on", VibrationSensor -> VibrationDetected 240 | "on", WindowSensor -> Open 241 | 242 | "off", BatterySensor -> Normal 243 | "off", BatteryCharging -> NotCharging 244 | "off", ColdSensor -> Normal 245 | "off", ConnectivitySensor -> Disconnected 246 | "off", DoorSensor -> Closed 247 | "off", GarageDoorSensor -> Open 248 | "off", GasSensor -> NoGas 249 | "off", HeatSensor -> Normal 250 | "off", LightSensor -> NoLight 251 | "off", LockSensor -> Locked 252 | "off", MoistureSensor -> NoMoisture 253 | "off", MotionSensor -> NoMotion 254 | "off", MovingSensor -> NotMoving 255 | "off", OccupancySensor -> NotOccupied 256 | "off", OpeningSensor -> Closed 257 | "off", PlugSensor -> Unplugged 258 | "off", PresenceSensor -> Away 259 | "off", ProblemSensor -> NoProblem 260 | "off", RunningSensor -> NotRunning 261 | "off", SafetySensor -> Safe 262 | "off", SmokeSensor -> NoSmoke 263 | "off", SoundSensor -> NoSound 264 | "off", TamperSensor -> NoTampering 265 | "off", UpdateSensor -> UpToDate 266 | "off", VibrationSensor -> NoVibration 267 | "off", WindowSensor -> Closed 268 | "unavailable", _ -> Unavailable 269 | value, _ -> StateValue(value) 270 | } 271 | State(mapped_state_value, attributes) 272 | } 273 | 274 | fn map_to_sensor_state(state_value: String, attributes: Attributes) -> State { 275 | let mapped_state_value = case attributes.device_class { 276 | AQISensor -> AirQualityIndex(state_value) 277 | BatterySensor -> Battery(state_value) 278 | CarbonDioxideSensor -> CarbonDioxide(state_value) 279 | CarbonMonoxideSensor -> CarbonMonoxide(state_value) 280 | CurrentSensor -> Current(state_value) 281 | DateSensor -> Date(state_value) 282 | EnergySensor -> Energy(state_value) 283 | FrequencySensor -> Frequency(state_value) 284 | GasSensor -> Gas(state_value) 285 | HumiditySensor -> Humidity(state_value) 286 | IlluminanceSensor -> Illuminance(state_value) 287 | MonetarySensor -> Monetary(state_value) 288 | NitrogenDioxideSensor -> NitrogenDioxide(state_value) 289 | NitrogenMonoxideSensor -> NitrogenMonoxide(state_value) 290 | NitrousOxideSensor -> NitrousOxide(state_value) 291 | OzoneSensor -> Ozone(state_value) 292 | PM1Sensor -> PM1(state_value) 293 | PM10Sensor -> PM10(state_value) 294 | PM25Sensor -> PM25(state_value) 295 | PowerFactorSensor -> PowerFactor(state_value) 296 | PowerSensor -> Power(state_value) 297 | PressureSensor -> Pressure(state_value) 298 | SignalStrengthSensor -> SignalStrength(state_value) 299 | SulphurDioxideSensor -> SulphurDioxide(state_value) 300 | TemperatureSensor -> Temperature(state_value) 301 | TimestampSensor -> Timestamp(state_value) 302 | VolatileOrganicCompoundsSensor -> VolatileOrganicCompounds(state_value) 303 | VoltageSensor -> Voltage(state_value) 304 | _ -> StateValue(state_value) 305 | } 306 | 307 | State(mapped_state_value, attributes) 308 | } 309 | --------------------------------------------------------------------------------