···11+MIT License
22+33+Copyright (c) 2025 Thomas Gazagnaire
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+79
README.md
···11+# meross
22+33+Control Meross smart plugs over local HTTP API without cloud.
44+55+## Overview
66+77+This library provides local control of Meross smart plugs (MSS310, MSS315, etc.) via their HTTP API. No cloud account or internet connection required.
88+99+## Features
1010+1111+- Power control (on/off/toggle/reboot)
1212+- Real-time electricity monitoring (voltage, current, power)
1313+- Historical energy consumption
1414+- Countdown timers
1515+- Automation triggers
1616+- LED control (Do Not Disturb mode)
1717+- WiFi signal monitoring
1818+- Matter pairing support
1919+- Device discovery/scanning
2020+2121+## Installation
2222+2323+```
2424+opam install meross
2525+```
2626+2727+## Usage
2828+2929+```ocaml
3030+Eio_main.run @@ fun env ->
3131+Eio.Switch.run @@ fun sw ->
3232+let net = Eio.Stdenv.net env in
3333+3434+(* Get device info *)
3535+match Meross.get_info ~net ~sw "192.168.0.6" with
3636+| Ok info -> Fmt.pr "%a@." Meross.pp_info info
3737+| Error (`Msg e) -> Fmt.epr "Error: %s@." e
3838+3939+(* Control power *)
4040+let _ = Meross.turn_on ~net ~sw "192.168.0.6" in
4141+let _ = Meross.turn_off ~net ~sw "192.168.0.6" in
4242+4343+(* Get electricity reading *)
4444+match Meross.get_electricity ~net ~sw "192.168.0.6" with
4545+| Ok e -> Fmt.pr "Power: %.1f W@." (Meross.Electricity.power e)
4646+| Error _ -> ()
4747+```
4848+4949+## Modules
5050+5151+- `Meross.Protocol` - HTTP protocol and authentication
5252+- `Meross.Device` - Device info and power control
5353+- `Meross.Electricity` - Real-time power monitoring
5454+- `Meross.Consumption` - Historical energy usage
5555+- `Meross.Timers` - Countdown timers
5656+- `Meross.Triggers` - Automation rules
5757+- `Meross.Dnd` - LED control
5858+- `Meross.Runtime` - WiFi signal and stats
5959+- `Meross.Abilities` - Feature discovery
6060+- `Meross.Commissioning` - Matter pairing
6161+6262+## Supported Devices
6363+6464+Tested with:
6565+- MSS310 (Smart Plug with Energy Monitor)
6666+- MSS315 (Smart Plug with Energy Monitor, Matter)
6767+6868+Should work with other Meross plugs that support local HTTP API.
6969+7070+## Related Work
7171+7272+- [meross-iot](https://github.com/albertogeniola/MerossIot) - Python library (cloud-based)
7373+- [meross-cloud](https://www.home-assistant.io/integrations/meross/) - Home Assistant integration
7474+7575+This library focuses on local control without cloud dependencies.
7676+7777+## License
7878+7979+MIT License. See [LICENSE.md](LICENSE.md) for details.
···11+(** Meross power monitoring.
22+33+ Real-time power consumption: watts, voltage, current. *)
44+55+module P = Protocol
66+77+let ( let* ) = Result.bind
88+99+(** {1 Types} *)
1010+1111+type t = {
1212+ power : float; (** Current power in watts *)
1313+ voltage : float; (** Current voltage in volts *)
1414+ current : float; (** Current in amps *)
1515+}
1616+1717+(** {1 Codecs} *)
1818+1919+let codec =
2020+ Jsont.Object.map ~kind:"electricity" (fun power voltage current ->
2121+ (* Meross reports in milliwatts/decivolts/milliamps *)
2222+ {
2323+ power = power /. 1000.0;
2424+ voltage = voltage /. 10.0;
2525+ current = current /. 1000.0;
2626+ })
2727+ |> Jsont.Object.mem "power" Jsont.number ~enc:(fun e -> e.power *. 1000.0)
2828+ |> Jsont.Object.mem "voltage" Jsont.number ~enc:(fun e -> e.voltage *. 10.0)
2929+ |> Jsont.Object.mem "current" Jsont.number ~enc:(fun e -> e.current *. 1000.0)
3030+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
3131+3232+type payload = { electricity : t }
3333+3434+let payload_codec =
3535+ Jsont.Object.map ~kind:"electricity_payload" (fun e -> { electricity = e })
3636+ |> Jsont.Object.mem "electricity" codec ~enc:(fun p -> p.electricity)
3737+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
3838+3939+(** {1 Operations} *)
4040+4141+(** Get current power consumption *)
4242+let get ~http ~sw ip =
4343+ let json =
4444+ P.make_request_empty ~method_:"GET"
4545+ ~namespace:"Appliance.Control.Electricity"
4646+ in
4747+ let* resp = P.http_post ~http ~sw ip json in
4848+ match P.decode (P.response_codec payload_codec) resp with
4949+ | Error _ -> Error (`Msg "Device does not support electricity monitoring")
5050+ | Ok r -> Ok r.resp_payload.electricity
5151+5252+(** {1 Pretty Printing} *)
5353+5454+let pp ppf e = Fmt.pf ppf "%.1fW @@ %.1fV (%.3fA)" e.power e.voltage e.current
+28
lib/electricity.mli
···11+(** Meross power monitoring.
22+33+ Real-time power consumption readings via [Appliance.Control.Electricity].
44+ Reports instantaneous power (watts), voltage (volts), and current (amps). *)
55+66+(** {1:types Types} *)
77+88+type t = {
99+ power : float; (** Current power in watts *)
1010+ voltage : float; (** Current voltage in volts *)
1111+ current : float; (** Current in amps *)
1212+}
1313+(** Power consumption reading. *)
1414+1515+(** {1:operations Operations} *)
1616+1717+val get :
1818+ http:Protocol.http ->
1919+ sw:Eio.Switch.t ->
2020+ string ->
2121+ (t, [> `Msg of string ]) result
2222+(** [get ~net ~sw ip] reads current power consumption.
2323+2424+ Returns error if device doesn't support electricity monitoring. *)
2525+2626+(** {1:pp Pretty printing} *)
2727+2828+val pp : t Fmt.t
+187
lib/meross.ml
···11+(** Meross smart plug local control.
22+33+ Control Meross smart plugs over local HTTP API.
44+55+ {2 Modules}
66+77+ - {!Protocol}: Core HTTP protocol and authentication
88+ - {!Device}: Device info and basic on/off control
99+ - {!Electricity}: Real-time power monitoring
1010+ - {!Consumption}: Historical energy consumption
1111+ - {!Timers}: Scheduled on/off control
1212+ - {!Triggers}: Automation rules (countdown, auto-off)
1313+ - {!Dnd}: LED DND mode control
1414+ - {!Runtime}: WiFi signal and runtime stats
1515+ - {!Abilities}: Feature discovery
1616+ - {!Commissioning}: Matter commissioning helpers
1717+1818+ {2 Example}
1919+2020+ {[
2121+ Eio_main.run @@ fun env ->
2222+ Eio.Switch.run @@ fun sw ->
2323+ let http =
2424+ Protocol.create_http ~clock:(Eio.Stdenv.clock env)
2525+ ~net:(Eio.Stdenv.net env)
2626+ in
2727+ match Meross.Device.get_info ~http ~sw "192.168.0.6" with
2828+ | Ok info ->
2929+ Fmt.pr "Device: %s, State: %s@." info.device_type
3030+ (if info.is_on then "ON" else "OFF")
3131+ | Error (`Msg msg) -> Fmt.epr "Error: %s@." msg
3232+ ]}
3333+3434+ @see <https://github.com/albertogeniola/MerossIot>
3535+ @see <https://github.com/arandall/meross/blob/main/doc/protocol.md> *)
3636+3737+module Protocol = Protocol
3838+module Device = Device
3939+module Electricity = Electricity
4040+module Consumption = Consumption
4141+module Timers = Timers
4242+module Triggers = Triggers
4343+module Dnd = Dnd
4444+module Runtime = Runtime
4545+module Abilities = Abilities
4646+module Commissioning = Commissioning
4747+4848+let ( let* ) = Result.bind
4949+5050+(** {1 Convenience Re-exports} *)
5151+5252+type device_info = Device.info = {
5353+ device_type : string;
5454+ mac : string;
5555+ uuid : string;
5656+ ip : string;
5757+ firmware : string;
5858+ is_on : bool;
5959+}
6060+(** Device info type *)
6161+6262+type extended_info = {
6363+ device : device_info;
6464+ electricity : Electricity.t option;
6565+ wifi_signal : int option;
6666+ led_off : bool option;
6767+}
6868+(** Extended device info with power monitoring *)
6969+7070+(** {1 High-Level Operations} *)
7171+7272+let get_info = Device.get_info
7373+let set_power = Device.set_power
7474+let turn_on = Device.turn_on
7575+let turn_off = Device.turn_off
7676+let toggle = Device.toggle
7777+let reboot = Device.reboot
7878+let is_meross_device = Device.is_meross
7979+let get_abilities = Abilities.get
8080+let get_electricity = Electricity.get
8181+let get_consumption = Consumption.get
8282+let get_timers = Timers.get
8383+let get_dnd_mode ~http ~sw ip = Dnd.get ~http ~sw ip
8484+let set_dnd_mode = Dnd.set
8585+let get_runtime = Runtime.get
8686+let unbind_matter = Commissioning.unbind
8787+let trigger_commissioning = Commissioning.trigger
8888+8989+(** Get full device status including power monitoring *)
9090+let get_extended_info ~http ~sw ip =
9191+ let* device = Device.get_info ~http ~sw ip in
9292+ let electricity =
9393+ match Electricity.get ~http ~sw ip with Ok e -> Some e | Error _ -> None
9494+ in
9595+ let wifi_signal =
9696+ match Runtime.get_signal ~http ~sw ip with
9797+ | Ok s -> Some s
9898+ | Error _ -> None
9999+ in
100100+ let led_off =
101101+ match Dnd.get ~http ~sw ip with Ok mode -> Some mode | Error _ -> None
102102+ in
103103+ Ok { device; electricity; wifi_signal; led_off }
104104+105105+(** {1 Scanning} *)
106106+107107+let scan ?(batch_size = 64) ~http ~sw ips =
108108+ (* Process IPs in batches to avoid exhausting file descriptors *)
109109+ let rec process_batches acc remaining =
110110+ match remaining with
111111+ | [] -> List.rev acc
112112+ | _ ->
113113+ let batch, rest =
114114+ let rec take n xs acc =
115115+ if n = 0 then (List.rev acc, xs)
116116+ else
117117+ match xs with
118118+ | [] -> (List.rev acc, [])
119119+ | x :: xs' -> take (n - 1) xs' (x :: acc)
120120+ in
121121+ take batch_size remaining []
122122+ in
123123+ let results =
124124+ Eio.Fiber.List.filter_map
125125+ (fun ip ->
126126+ match Device.get_info ~http ~sw ip with
127127+ | Ok info -> Some info
128128+ | Error _ -> None)
129129+ batch
130130+ in
131131+ process_batches (List.rev_append results acc) rest
132132+ in
133133+ process_batches [] ips
134134+135135+(** {1 Device Detection} *)
136136+137137+type detected = { name : string; version : string option; info : string option }
138138+139139+let detect ~http ~sw ip =
140140+ match Device.get_info ~http ~sw ip with
141141+ | Error _ -> None
142142+ | Ok dev_info ->
143143+ let name =
144144+ match dev_info.device_type with
145145+ | "mss315" -> "Meross Smart Plug (MSS315)"
146146+ | "mss310" -> "Meross Smart Plug (MSS310)"
147147+ | "mss210" -> "Meross Smart Plug (MSS210)"
148148+ | "mss110" -> "Meross Smart Plug (MSS110)"
149149+ | "mss620" -> "Meross Smart Power Strip (MSS620)"
150150+ | "mss425" -> "Meross Smart Power Strip (MSS425)"
151151+ | t -> Fmt.str "Meross Device (%s)" t
152152+ in
153153+ Some
154154+ {
155155+ name;
156156+ version = Some dev_info.firmware;
157157+ info =
158158+ Some
159159+ (Fmt.str "MAC: %s, State: %s" dev_info.mac
160160+ (if dev_info.is_on then "ON" else "OFF"));
161161+ }
162162+163163+(** Check if device has Matter abilities *)
164164+let has_matter_ability ~http ~sw ip =
165165+ match Abilities.get ~http ~sw ip with
166166+ | Error _ -> false
167167+ | Ok abilities -> Abilities.has_matter abilities
168168+169169+(** {1 Pretty Printing} *)
170170+171171+let pp_info = Device.pp
172172+let pp_electricity = Electricity.pp
173173+let pp_consumption = Consumption.pp
174174+let pp_timer = Timers.pp
175175+176176+let pp_extended_info ppf info =
177177+ Device.pp ppf info.device;
178178+ (match info.electricity with
179179+ | Some e -> Fmt.pf ppf " Power: %a@." Electricity.pp e
180180+ | None -> ());
181181+ (match info.wifi_signal with
182182+ | Some s -> Fmt.pf ppf " WiFi: %d%%@." s
183183+ | None -> ());
184184+ match info.led_off with
185185+ | Some true -> Fmt.pf ppf " LED: off (DND mode)@."
186186+ | Some false -> Fmt.pf ppf " LED: on@."
187187+ | None -> ()
+201
lib/meross.mli
···11+(** Meross smart plug local control.
22+33+ Control Meross smart plugs over local HTTP API. No cloud required.
44+55+ {2 Submodules}
66+77+ - {!Protocol}: HTTP protocol and authentication
88+ - {!Device}: Device info and power control
99+ - {!Electricity}: Real-time power monitoring
1010+ - {!Consumption}: Historical energy usage
1111+ - {!Timers}: Countdown timers (TimerX)
1212+ - {!Triggers}: Automation rules (TriggerX)
1313+ - {!Dnd}: LED control (Do Not Disturb)
1414+ - {!Runtime}: WiFi signal and stats
1515+ - {!Abilities}: Feature discovery
1616+ - {!Commissioning}: Matter pairing helpers
1717+1818+ {2 Example}
1919+2020+ {[
2121+ Eio_main.run @@ fun env ->
2222+ Eio.Switch.run @@ fun sw ->
2323+ let http =
2424+ Protocol.create_http ~clock:(Eio.Stdenv.clock env)
2525+ ~net:(Eio.Stdenv.net env)
2626+ in
2727+ match Meross.get_info ~http ~sw "192.168.0.6" with
2828+ | Ok info -> Fmt.pr "%a" Meross.pp_info info
2929+ | Error (`Msg e) -> Fmt.epr "Error: %s@." e
3030+ ]} *)
3131+3232+(** {1:modules Submodules} *)
3333+3434+module Protocol = Protocol
3535+module Device = Device
3636+module Electricity = Electricity
3737+module Consumption = Consumption
3838+module Timers = Timers
3939+module Triggers = Triggers
4040+module Dnd = Dnd
4141+module Runtime = Runtime
4242+module Abilities = Abilities
4343+module Commissioning = Commissioning
4444+4545+(** {1:types Types} *)
4646+4747+type device_info = Device.info = {
4848+ device_type : string;
4949+ mac : string;
5050+ uuid : string;
5151+ ip : string;
5252+ firmware : string;
5353+ is_on : bool;
5454+}
5555+(** Device information. *)
5656+5757+type extended_info = {
5858+ device : device_info;
5959+ electricity : Electricity.t option;
6060+ wifi_signal : int option;
6161+ led_off : bool option;
6262+}
6363+(** Extended info with power monitoring. *)
6464+6565+type detected = { name : string; version : string option; info : string option }
6666+(** Detection result for {!Identify} integration. *)
6767+6868+(** {1:info Device info} *)
6969+7070+val get_info :
7171+ http:Protocol.http ->
7272+ sw:Eio.Switch.t ->
7373+ string ->
7474+ (device_info, [> `Msg of string ]) result
7575+(** [get_info ~net ~sw ip] retrieves device information. *)
7676+7777+val get_extended_info :
7878+ http:Protocol.http ->
7979+ sw:Eio.Switch.t ->
8080+ string ->
8181+ (extended_info, [> `Msg of string ]) result
8282+(** [get_extended_info ~net ~sw ip] retrieves full status. *)
8383+8484+(** {1:control Power control} *)
8585+8686+val set_power :
8787+ http:Protocol.http ->
8888+ sw:Eio.Switch.t ->
8989+ string ->
9090+ on:bool ->
9191+ (unit, [> `Msg of string ]) result
9292+(** [set_power ~net ~sw ip ~on] sets power state. *)
9393+9494+val turn_on :
9595+ http:Protocol.http ->
9696+ sw:Eio.Switch.t ->
9797+ string ->
9898+ (unit, [> `Msg of string ]) result
9999+100100+val turn_off :
101101+ http:Protocol.http ->
102102+ sw:Eio.Switch.t ->
103103+ string ->
104104+ (unit, [> `Msg of string ]) result
105105+106106+val toggle :
107107+ http:Protocol.http ->
108108+ sw:Eio.Switch.t ->
109109+ string ->
110110+ (unit, [> `Msg of string ]) result
111111+112112+val reboot :
113113+ http:Protocol.http ->
114114+ sw:Eio.Switch.t ->
115115+ string ->
116116+ (unit, [> `Msg of string ]) result
117117+118118+(** {1:monitoring Monitoring} *)
119119+120120+val get_abilities :
121121+ http:Protocol.http ->
122122+ sw:Eio.Switch.t ->
123123+ string ->
124124+ (string list, [> `Msg of string ]) result
125125+126126+val get_electricity :
127127+ http:Protocol.http ->
128128+ sw:Eio.Switch.t ->
129129+ string ->
130130+ (Electricity.t, [> `Msg of string ]) result
131131+132132+val get_consumption :
133133+ http:Protocol.http ->
134134+ sw:Eio.Switch.t ->
135135+ string ->
136136+ (Consumption.entry list, [> `Msg of string ]) result
137137+138138+val get_timers :
139139+ http:Protocol.http ->
140140+ sw:Eio.Switch.t ->
141141+ string ->
142142+ (Timers.t list, [> `Msg of string ]) result
143143+144144+val get_dnd_mode :
145145+ http:Protocol.http ->
146146+ sw:Eio.Switch.t ->
147147+ string ->
148148+ (bool, [> `Msg of string ]) result
149149+150150+val set_dnd_mode :
151151+ http:Protocol.http ->
152152+ sw:Eio.Switch.t ->
153153+ string ->
154154+ enabled:bool ->
155155+ (unit, [> `Msg of string ]) result
156156+157157+val get_runtime :
158158+ http:Protocol.http ->
159159+ sw:Eio.Switch.t ->
160160+ string ->
161161+ (Runtime.t, [> `Msg of string ]) result
162162+163163+(** {1:matter Matter} *)
164164+165165+val unbind_matter :
166166+ http:Protocol.http ->
167167+ sw:Eio.Switch.t ->
168168+ string ->
169169+ (unit, [> `Msg of string ]) result
170170+171171+val trigger_commissioning :
172172+ http:Protocol.http ->
173173+ sw:Eio.Switch.t ->
174174+ string ->
175175+ (unit, [> `Msg of string ]) result
176176+177177+val has_matter_ability : http:Protocol.http -> sw:Eio.Switch.t -> string -> bool
178178+179179+(** {1:detection Detection} *)
180180+181181+val is_meross_device : http:Protocol.http -> sw:Eio.Switch.t -> string -> bool
182182+val detect : http:Protocol.http -> sw:Eio.Switch.t -> string -> detected option
183183+184184+(** {1:scanning Scanning} *)
185185+186186+val scan :
187187+ ?batch_size:int ->
188188+ http:Protocol.http ->
189189+ sw:Eio.Switch.t ->
190190+ string list ->
191191+ device_info list
192192+(** [scan ?batch_size ~net ~sw ips] probes IPs in batches (default 64 per batch)
193193+ to avoid exhausting file descriptors, returning Meross devices found. *)
194194+195195+(** {1:pp Pretty printing} *)
196196+197197+val pp_info : device_info Fmt.t
198198+val pp_extended_info : extended_info Fmt.t
199199+val pp_electricity : Electricity.t Fmt.t
200200+val pp_consumption : Consumption.entry list Fmt.t
201201+val pp_timer : Timers.t Fmt.t
+168
lib/protocol.ml
···11+(** Meross HTTP protocol primitives.
22+33+ Core protocol implementation: authentication, headers, and HTTP transport.
44+*)
55+66+let log_src = Logs.Src.create "meross.protocol"
77+88+module Log = (val Logs.src_log log_src : Logs.LOG)
99+1010+(** {1 Authentication} *)
1111+1212+(** MD5 hash using digestif *)
1313+let md5 s = Digestif.MD5.(digest_string s |> to_hex)
1414+1515+(** Get Unix epoch timestamp using ptime *)
1616+let unix_epoch_seconds () =
1717+ let ptime = Ptime_clock.now () in
1818+ Ptime.to_float_s ptime |> int_of_float
1919+2020+(** Generate message ID and signature. The signature is: md5(messageId + key +
2121+ timestamp). For local access without cloud pairing, an empty key works. *)
2222+let make_auth () =
2323+ let timestamp = unix_epoch_seconds () in
2424+ (* Use secure random for message ID generation *)
2525+ let rand_bytes = Crypto_rng.generate 4 in
2626+ let rand =
2727+ (Char.code rand_bytes.[0] lsl 24)
2828+ lor (Char.code rand_bytes.[1] lsl 16)
2929+ lor (Char.code rand_bytes.[2] lsl 8)
3030+ lor Char.code rand_bytes.[3]
3131+ in
3232+ let message_id = md5 (Fmt.str "%d%d" rand timestamp) in
3333+ let sign = md5 (Fmt.str "%s%d" message_id timestamp) in
3434+ (message_id, timestamp, sign)
3535+3636+(** {1 Header Type and Codec} *)
3737+3838+type header = {
3939+ from_ : string;
4040+ message_id : string;
4141+ method_ : string;
4242+ namespace : string;
4343+ payload_version : int;
4444+ sign : string;
4545+ timestamp : int;
4646+}
4747+4848+let header_codec =
4949+ Jsont.Object.map ~kind:"header"
5050+ (fun from_ message_id method_ namespace payload_version sign timestamp ->
5151+ {
5252+ from_;
5353+ message_id;
5454+ method_;
5555+ namespace;
5656+ payload_version;
5757+ sign;
5858+ timestamp;
5959+ })
6060+ |> Jsont.Object.mem "from" Jsont.string ~enc:(fun h -> h.from_)
6161+ |> Jsont.Object.mem "messageId" Jsont.string ~enc:(fun h -> h.message_id)
6262+ |> Jsont.Object.mem "method" Jsont.string ~enc:(fun h -> h.method_)
6363+ |> Jsont.Object.mem "namespace" Jsont.string ~enc:(fun h -> h.namespace)
6464+ |> Jsont.Object.mem "payloadVersion" Jsont.int ~enc:(fun h ->
6565+ h.payload_version)
6666+ |> Jsont.Object.mem "sign" Jsont.string ~enc:(fun h -> h.sign)
6767+ |> Jsont.Object.mem "timestamp" Jsont.int ~enc:(fun h -> h.timestamp)
6868+ |> Jsont.Object.finish
6969+7070+(** Build a request header *)
7171+let make_header ~method_ ~namespace =
7272+ let message_id, timestamp, sign = make_auth () in
7373+ {
7474+ from_ = "";
7575+ message_id;
7676+ method_;
7777+ namespace;
7878+ payload_version = 1;
7979+ sign;
8080+ timestamp;
8181+ }
8282+8383+(** {1 Request/Response Types} *)
8484+8585+type 'a request = { header : header; payload : 'a }
8686+8787+let request_codec payload_codec =
8888+ Jsont.Object.map ~kind:"request" (fun header payload -> { header; payload })
8989+ |> Jsont.Object.mem "header" header_codec ~enc:(fun r -> r.header)
9090+ |> Jsont.Object.mem "payload" payload_codec ~enc:(fun r -> r.payload)
9191+ |> Jsont.Object.finish
9292+9393+type 'a response = { resp_header : header; resp_payload : 'a }
9494+9595+let response_codec payload_codec =
9696+ Jsont.Object.map ~kind:"response" (fun resp_header resp_payload ->
9797+ { resp_header; resp_payload })
9898+ |> Jsont.Object.mem "header" header_codec ~enc:(fun r -> r.resp_header)
9999+ |> Jsont.Object.mem "payload" payload_codec ~enc:(fun r -> r.resp_payload)
100100+ |> Jsont.Object.finish
101101+102102+(** {1 Common Payload Codecs} *)
103103+104104+(** Empty payload for commands that don't need data *)
105105+let empty_payload_codec : unit Jsont.t =
106106+ Jsont.Object.map ~kind:"empty" ()
107107+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
108108+109109+type enable_payload = { enable : int }
110110+(** Enable payload for SET commands *)
111111+112112+let enable_payload_codec =
113113+ Jsont.Object.map ~kind:"enable_payload" (fun enable -> { enable })
114114+ |> Jsont.Object.mem "enable" Jsont.int ~enc:(fun p -> p.enable)
115115+ |> Jsont.Object.finish
116116+117117+(** {1 JSON Helpers} *)
118118+119119+let decode codec s = Jsont_bytesrw.decode_string codec s
120120+121121+let encode codec v =
122122+ Jsont_bytesrw.encode_string codec v |> Result.value ~default:""
123123+124124+(** {1 Request Builders} *)
125125+126126+(** Build a request with empty payload *)
127127+let make_request_empty ~method_ ~namespace =
128128+ let req = { header = make_header ~method_ ~namespace; payload = () } in
129129+ encode (request_codec empty_payload_codec) req
130130+131131+(** Build a request with enable payload *)
132132+let make_request_enable ~namespace =
133133+ let req =
134134+ { header = make_header ~method_:"SET" ~namespace; payload = { enable = 1 } }
135135+ in
136136+ encode (request_codec enable_payload_codec) req
137137+138138+(** {1 HTTP Transport} *)
139139+140140+type http = Http : { net : 'a Eio.Net.t; clock : 'b Eio.Time.clock } -> http
141141+142142+let create_http ~net ~clock = Http { net; clock }
143143+144144+let http_post ?(timeout = 2.0) ~http ~sw ip json_str =
145145+ let (Http { net; clock }) = http in
146146+ let url = Fmt.str "http://%s/config" ip in
147147+ let headers =
148148+ Requests.Headers.(empty |> set_string "Content-Type" "application/json")
149149+ in
150150+ let body = Requests.Body.of_string Requests.Mime.json json_str in
151151+ let timeout = Requests.Timeout.create ~connect:timeout ~read:timeout () in
152152+ let response =
153153+ Requests.One.post ~sw ~clock ~net ~headers ~body ~timeout ~verify_tls:false
154154+ url
155155+ in
156156+ Ok (Requests.Response.text response)
157157+158158+(** {1 Response Helpers} *)
159159+160160+(** Check if response is an acknowledgement *)
161161+let is_ack resp_header = resp_header.method_ = "SETACK"
162162+163163+(** Parse and check for SETACK *)
164164+let parse_ack resp =
165165+ match decode (response_codec empty_payload_codec) resp with
166166+ | Ok r when is_ack r.resp_header -> Ok ()
167167+ | Ok _ -> Error (`Msg "Command not acknowledged")
168168+ | Error _ -> Error (`Msg "Invalid response")
+96
lib/protocol.mli
···11+(** Meross HTTP protocol primitives.
22+33+ Core protocol implementation for local Meross device communication. Handles
44+ authentication (MD5 signature), request/response framing, and HTTP
55+ transport.
66+77+ Messages use a JSON format with [header] (auth, namespace) and [payload]. *)
88+99+(** {1:header Message header} *)
1010+1111+type header = {
1212+ from_ : string;
1313+ message_id : string;
1414+ method_ : string; (** "GET", "SET", or "SETACK" *)
1515+ namespace : string; (** e.g. "Appliance.System.All" *)
1616+ payload_version : int;
1717+ sign : string; (** MD5 signature *)
1818+ timestamp : int;
1919+}
2020+(** Request/response header with authentication. *)
2121+2222+val header_codec : header Jsont.t
2323+(** Codec for header serialization. *)
2424+2525+val make_header : method_:string -> namespace:string -> header
2626+(** [make_header ~method_ ~namespace] builds header with fresh auth. *)
2727+2828+(** {1:request Request types} *)
2929+3030+type 'a request = { header : header; payload : 'a }
3131+(** Generic request with typed payload. *)
3232+3333+val request_codec : 'a Jsont.t -> 'a request Jsont.t
3434+(** [request_codec payload_codec] builds request codec. *)
3535+3636+type 'a response = { resp_header : header; resp_payload : 'a }
3737+(** Generic response with typed payload. *)
3838+3939+val response_codec : 'a Jsont.t -> 'a response Jsont.t
4040+(** [response_codec payload_codec] builds response codec. *)
4141+4242+(** {1:payloads Common payloads} *)
4343+4444+val empty_payload_codec : unit Jsont.t
4545+(** Empty payload codec for requests without data. *)
4646+4747+type enable_payload = { enable : int }
4848+(** Enable payload for SET commands. *)
4949+5050+val enable_payload_codec : enable_payload Jsont.t
5151+5252+(** {1:builders Request builders} *)
5353+5454+val make_request_empty : method_:string -> namespace:string -> string
5555+(** [make_request_empty ~method_ ~namespace] builds request with empty payload.
5656+*)
5757+5858+val make_request_enable : namespace:string -> string
5959+(** [make_request_enable ~namespace] builds SET request with enable=1. *)
6060+6161+(** {1:transport HTTP transport} *)
6262+6363+type http
6464+(** HTTP client type. *)
6565+6666+val create_http : net:_ Eio.Net.t -> clock:_ Eio.Time.clock -> http
6767+(** [create_http ~net ~clock] creates an HTTP client. *)
6868+6969+val http_post :
7070+ ?timeout:float ->
7171+ http:http ->
7272+ sw:Eio.Switch.t ->
7373+ string ->
7474+ string ->
7575+ (string, [> `Msg of string ]) result
7676+(** [http_post ?timeout ~http ~sw ip json] posts JSON to device's /config
7777+ endpoint. Default timeout is 2 seconds. *)
7878+7979+(** {1:response Response helpers} *)
8080+8181+val is_ack : header -> bool
8282+(** [is_ack header] returns true if method is "SETACK". *)
8383+8484+val parse_ack : string -> (unit, [> `Msg of string ]) result
8585+(** [parse_ack resp] checks response is a valid acknowledgement. *)
8686+8787+(** {1:utils Utilities} *)
8888+8989+val unix_epoch_seconds : unit -> int
9090+(** [unix_epoch_seconds ()] returns current Unix timestamp. *)
9191+9292+val decode : 'a Jsont.t -> string -> ('a, string) result
9393+(** [decode codec s] decodes JSON string. *)
9494+9595+val encode : 'a Jsont.t -> 'a -> string
9696+(** [encode codec v] encodes value to JSON string. *)
+43
lib/runtime.ml
···11+(** Meross runtime stats.
22+33+ WiFi signal strength and other runtime information. *)
44+55+module P = Protocol
66+77+let ( let* ) = Result.bind
88+99+(** {1 Types} *)
1010+1111+type t = { signal : int (** WiFi signal strength 0-100 *) }
1212+1313+(** {1 Codecs} *)
1414+1515+let codec =
1616+ Jsont.Object.map ~kind:"runtime" (fun signal ->
1717+ { signal = Option.value ~default:0 signal })
1818+ |> Jsont.Object.opt_mem "signal" Jsont.int ~enc:(fun r -> Some r.signal)
1919+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
2020+2121+type payload = { runtime : t }
2222+2323+let payload_codec =
2424+ Jsont.Object.map ~kind:"runtime_payload" (fun r -> { runtime = r })
2525+ |> Jsont.Object.mem "runtime" codec ~enc:(fun p -> p.runtime)
2626+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
2727+2828+(** {1 Operations} *)
2929+3030+(** Get runtime stats *)
3131+let get ~http ~sw ip =
3232+ let json =
3333+ P.make_request_empty ~method_:"GET" ~namespace:"Appliance.System.Runtime"
3434+ in
3535+ let* resp = P.http_post ~http ~sw ip json in
3636+ match P.decode (P.response_codec payload_codec) resp with
3737+ | Error _ -> Error (`Msg "Device does not support runtime info")
3838+ | Ok r -> Ok r.resp_payload.runtime
3939+4040+(** Get WiFi signal strength *)
4141+let get_signal ~http ~sw ip =
4242+ let* r = get ~http ~sw ip in
4343+ Ok r.signal
+33
lib/runtime.mli
···11+(** Meross runtime stats.
22+33+ Runtime information via [Appliance.System.Runtime] including WiFi signal
44+ strength. *)
55+66+(** {1:types Types} *)
77+88+type t = { signal : int (** WiFi signal strength 0-100 *) }
99+(** Runtime statistics. *)
1010+1111+type payload = { runtime : t }
1212+(** Payload container. *)
1313+1414+(** {1:codecs Codecs} *)
1515+1616+val codec : t Jsont.t
1717+val payload_codec : payload Jsont.t
1818+1919+(** {1:operations Operations} *)
2020+2121+val get :
2222+ http:Protocol.http ->
2323+ sw:Eio.Switch.t ->
2424+ string ->
2525+ (t, [> `Msg of string ]) result
2626+(** [get ~net ~sw ip] retrieves runtime stats. *)
2727+2828+val get_signal :
2929+ http:Protocol.http ->
3030+ sw:Eio.Switch.t ->
3131+ string ->
3232+ (int, [> `Msg of string ]) result
3333+(** [get_signal ~net ~sw ip] gets WiFi signal strength. *)
+157
lib/timers.ml
···11+(** Meross timer control (TimerX namespace).
22+33+ TimerX provides countdown timers for turning devices on/off after a
44+ specified duration. *)
55+66+module P = Protocol
77+88+let ( let* ) = Result.bind
99+1010+(** {1 Types} *)
1111+1212+type countdown = {
1313+ onoff : int; (** Target state: 0=off, 1=on *)
1414+ end_time : int; (** Unix timestamp when timer fires *)
1515+ duration : int; (** Duration in seconds *)
1616+}
1717+(** Countdown timer configuration *)
1818+1919+type t = {
2020+ channel : int;
2121+ timer_type : int; (** 1 = countdown *)
2222+ down : countdown option;
2323+}
2424+(** Timer entry *)
2525+2626+(** {1 Codecs} *)
2727+2828+let countdown_codec =
2929+ Jsont.Object.map ~kind:"countdown" (fun onoff end_time duration ->
3030+ {
3131+ onoff = Option.value ~default:0 onoff;
3232+ end_time = Option.value ~default:0 end_time;
3333+ duration = Option.value ~default:0 duration;
3434+ })
3535+ |> Jsont.Object.opt_mem "onoff" Jsont.int ~enc:(fun c -> Some c.onoff)
3636+ |> Jsont.Object.opt_mem "end" Jsont.int ~enc:(fun c -> Some c.end_time)
3737+ |> Jsont.Object.opt_mem "duration" Jsont.int ~enc:(fun c -> Some c.duration)
3838+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
3939+4040+let timer_codec =
4141+ Jsont.Object.map ~kind:"timer" (fun channel timer_type down ->
4242+ {
4343+ channel = Option.value ~default:0 channel;
4444+ timer_type = Option.value ~default:1 timer_type;
4545+ down;
4646+ })
4747+ |> Jsont.Object.opt_mem "channel" Jsont.int ~enc:(fun t -> Some t.channel)
4848+ |> Jsont.Object.opt_mem "type" Jsont.int ~enc:(fun t -> Some t.timer_type)
4949+ |> Jsont.Object.opt_mem "down" countdown_codec ~enc:(fun t -> t.down)
5050+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
5151+5252+type payload = { timerx : t list }
5353+(** Payload with timer list *)
5454+5555+let payload_codec =
5656+ Jsont.Object.map ~kind:"timerx_payload" (fun t -> { timerx = t })
5757+ |> Jsont.Object.mem "timerx" (Jsont.list timer_codec) ~enc:(fun p -> p.timerx)
5858+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
5959+6060+(** {1 Operations} *)
6161+6262+(** Get all timers *)
6363+let get ~http ~sw ip =
6464+ let json =
6565+ P.make_request_empty ~method_:"GET" ~namespace:"Appliance.Control.TimerX"
6666+ in
6767+ let* resp = P.http_post ~http ~sw ip json in
6868+ Logs.info (fun m -> m "TimerX GET response: %s" resp);
6969+ match P.decode (P.response_codec payload_codec) resp with
7070+ | Error _ -> Ok []
7171+ | Ok r -> Ok r.resp_payload.timerx
7272+7373+(** Set a countdown timer to turn off after duration seconds *)
7474+let set_off_timer ~http ~sw ip ~duration =
7575+ let now = P.unix_epoch_seconds () in
7676+ let timer =
7777+ {
7878+ channel = 0;
7979+ timer_type = 1;
8080+ (* countdown *)
8181+ down =
8282+ Some { onoff = 0; (* turn OFF *) end_time = now + duration; duration };
8383+ }
8484+ in
8585+ let payload = { timerx = [ timer ] } in
8686+ let req =
8787+ {
8888+ P.header =
8989+ P.make_header ~method_:"SET" ~namespace:"Appliance.Control.TimerX";
9090+ P.payload;
9191+ }
9292+ in
9393+ let json = P.encode (P.request_codec payload_codec) req in
9494+ Logs.info (fun m -> m "TimerX SET request: %s" json);
9595+ let* resp = P.http_post ~http ~sw ip json in
9696+ Logs.info (fun m -> m "TimerX SET response: %s" resp);
9797+ P.parse_ack resp
9898+9999+(** Set a countdown timer to turn on after duration seconds *)
100100+let set_on_timer ~http ~sw ip ~duration =
101101+ let now = P.unix_epoch_seconds () in
102102+ let timer =
103103+ {
104104+ channel = 0;
105105+ timer_type = 1;
106106+ (* countdown *)
107107+ down =
108108+ Some { onoff = 1; (* turn ON *) end_time = now + duration; duration };
109109+ }
110110+ in
111111+ let payload = { timerx = [ timer ] } in
112112+ let req =
113113+ {
114114+ P.header =
115115+ P.make_header ~method_:"SET" ~namespace:"Appliance.Control.TimerX";
116116+ P.payload;
117117+ }
118118+ in
119119+ let json = P.encode (P.request_codec payload_codec) req in
120120+ Logs.info (fun m -> m "TimerX SET request: %s" json);
121121+ let* resp = P.http_post ~http ~sw ip json in
122122+ Logs.info (fun m -> m "TimerX SET response: %s" resp);
123123+ P.parse_ack resp
124124+125125+(** Cancel all timers *)
126126+let clear ~http ~sw ip =
127127+ let payload = { timerx = [] } in
128128+ let req =
129129+ {
130130+ P.header =
131131+ P.make_header ~method_:"SET" ~namespace:"Appliance.Control.TimerX";
132132+ P.payload;
133133+ }
134134+ in
135135+ let json = P.encode (P.request_codec payload_codec) req in
136136+ let* resp = P.http_post ~http ~sw ip json in
137137+ P.parse_ack resp
138138+139139+(** {1 Pretty Printing} *)
140140+141141+let format_duration secs =
142142+ if secs < 60 then Printf.sprintf "%ds" secs
143143+ else if secs < 3600 then Printf.sprintf "%dm%ds" (secs / 60) (secs mod 60)
144144+ else Printf.sprintf "%dh%dm" (secs / 3600) (secs mod 3600 / 60)
145145+146146+let pp_countdown ppf c =
147147+ let action = if c.onoff = 0 then "OFF" else "ON" in
148148+ let now = P.unix_epoch_seconds () in
149149+ let remaining = c.end_time - now in
150150+ if remaining > 0 then
151151+ Fmt.pf ppf "turn %s in %s" action (format_duration remaining)
152152+ else Fmt.pf ppf "turn %s (expired)" action
153153+154154+let pp ppf t =
155155+ match t.down with
156156+ | Some c -> Fmt.pf ppf "Timer[ch%d]: %a" t.channel pp_countdown c
157157+ | None -> Fmt.pf ppf "Timer[ch%d]: (no config)" t.channel
+67
lib/timers.mli
···11+(** Meross timer control.
22+33+ Countdown timers via [Appliance.Control.TimerX] for scheduling automatic
44+ power state changes after a duration. *)
55+66+(** {1:types Types} *)
77+88+type countdown = {
99+ onoff : int; (** Target state: 0=off, 1=on *)
1010+ end_time : int; (** Unix timestamp when timer fires *)
1111+ duration : int; (** Duration in seconds *)
1212+}
1313+(** Countdown configuration. *)
1414+1515+type t = {
1616+ channel : int; (** Output channel (0 for single-outlet) *)
1717+ timer_type : int; (** Timer type: 1=countdown *)
1818+ down : countdown option;
1919+}
2020+(** Timer entry. *)
2121+2222+type payload = { timerx : t list }
2323+(** Payload container. *)
2424+2525+(** {1:codecs JSON codecs} *)
2626+2727+val countdown_codec : countdown Jsont.t
2828+val timer_codec : t Jsont.t
2929+val payload_codec : payload Jsont.t
3030+3131+(** {1:operations Operations} *)
3232+3333+val get :
3434+ http:Protocol.http ->
3535+ sw:Eio.Switch.t ->
3636+ string ->
3737+ (t list, [> `Msg of string ]) result
3838+(** [get ~net ~sw ip] retrieves all active timers. *)
3939+4040+val set_off_timer :
4141+ http:Protocol.http ->
4242+ sw:Eio.Switch.t ->
4343+ string ->
4444+ duration:int ->
4545+ (unit, [> `Msg of string ]) result
4646+(** [set_off_timer ~net ~sw ip ~duration] sets timer to turn off. *)
4747+4848+val set_on_timer :
4949+ http:Protocol.http ->
5050+ sw:Eio.Switch.t ->
5151+ string ->
5252+ duration:int ->
5353+ (unit, [> `Msg of string ]) result
5454+(** [set_on_timer ~net ~sw ip ~duration] sets timer to turn on. *)
5555+5656+val clear :
5757+ http:Protocol.http ->
5858+ sw:Eio.Switch.t ->
5959+ string ->
6060+ (unit, [> `Msg of string ]) result
6161+(** [clear ~net ~sw ip] cancels all timers. *)
6262+6363+(** {1:pp Pretty printing} *)
6464+6565+val format_duration : int -> string
6666+val pp_countdown : countdown Fmt.t
6767+val pp : t Fmt.t
+161
lib/triggers.ml
···11+(** Meross trigger/automation control.
22+33+ Triggers are automation rules like "turn off after X seconds" or countdown
44+ timers. *)
55+66+module P = Protocol
77+88+let ( let* ) = Result.bind
99+1010+(** {1 Types} *)
1111+1212+type rule = {
1313+ week : int; (** Day bitmask: bit 0=Sun, 1=Mon, ..., 6=Sat; 127=daily *)
1414+ duration : int; (** Duration in seconds *)
1515+}
1616+1717+type t = {
1818+ id : string;
1919+ trigger_type : int; (** 1 = weekly, 2 = once/countdown *)
2020+ enabled : bool;
2121+ channel : int;
2222+ alias : string;
2323+ create_time : int;
2424+ rule : rule;
2525+}
2626+2727+(** {1 Codecs} *)
2828+2929+let rule_codec =
3030+ Jsont.Object.map ~kind:"rule" (fun week duration -> { week; duration })
3131+ |> Jsont.Object.mem "week" Jsont.int ~enc:(fun r -> r.week)
3232+ |> Jsont.Object.mem "duration" Jsont.int ~enc:(fun r -> r.duration)
3333+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
3434+3535+let codec =
3636+ Jsont.Object.map ~kind:"trigger"
3737+ (fun id trigger_type enabled channel alias create_time rule ->
3838+ {
3939+ id;
4040+ trigger_type = Option.value ~default:0 trigger_type;
4141+ enabled = enabled = 1;
4242+ channel = Option.value ~default:0 channel;
4343+ alias = Option.value ~default:"" alias;
4444+ create_time = Option.value ~default:0 create_time;
4545+ rule;
4646+ })
4747+ |> Jsont.Object.mem "id" Jsont.string ~enc:(fun t -> t.id)
4848+ |> Jsont.Object.opt_mem "type" Jsont.int ~enc:(fun t -> Some t.trigger_type)
4949+ |> Jsont.Object.mem "enable" Jsont.int ~enc:(fun t ->
5050+ if t.enabled then 1 else 0)
5151+ |> Jsont.Object.opt_mem "channel" Jsont.int ~enc:(fun t -> Some t.channel)
5252+ |> Jsont.Object.opt_mem "alias" Jsont.string ~enc:(fun t ->
5353+ if t.alias = "" then None else Some t.alias)
5454+ |> Jsont.Object.opt_mem "createTime" Jsont.int ~enc:(fun t ->
5555+ Some t.create_time)
5656+ |> Jsont.Object.mem "rule" rule_codec ~enc:(fun t -> t.rule)
5757+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
5858+5959+type payload = { triggerx : t list }
6060+6161+let payload_codec =
6262+ Jsont.Object.map ~kind:"triggerx_payload" (fun t -> { triggerx = t })
6363+ |> Jsont.Object.mem "triggerx" (Jsont.list codec) ~enc:(fun p -> p.triggerx)
6464+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
6565+6666+type single_payload = { trigger : t }
6767+(** Single trigger payload for SET operations *)
6868+6969+let single_payload_codec =
7070+ Jsont.Object.map ~kind:"single_triggerx_payload" (fun t -> { trigger = t })
7171+ |> Jsont.Object.mem "triggerx" codec ~enc:(fun p -> p.trigger)
7272+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
7373+7474+type digest_payload = { digest : t list }
7575+(** Digest payload uses "digest" field instead of "triggerx" *)
7676+7777+let digest_payload_codec =
7878+ Jsont.Object.map ~kind:"digest_payload" (fun d -> { digest = d })
7979+ |> Jsont.Object.mem "digest" (Jsont.list codec) ~enc:(fun p -> p.digest)
8080+ |> Jsont.Object.skip_unknown |> Jsont.Object.finish
8181+8282+(** {1 Operations} *)
8383+8484+(** Get all triggers via Digest *)
8585+let get ~http ~sw ip =
8686+ let json =
8787+ P.make_request_empty ~method_:"GET" ~namespace:"Appliance.Digest.TriggerX"
8888+ in
8989+ let* resp = P.http_post ~http ~sw ip json in
9090+ match P.decode (P.response_codec digest_payload_codec) resp with
9191+ | Error _ -> Ok []
9292+ | Ok r -> Ok r.resp_payload.digest
9393+9494+(** Generate a unique trigger ID using secure random *)
9595+let make_id () =
9696+ let rand_bytes = Crypto_rng.generate 3 in
9797+ Printf.sprintf "%02x%02x%02x"
9898+ (Char.code rand_bytes.[0])
9999+ (Char.code rand_bytes.[1])
100100+ (Char.code rand_bytes.[2])
101101+102102+(** Create a countdown trigger to turn off after duration seconds *)
103103+let set_countdown ~http ~sw ip ~duration =
104104+ let trigger =
105105+ {
106106+ id = make_id ();
107107+ trigger_type = 2;
108108+ (* 2 = once/countdown *)
109109+ enabled = true;
110110+ channel = 0;
111111+ alias = "auto-off";
112112+ create_time = int_of_float (Unix.time ());
113113+ rule = { week = 128; duration };
114114+ (* MSB set for once triggers *)
115115+ }
116116+ in
117117+ let payload = { trigger } in
118118+ let req =
119119+ {
120120+ P.header =
121121+ P.make_header ~method_:"SET" ~namespace:"Appliance.Control.TriggerX";
122122+ P.payload;
123123+ }
124124+ in
125125+ let json = P.encode (P.request_codec single_payload_codec) req in
126126+ Logs.info (fun m -> m "TriggerX request: %s" json);
127127+ let* resp = P.http_post ~http ~sw ip json in
128128+ Logs.info (fun m -> m "TriggerX response: %s" resp);
129129+ P.parse_ack resp
130130+131131+(** Cancel all triggers *)
132132+let clear ~http ~sw ip =
133133+ (* Send empty trigger list to clear *)
134134+ let payload = { triggerx = [] } in
135135+ let req =
136136+ {
137137+ P.header =
138138+ P.make_header ~method_:"SET" ~namespace:"Appliance.Control.TriggerX";
139139+ P.payload;
140140+ }
141141+ in
142142+ let json = P.encode (P.request_codec payload_codec) req in
143143+ let* resp = P.http_post ~http ~sw ip json in
144144+ P.parse_ack resp
145145+146146+(** {1 Pretty Printing} *)
147147+148148+let pp_rule ppf r =
149149+ if r.week = 0 then Fmt.pf ppf "once, %ds" r.duration
150150+ else if r.week = 127 then Fmt.pf ppf "daily, %ds" r.duration
151151+ else Fmt.pf ppf "week=%d, %ds" r.week r.duration
152152+153153+let pp ppf t =
154154+ let state = if t.enabled then "enabled" else "disabled" in
155155+ Fmt.pf ppf "Trigger %s [%s]: %a (ch=%d)" t.id state pp_rule t.rule t.channel
156156+157157+(** Format duration as human readable *)
158158+let format_duration secs =
159159+ if secs < 60 then Printf.sprintf "%ds" secs
160160+ else if secs < 3600 then Printf.sprintf "%dm%ds" (secs / 60) (secs mod 60)
161161+ else Printf.sprintf "%dh%dm" (secs / 3600) (secs mod 3600 / 60)
+68
lib/triggers.mli
···11+(** Meross trigger/automation control.
22+33+ Automation rules via [Appliance.Control.TriggerX] for scheduled or
44+ countdown-based actions. Supports weekly schedules and one-time countdown
55+ timers. *)
66+77+(** {1:types Types} *)
88+99+type rule = {
1010+ week : int; (** Day bitmask: 0=Sun..6=Sat, 127=daily, 128=once *)
1111+ duration : int; (** Duration in seconds *)
1212+}
1313+(** Trigger timing rule. *)
1414+1515+type t = {
1616+ id : string; (** Unique trigger ID *)
1717+ trigger_type : int; (** 1=weekly, 2=once/countdown *)
1818+ enabled : bool; (** Whether trigger is active *)
1919+ channel : int; (** Output channel *)
2020+ alias : string; (** User-defined name *)
2121+ create_time : int; (** Creation timestamp *)
2222+ rule : rule; (** Timing configuration *)
2323+}
2424+(** Trigger configuration. *)
2525+2626+(** {1:operations Operations} *)
2727+2828+val get :
2929+ http:Protocol.http ->
3030+ sw:Eio.Switch.t ->
3131+ string ->
3232+ (t list, [> `Msg of string ]) result
3333+(** [get ~net ~sw ip] retrieves all triggers. *)
3434+3535+val set_countdown :
3636+ http:Protocol.http ->
3737+ sw:Eio.Switch.t ->
3838+ string ->
3939+ duration:int ->
4040+ (unit, [> `Msg of string ]) result
4141+(** [set_countdown ~net ~sw ip ~duration] creates countdown to turn off. *)
4242+4343+val clear :
4444+ http:Protocol.http ->
4545+ sw:Eio.Switch.t ->
4646+ string ->
4747+ (unit, [> `Msg of string ]) result
4848+(** [clear ~net ~sw ip] removes all triggers. *)
4949+5050+(** {1:pp Pretty printing} *)
5151+5252+val format_duration : int -> string
5353+val pp_rule : rule Fmt.t
5454+val pp : t Fmt.t
5555+5656+(** {1:codecs Codecs} *)
5757+5858+val rule_codec : rule Jsont.t
5959+val codec : t Jsont.t
6060+6161+type payload = { triggerx : t list }
6262+6363+val payload_codec : payload Jsont.t
6464+6565+type digest_payload = { digest : t list }
6666+6767+val digest_payload_codec : digest_payload Jsont.t
6868+val make_id : unit -> string