From db56cb40ed6189a70273c6670e7a595fc111dbe3 Mon Sep 17 00:00:00 2001 From: SongPaul Date: Wed, 25 Mar 2026 14:49:54 +0900 Subject: [PATCH] Add Timemore Dot scale support Add BLE integration for Timemore Dot scale (Service 0xFFF0). Protocol reverse-engineered from HCI snoop log of official Timemore app. Core changes: - machine.tcl: UUID constants (suuid/cuuid for FFF0/FFF1/FFF2) - bluetooth.tcl: scan detection, connection handler, dispatch entries (timer start/stop/reset, weight notifications), notification routing, and 12 procedures (init sequence, polling, tare, timer, parse) - device_scale.tcl: tare dispatch and weight period (0.50s) Also includes a standalone plugin version (plugins/timemore_dot/) that can work without core changes via proc rename wrapping and dynamic de1_ble_handler patching. Co-Authored-By: Claude Opus 4.6 (1M context) --- de1plus/bluetooth.tcl | 237 ++++++++++++++ de1plus/device_scale.tcl | 2 + de1plus/machine.tcl | 3 + de1plus/plugins/timemore_dot/plugin.tcl | 411 ++++++++++++++++++++++++ 4 files changed, 653 insertions(+) create mode 100644 de1plus/plugins/timemore_dot/plugin.tcl diff --git a/de1plus/bluetooth.tcl b/de1plus/bluetooth.tcl index efe5410d..8e22cf34 100755 --- a/de1plus/bluetooth.tcl +++ b/de1plus/bluetooth.tcl @@ -69,6 +69,8 @@ proc scale_timer_start {} { after 500 solo_barista_start_timer } elseif {$::settings(scale_type) == "difluid"} { difluid_start_timer + } elseif {$::settings(scale_type) == "timemore_dot"} { + timemore_dot_start_timer } } @@ -96,6 +98,8 @@ proc scale_timer_stop {} { } elseif {$::settings(scale_type) == "difluid"} { difluid_stop_timer difluid_reset_timer + } elseif {$::settings(scale_type) == "timemore_dot"} { + timemore_dot_stop_timer } } @@ -124,6 +128,8 @@ proc scale_timer_reset {} { after 500 solo_barista_reset_timer } elseif {$::settings(scale_type) == "difluid"} { # moved the reset to the stop function because stop is more like a pause + } elseif {$::settings(scale_type) == "timemore_dot"} { + timemore_dot_reset_timer } } @@ -162,6 +168,8 @@ proc scale_enable_weight_notifications {} { difluid_enable_weight_notifications } elseif {$::settings(scale_type) == "varia_aku"} { varia_aku_enable_weight_notifications + } elseif {$::settings(scale_type) == "timemore_dot"} { + timemore_dot_enable_weight_notifications } } @@ -1620,6 +1628,226 @@ proc varia_aku_parse_response { value } { } +#### Timemore Dot Scale +# BLE Protocol: Service 0xFFF0, Notify 0xFFF1, Write 0xFFF2 +# Packet format: A5 5A [type] ... where type 0x01 = weight data +# Weight: signed 16-bit big-endian at bytes[8:9], divide by 10.0 for grams +# Requires init sequence after connection and periodic polling (~10s) + +proc timemore_dot_enable_weight_notifications {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + ::bt::msg -DEBUG "Timemore Dot not connected, cannot enable weight notifications" + return + } + + userdata_append "SCALE: enable Timemore Dot weight notifications" [list ble enable $::de1(scale_device_handle) $::de1(suuid_timemore_dot) $::sinstance($::de1(suuid_timemore_dot)) $::de1(cuuid_timemore_dot_status) $::cinstance($::de1(cuuid_timemore_dot_status))] 1 +} + +proc timemore_dot_send_init_sequence {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + ::bt::msg -DEBUG "Timemore Dot not connected, cannot send init sequence" + return + } + + ::bt::msg -NOTICE "Timemore Dot: sending init sequence" + + # Init commands extracted from HCI snoop log of official Timemore app + # These are sent twice with delays, matching official app behavior + set init_cmds [list \ + [binary decode hex "A55A021300000000"] \ + [binary decode hex "A55A020800000000"] \ + [binary decode hex "A55A020500000000"] \ + [binary decode hex "A55A020200000000"] \ + [binary decode hex "A55A020600000000"] \ + [binary decode hex "A55A020C00000000"] \ + ] + + # Send init commands (first pass) + set delay 100 + foreach cmd $init_cmds { + after $delay [list timemore_dot_write_cmd $cmd] + incr delay 150 + } + + # Send init commands (second pass, matching official app) + incr delay 300 + foreach cmd $init_cmds { + after $delay [list timemore_dot_write_cmd $cmd] + incr delay 150 + } + + # Send initial poll after init + incr delay 300 + after $delay timemore_dot_send_poll + + # Setup periodic polling every 10 seconds to keep connection alive + after [expr {$delay + 500}] timemore_dot_start_polling + + ::bt::msg -NOTICE "Timemore Dot: init sequence scheduled" +} + +proc timemore_dot_write_cmd {cmd} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + return + } + + userdata_append "SCALE: Timemore Dot write cmd" [list ble write $::de1(scale_device_handle) $::de1(suuid_timemore_dot) $::sinstance($::de1(suuid_timemore_dot)) $::de1(cuuid_timemore_dot_cmd) $::cinstance($::de1(cuuid_timemore_dot_cmd)) $cmd] 0 +} + +proc timemore_dot_send_poll {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + return + } + + # Poll command: query state + set poll1 [binary decode hex "A55A020800000000"] + set poll2 [binary decode hex "A55A030800020100000025"] + + timemore_dot_write_cmd $poll1 + after 100 [list timemore_dot_write_cmd $poll2] +} + +proc timemore_dot_start_polling {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + # Cancel any existing polling + catch {after cancel timemore_dot_poll_tick} + + # Schedule recurring poll every 10 seconds + timemore_dot_poll_tick +} + +proc timemore_dot_poll_tick {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + timemore_dot_send_poll + after 10000 timemore_dot_poll_tick +} + +proc timemore_dot_tare {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + error "Timemore Dot not connected, cannot send tare cmd" + return + } + + ::bt::msg -NOTICE "Timemore Dot: sending tare command" + + # Tare command: A5 5A 03 0D 00 02 00 00 00 71 (10 bytes, confirmed from HCI snoop log) + set tare [binary decode hex "A55A030D000200000071"] + + # Send poll first to ensure scale is responsive, then tare + timemore_dot_send_poll + after 200 [list timemore_dot_write_cmd $tare] +} + +proc timemore_dot_start_timer {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + ::bt::msg -DEBUG "Timemore Dot not connected, cannot start timer" + return + } + + # Timer start: A5 5A 03 02 00 01 01 00 20 + set timer_start [binary decode hex "A55A03020001010020"] + + timemore_dot_write_cmd $timer_start +} + +proc timemore_dot_stop_timer {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + ::bt::msg -DEBUG "Timemore Dot not connected, cannot stop timer" + return + } + + # Timer stop: A5 5A 03 02 00 01 02 FF D0 + set timer_stop [binary decode hex "A55A030200010200FFD0"] + + timemore_dot_write_cmd $timer_stop +} + +proc timemore_dot_reset_timer {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + ::bt::msg -DEBUG "Timemore Dot not connected, cannot reset timer" + return + } + + # Timer reset: A5 5A 03 02 00 01 03 FF 81 + set timer_reset [binary decode hex "A55A030200010300FF81"] + + timemore_dot_write_cmd $timer_reset +} + +proc timemore_dot_parse_response { value } { + # Packet format: A5 5A [type] [stat] .. .. .. .. [WH] [WL] [t0] [t1] [t2] [t3] ... + # type 0x01 = weight data, weight at bytes 8-9 as signed 16-bit big-endian, /10.0 for grams + # type 0x02 = command response, type 0x03 = ACK (both ignored for weight processing) + + if {[string length $value] < 10} { + return + } + + binary scan $value cucucucucucucucucucu b0 b1 pktType b3 b4 b5 b6 b7 wH wL + + # Validate header: must start with A5 5A + if {$b0 != 0xA5 || $b1 != 0x5A} { + return + } + + # Only process weight packets (type 0x01) + if {$pktType != 0x01} { + return + } + + # Combine bytes 8-9 as signed 16-bit big-endian + set raw_weight [expr {($wH << 8) | $wL}] + + # Handle signed 16-bit (two's complement) + if {$raw_weight > 32767} { + set raw_weight [expr {$raw_weight - 65536}] + } + + # Convert to grams (raw value is in 0.1g units) + set weight [expr {$raw_weight / 10.0}] + + ::device::scale::process_weight_update $weight +} + + proc close_all_ble_and_exit {} { ::bt::msg -NOTICE close_all_ble_and_exit @@ -2231,6 +2459,8 @@ proc de1_ble_handler { event data } { append_to_peripheral_list $address $name "ble" "scale" "smartchef" } elseif {[string first "Microbalance" $name] == 0 } { append_to_peripheral_list $address $name "ble" "scale" "difluid" + } elseif {[string first "TIMEMORE" $name] == 0 } { + append_to_peripheral_list $address $name "ble" "scale" "timemore_dot" } else { return } @@ -2410,6 +2640,10 @@ proc de1_ble_handler { event data } { } elseif {$::settings(scale_type) == "varia_aku"} { append_to_peripheral_list $address $::settings(scale_bluetooth_name) "ble" "scale" "varia_aku" after 200 varia_aku_enable_weight_notifications + } elseif {$::settings(scale_type) == "timemore_dot"} { + append_to_peripheral_list $address $::settings(scale_bluetooth_name) "ble" "scale" "timemore_dot" + after 200 timemore_dot_enable_weight_notifications + after 500 timemore_dot_send_init_sequence } else { error "unknown scale: '$::settings(scale_type)'" } @@ -2793,6 +3027,9 @@ proc de1_ble_handler { event data } { } elseif {$cuuid eq $::de1(cuuid_smartchef_status) && $::settings(scale_type) == "smartchef"} { # smartchef scale smartchef_parse_response $value + } elseif {$cuuid eq $::de1(cuuid_timemore_dot_status) && $::settings(scale_type) == "timemore_dot"} { + # timemore dot scale + timemore_dot_parse_response $value } elseif {$cuuid eq $::de1(cuuid_difluid)} { # difluid scale difluid_parse_response $value diff --git a/de1plus/device_scale.tcl b/de1plus/device_scale.tcl index 10541423..a47697be 100644 --- a/de1plus/device_scale.tcl +++ b/de1plus/device_scale.tcl @@ -176,6 +176,7 @@ namespace eval ::device::scale { bookoo { expr { 0.50 } } atomheart_eclair { expr { 0.50 } } acaiascale { expr { 0.69 } } + timemore_dot { expr { 0.50 } } default { expr { 0.38 } } }] @@ -368,6 +369,7 @@ namespace eval ::device::scale { difluid {difluid_tare } varia_aku { varia_aku_tare } + timemore_dot { timemore_dot_tare } } set ::device::scale::_tare_last_requested [clock milliseconds] diff --git a/de1plus/machine.tcl b/de1plus/machine.tcl index 3748ee7e..0f695ab5 100644 --- a/de1plus/machine.tcl +++ b/de1plus/machine.tcl @@ -108,6 +108,9 @@ array set ::de1 { suuid_varia_aku "0000FFF0-0000-1000-8000-00805F9B34FB" cuuid_varia_aku "0000FFF1-0000-1000-8000-00805F9B34FB" cuuid_varia_aku_cmd "0000FFF2-0000-1000-8000-00805F9B34FB" + suuid_timemore_dot "0000FFF0-0000-1000-8000-00805F9B34FB" + cuuid_timemore_dot_status "0000FFF1-0000-1000-8000-00805F9B34FB" + cuuid_timemore_dot_cmd "0000FFF2-0000-1000-8000-00805F9B34FB" cinstance 0 diff --git a/de1plus/plugins/timemore_dot/plugin.tcl b/de1plus/plugins/timemore_dot/plugin.tcl new file mode 100644 index 00000000..a6455f80 --- /dev/null +++ b/de1plus/plugins/timemore_dot/plugin.tcl @@ -0,0 +1,411 @@ +set plugin_name "timemore_dot" + +namespace eval ::plugins::${plugin_name} { + + variable author "DE1 Community" + variable contact "" + variable version 1.0 + variable description "Timemore Dot scale support via BLE (Service 0xFFF0)" + variable name "Timemore Dot Scale" + + # BLE protocol constants (extracted from HCI snoop log of official Timemore app) + variable SVC_UUID "0000FFF0-0000-1000-8000-00805F9B34FB" + variable NTF_UUID "0000FFF1-0000-1000-8000-00805F9B34FB" + variable WRT_UUID "0000FFF2-0000-1000-8000-00805F9B34FB" + + proc preload {} { + } + + proc main {} { + + # ────────────────────────────────────────── + # 1. Register BLE UUID constants into ::de1 array + # ────────────────────────────────────────── + set ::de1(suuid_timemore_dot) $::plugins::timemore_dot::SVC_UUID + set ::de1(cuuid_timemore_dot_status) $::plugins::timemore_dot::NTF_UUID + set ::de1(cuuid_timemore_dot_cmd) $::plugins::timemore_dot::WRT_UUID + + # ────────────────────────────────────────── + # 2. Wrap generic scale dispatch functions + # (rename originals, insert timemore_dot branch) + # ────────────────────────────────────────── + wrap_scale_dispatch + + # ────────────────────────────────────────── + # 3. Patch de1_ble_handler for scan/connect/notify + # (dynamic proc body rewrite at load time) + # ────────────────────────────────────────── + patch_ble_handler + + msg -NOTICE "Timemore Dot scale plugin loaded (v$::plugins::timemore_dot::version)" + } + + # ══════════════════════════════════════════════════════ + # Dispatch function wrapping via rename + # ══════════════════════════════════════════════════════ + + proc wrap_scale_dispatch {} { + + # -- scale_timer_start -- + rename ::scale_timer_start ::plugins::timemore_dot::_orig_scale_timer_start + proc ::scale_timer_start {} { + if {$::settings(scale_type) == "timemore_dot"} { + ::plugins::timemore_dot::start_timer + } else { + ::plugins::timemore_dot::_orig_scale_timer_start + } + } + + # -- scale_timer_stop -- + rename ::scale_timer_stop ::plugins::timemore_dot::_orig_scale_timer_stop + proc ::scale_timer_stop {} { + if {$::settings(scale_type) == "timemore_dot"} { + ::plugins::timemore_dot::stop_timer + } else { + ::plugins::timemore_dot::_orig_scale_timer_stop + } + } + + # -- scale_timer_reset -- + rename ::scale_timer_reset ::plugins::timemore_dot::_orig_scale_timer_reset + proc ::scale_timer_reset {} { + if {$::settings(scale_type) == "timemore_dot"} { + ::plugins::timemore_dot::reset_timer + } else { + ::plugins::timemore_dot::_orig_scale_timer_reset + } + } + + # -- scale_enable_weight_notifications -- + rename ::scale_enable_weight_notifications ::plugins::timemore_dot::_orig_scale_enable_weight_notifications + proc ::scale_enable_weight_notifications {} { + if {$::settings(scale_type) == "timemore_dot"} { + ::plugins::timemore_dot::enable_weight_notifications + } else { + ::plugins::timemore_dot::_orig_scale_enable_weight_notifications + } + } + + # -- ::device::scale::tare -- + rename ::device::scale::tare ::plugins::timemore_dot::_orig_device_scale_tare + proc ::device::scale::tare {args} { + if {$::settings(scale_type) == "timemore_dot"} { + ::plugins::timemore_dot::tare + } else { + ::plugins::timemore_dot::_orig_device_scale_tare {*}$args + } + } + + msg -NOTICE "Timemore Dot: wrapped 5 scale dispatch functions" + } + + # ══════════════════════════════════════════════════════ + # BLE handler patching + # Dynamically rewrites de1_ble_handler proc body to add: + # - Scan detection for "TIMEMORE" BLE name prefix + # - Connection init handler for timemore_dot scale_type + # - Notification routing for timemore_dot characteristic + # ══════════════════════════════════════════════════════ + + proc patch_ble_handler {} { + + set body [info body de1_ble_handler] + + # Preserve full argument specification including defaults + set args_spec {} + foreach arg [info args de1_ble_handler] { + if {[info default de1_ble_handler $arg default_val]} { + lappend args_spec [list $arg $default_val] + } else { + lappend args_spec $arg + } + } + + set patch_count 0 + + # Tab helpers (must match bluetooth.tcl indentation exactly) + set t4 "\t\t\t\t" + set t5 "\t\t\t\t\t" + set t6 "\t\t\t\t\t\t" + set t7 "\t\t\t\t\t\t\t" + + # ── Patch 1: Scan detection ── + # Inject TIMEMORE name check before "} else { return }" in the scan section + # Anchor: 5-tab difluid line + newline + 4-tab "} else {" + set scan_old "${t5}append_to_peripheral_list \$address \$name \"ble\" \"scale\" \"difluid\"\n${t4}} else {" + set scan_new "${t5}append_to_peripheral_list \$address \$name \"ble\" \"scale\" \"difluid\"\n${t4}} elseif {\[string first \"TIMEMORE\" \$name\] == 0 } {\n${t5}append_to_peripheral_list \$address \$name \"ble\" \"scale\" \"timemore_dot\"\n${t4}} else {" + + set new_body [string map [list $scan_old $scan_new] $body] + if {$new_body ne $body} { + set body $new_body + incr patch_count + msg -NOTICE "Timemore Dot: scan detection patch applied" + } else { + msg -ERROR "Timemore Dot: scan detection patch FAILED - anchor not found" + } + + # ── Patch 2: Connection handler ── + # Inject timemore_dot init branch before "error unknown scale" + # Anchor: 7-tab varia_aku line + newline + 6-tab "} else {" + newline + 7-tab error + set conn_old "${t7}after 200 varia_aku_enable_weight_notifications\n${t6}} else {\n${t7}error \"unknown scale: '\$::settings(scale_type)'\"" + set conn_new "${t7}after 200 varia_aku_enable_weight_notifications\n${t6}} elseif {\$::settings(scale_type) == \"timemore_dot\"} {\n${t7}append_to_peripheral_list \$address \$::settings(scale_bluetooth_name) \"ble\" \"scale\" \"timemore_dot\"\n${t7}after 200 ::plugins::timemore_dot::enable_weight_notifications\n${t7}after 500 ::plugins::timemore_dot::send_init_sequence\n${t6}} else {\n${t7}error \"unknown scale: '\$::settings(scale_type)'\"" + + set new_body [string map [list $conn_old $conn_new] $body] + if {$new_body ne $body} { + set body $new_body + incr patch_count + msg -NOTICE "Timemore Dot: connection handler patch applied" + } else { + msg -ERROR "Timemore Dot: connection handler patch FAILED - anchor not found" + } + + # ── Patch 3: Notification handler ── + # Inject timemore_dot UUID check before difluid handler + # Since FFF1 UUID is shared, also checks scale_type + # Anchor: 7-tab smartchef line + newline + 7-tab "} elseif {cuuid_difluid}" + set ntf_old "${t7}smartchef_parse_response \$value\n${t7}} elseif {\$cuuid eq \$::de1(cuuid_difluid)} {" + set ntf_new "${t7}smartchef_parse_response \$value\n${t6}} elseif {\$cuuid eq \$::de1(cuuid_timemore_dot_status) && \$::settings(scale_type) == \"timemore_dot\"} {\n${t7}::plugins::timemore_dot::parse_response \$value\n${t7}} elseif {\$cuuid eq \$::de1(cuuid_difluid)} {" + + set new_body [string map [list $ntf_old $ntf_new] $body] + if {$new_body ne $body} { + set body $new_body + incr patch_count + msg -NOTICE "Timemore Dot: notification handler patch applied" + } else { + msg -ERROR "Timemore Dot: notification handler patch FAILED - anchor not found" + } + + # Rebuild the proc with patched body + if {$patch_count > 0} { + proc ::de1_ble_handler $args_spec $body + msg -NOTICE "Timemore Dot: patched de1_ble_handler ($patch_count/3 patches applied)" + } else { + msg -ERROR "Timemore Dot: NO patches applied - BLE handler unchanged" + } + } + + # ══════════════════════════════════════════════════════ + # BLE communication helper + # ══════════════════════════════════════════════════════ + + proc write_cmd {cmd} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + return + } + userdata_append "SCALE: Timemore Dot cmd" [list ble write \ + $::de1(scale_device_handle) \ + $::de1(suuid_timemore_dot) $::sinstance($::de1(suuid_timemore_dot)) \ + $::de1(cuuid_timemore_dot_cmd) $::cinstance($::de1(cuuid_timemore_dot_cmd)) \ + $cmd] 0 + } + + # ══════════════════════════════════════════════════════ + # Weight notification enable + # ══════════════════════════════════════════════════════ + + proc enable_weight_notifications {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + msg -DEBUG "Timemore Dot not connected, cannot enable weight notifications" + return + } + userdata_append "SCALE: enable Timemore Dot weight notifications" [list ble enable \ + $::de1(scale_device_handle) \ + $::de1(suuid_timemore_dot) $::sinstance($::de1(suuid_timemore_dot)) \ + $::de1(cuuid_timemore_dot_status) $::cinstance($::de1(cuuid_timemore_dot_status))] 1 + } + + # ══════════════════════════════════════════════════════ + # Init sequence (from HCI snoop log of official Timemore app) + # Sent twice with delays, then state poll, then periodic polling + # ══════════════════════════════════════════════════════ + + proc send_init_sequence {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + msg -DEBUG "Timemore Dot not connected, cannot send init sequence" + return + } + + msg -NOTICE "Timemore Dot: sending init sequence" + + set init_cmds [list \ + [binary decode hex "A55A021300000000"] \ + [binary decode hex "A55A020800000000"] \ + [binary decode hex "A55A020500000000"] \ + [binary decode hex "A55A020200000000"] \ + [binary decode hex "A55A020600000000"] \ + [binary decode hex "A55A020C00000000"] \ + ] + + # First pass + set delay 100 + foreach cmd $init_cmds { + after $delay [list ::plugins::timemore_dot::write_cmd $cmd] + incr delay 150 + } + + # Second pass (official app sends init commands twice) + incr delay 300 + foreach cmd $init_cmds { + after $delay [list ::plugins::timemore_dot::write_cmd $cmd] + incr delay 150 + } + + # Initial state poll + incr delay 300 + after $delay ::plugins::timemore_dot::send_poll + + # Start periodic keepalive polling (10 second interval) + after [expr {$delay + 500}] ::plugins::timemore_dot::start_polling + + msg -NOTICE "Timemore Dot: init sequence scheduled" + } + + # ══════════════════════════════════════════════════════ + # Periodic polling / keepalive + # Official app polls ~15 sec; we poll every 10 sec for reliability + # ══════════════════════════════════════════════════════ + + proc send_poll {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + return + } + set poll1 [binary decode hex "A55A020800000000"] + set poll2 [binary decode hex "A55A030800020100000025"] + + write_cmd $poll1 + after 100 [list ::plugins::timemore_dot::write_cmd $poll2] + } + + proc start_polling {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + catch {after cancel ::plugins::timemore_dot::poll_tick} + poll_tick + } + + proc poll_tick {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + send_poll + after 10000 ::plugins::timemore_dot::poll_tick + } + + # ══════════════════════════════════════════════════════ + # Tare command + # A5 5A 03 0D 00 02 00 00 00 71 (10 bytes) + # Confirmed from HCI snoop log: write #20689, #21285 + # Sends poll first to ensure scale is responsive + # ══════════════════════════════════════════════════════ + + proc tare {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + msg -ERROR "Timemore Dot not connected, cannot send tare cmd" + return + } + + msg -NOTICE "Timemore Dot: sending tare command" + + set tare_cmd [binary decode hex "A55A030D000200000071"] + + send_poll + after 200 [list ::plugins::timemore_dot::write_cmd $tare_cmd] + } + + # ══════════════════════════════════════════════════════ + # Timer control + # ══════════════════════════════════════════════════════ + + proc start_timer {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + msg -DEBUG "Timemore Dot not connected, cannot start timer" + return + } + # A5 5A 03 02 00 01 01 00 20 + set cmd [binary decode hex "A55A03020001010020"] + write_cmd $cmd + } + + proc stop_timer {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + msg -DEBUG "Timemore Dot not connected, cannot stop timer" + return + } + # A5 5A 03 02 00 01 02 FF D0 + set cmd [binary decode hex "A55A030200010200FFD0"] + write_cmd $cmd + } + + proc reset_timer {} { + if {$::de1(scale_device_handle) == 0 || $::settings(scale_type) != "timemore_dot"} { + return + } + if {[ifexists ::sinstance($::de1(suuid_timemore_dot))] == ""} { + msg -DEBUG "Timemore Dot not connected, cannot reset timer" + return + } + # A5 5A 03 02 00 01 03 FF 81 + set cmd [binary decode hex "A55A030200010300FF81"] + write_cmd $cmd + } + + # ══════════════════════════════════════════════════════ + # Packet parsing + # Notify packet: A5 5A [type] [stat] .. .. .. .. [WH] [WL] ... + # type 0x01 = weight data (other types: 0x02=response, 0x03=ACK) + # Weight: bytes[8:9], signed 16-bit big-endian, /10.0 for grams + # ══════════════════════════════════════════════════════ + + proc parse_response { value } { + + if {[string length $value] < 10} { + return + } + + binary scan $value cucucucucucucucucucu b0 b1 pktType b3 b4 b5 b6 b7 wH wL + + # Validate A5 5A header + if {$b0 != 0xA5 || $b1 != 0x5A} { + return + } + + # Only process weight packets (type 0x01) + if {$pktType != 0x01} { + return + } + + # Combine bytes 8-9 as signed 16-bit big-endian + set raw_weight [expr {($wH << 8) | $wL}] + if {$raw_weight > 32767} { + set raw_weight [expr {$raw_weight - 65536}] + } + + # Convert to grams (raw is in 0.1g units) + set weight [expr {$raw_weight / 10.0}] + + ::device::scale::process_weight_update $weight + } + +}