Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions pkg/jiecang/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,21 @@ package jiecang
// Common functions used to decode messages from/to the controller
// checking validity etc.

// Function that checks whether data received from DataIn are valid.
// They should start with "f2f2", end with "7e" and te previous to last byte (which is a checksum) should not fail.
// isValidData validates data received from the controller's DataOut characteristic.
//
// The Jiecang protocol uses the following message format:
// - Bytes 0-1: Preamble (0xf2, 0xf2)
// - Byte 2: Message type/command
// - Byte 3: Data length (number of data bytes)
// - Bytes 4..(4+dataLen-1): Data payload
// - Byte (len-2): Checksum (sum of type, length, and data bytes, mod 256)
// - Byte (len-1): Terminator (0x7e)
//
// Parameters:
// - buf: Raw response buffer from the controller
//
// Returns true if the message has valid preamble, terminator, and checksum;
// false otherwise.
func isValidData(buf []byte) bool {
// Check length first to prevent index out of bounds
if len(buf) < 6 {
Expand Down
91 changes: 64 additions & 27 deletions pkg/jiecang/height.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,41 @@ import (
"time"
)

// Contains functions for height movement only.
// This file contains functions for controlling desk height.

// Moves the desk up
// Up sends a command to move the desk upward by one increment.
// Equivalent to pressing the up button on the desk control panel once.
// Returns an error if the command transmission fails.
func (j *Jiecang) Up() error {
return j.sendCommand(commands["up"])
}

// Moves the desk down
// Down sends a command to move the desk downward by one increment.
// Equivalent to pressing the down button on the desk control panel once.
// Returns an error if the command transmission fails.
func (j *Jiecang) Down() error {
return j.sendCommand(commands["down"])
}

// Moves the desk to the designated height. Height should be within the limits
// of the desk. The operation can be cancelled via the provided context.
// GoToHeight moves the desk to the specified height in centimeters.
//
// The function validates that the target height is within the desk's configured
// limits (LowestHeight and HighestHeight), then sends movement commands and polls
// the current height until the target is reached or the context is cancelled.
//
// Parameters:
// - ctx: Context for timeout and cancellation. The operation can be interrupted
// by cancelling the context (e.g., with Ctrl+C or timeout).
// - height: Target height in centimeters. Must be between LowestHeight and
// HighestHeight (typically 60-120cm).
//
// Returns an error if:
// - The target height is out of range
// - Command transmission fails
// - The context is cancelled (returns ctx.Err())
//
// The function polls the height every 200ms and sends a stop command when
// the target is reached or the operation is cancelled.
func (j *Jiecang) GoToHeight(ctx context.Context, height uint8) error {
//Ensure that height is within low and high limits of the desk.
if height > j.HighestHeight || height < j.LowestHeight {
Expand Down Expand Up @@ -68,41 +89,45 @@ func (j *Jiecang) GoToHeight(ctx context.Context, height uint8) error {
return nil
}

// FetchHeight requests the desk's saved memory preset heights from the controller.
// The command is sent twice as required by the protocol for reliability.
//
// The response contains the height values for all memory presets (1-4).
// The values are processed asynchronously by the characteristicReceiver callback
// and stored in the presets map.
//
// Returns an error if the command transmission fails.
func (j *Jiecang) FetchHeight() error {
//Implements fetch_height command

// Returns
/*
f2f2 25 02 044e 79 7e //044e =1100 in decimal. Memory 1
f2f2 25 02 044e 79 7e
f2f2 26 02 030c 37 7e //030c = 780 in dec. Memory 2
f2f2 26 02 030c 37 7e
f2f2 27 02 0372 9e 7e //0372 = 882 in dec. Memory 3
f2f2 27 02 0372 9e 7e
f2f2 28 02 0000 2a 7e // Memory 4?
f2f2 28 02 0000 2a 7e

*/
if err := j.sendCommand(commands["fetch_height"]); err != nil {
return err
}
return j.sendCommand(commands["fetch_height"])
}

// FetchHeightRange requests the desk's minimum and maximum height limits.
// The command is sent twice as required by the protocol for reliability.
//
// The response contains the highest and lowest height values that the desk
// can physically reach. The values are processed asynchronously by the
// characteristicReceiver callback and stored in HighestHeight and LowestHeight.
//
// Returns an error if the command transmission fails.
func (j *Jiecang) FetchHeightRange() error {
// Implements fetch_height_range command

//Retuns
/*
f2f2 07 04 04f8 026c 75 7e
LEN HGH LOW CSUM
*/
if err := j.sendCommand(commands["fetch_height_range"]); err != nil {
return err
}
return j.sendCommand(commands["fetch_height_range"])
}

// readHeight decodes the current height value from the controller response.
// The function extracts the height from bytes 4-5 of the response buffer and converts
// it from millimeters (protocol format) to centimeters (application format).
//
// Parameters:
// - buf: Response buffer from the controller. Expected format:
// [0xf2, 0xf2, type, 0x03, highByte, lowByte, ..., checksum, 0x7e]
//
// Returns the current height in centimeters, or 0 if the response type is invalid.
func readHeight(buf []byte) uint8 {
if buf[3] == 0x03 {
height := int(buf[4])*256 + int(buf[5])
Expand All @@ -112,7 +137,19 @@ func readHeight(buf []byte) uint8 {
return 0
}

// Handles the response of FetchHeightRange command from the controller
// readHeightRange decodes the height range limits from the controller response.
// This handles the response from the FetchHeightRange command, extracting both
// the maximum and minimum height limits that the desk can physically reach.
//
// Parameters:
// - buf: Response buffer from the controller. Expected format:
// [0xf2, 0xf2, type, 0x04, highestHighByte, highestLowByte,
// lowestHighByte, lowestLowByte, ..., checksum, 0x7e]
//
// Returns:
// - highestHeight: Maximum reachable height in centimeters
// - lowestHeight: Minimum reachable height in centimeters
// - (0, 0) if the response type is invalid
func readHeightRange(buf []byte) (uint8, uint8) {
if buf[3] == 0x04 {
highestHeight := int(buf[4])*256 + int(buf[5])
Expand Down
97 changes: 81 additions & 16 deletions pkg/jiecang/jiecang.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
// Package jiecang provides control functionality for Jiecang standing desk controllers
// via Bluetooth Low Energy (BLE) communication using the Lierda LSD4BT-E95ASTD001 module.
//
// The package implements the Jiecang UART protocol over BLE, supporting:
// - Height control (up/down, go to specific height)
// - Memory presets (save and recall positions)
// - Height range queries
// - Desk settings (memory mode, anti-collision sensitivity)
//
// Example usage:
//
// ctx := context.Background()
// adapter := bluetooth.DefaultAdapter
// adapter.Enable()
//
// address := bluetooth.MustParseMAC("AA:BB:CC:DD:EE:FF")
// desk, err := jiecang.Init(adapter, bluetooth.Address{MACAddress: bluetooth.MACAddress{MAC: address}})
// if err != nil {
// log.Fatal(err)
// }
// defer desk.Disconnect()
//
// // Move desk to 100cm height with timeout
// ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
// defer cancel()
// desk.GoToHeight(ctx, 100)
package jiecang

import (
Expand Down Expand Up @@ -31,29 +57,61 @@ const (
BLECharDataOutId = 0xFE62
)

// Jiecang represents a connection to a Jiecang desk controller.
// It manages BLE communication and maintains the current state of the desk.
//
// The struct uses a mutex to protect concurrent access to shared state,
// allowing safe use from multiple goroutines. Height values are stored
// in centimeters for convenience.
type Jiecang struct {
device bluetooth.Device
dataIn bluetooth.DeviceCharacteristic
dataOut bluetooth.DeviceCharacteristic
device bluetooth.Device // BLE device connection
dataIn bluetooth.DeviceCharacteristic // Write characteristic for sending commands
dataOut bluetooth.DeviceCharacteristic // Read characteristic for receiving responses

//currentHeight in cm
currentHeight uint8
mu sync.RWMutex
currentHeight uint8 // Current height in centimeters
mu sync.RWMutex // Protects concurrent access to shared state

//Memory presets (memory 1-4)
presets map[string]uint8
presets map[string]uint8 // Memory presets (memory1-4) in centimeters

//Highest and lowest height of desk
LowestHeight uint8
// LowestHeight is the minimum height limit of the desk in centimeters.
// Set during initialization from the controller.
LowestHeight uint8

// HighestHeight is the maximum height limit of the desk in centimeters.
// Set during initialization from the controller.
HighestHeight uint8

//Desk settings
// Memory mode (One-touch mode vs constant touch)
// MemoryConstantTouchMode indicates if memory mode requires constant touch.
// false = one-touch mode, true = constant touch mode.
MemoryConstantTouchMode bool
// Anti-collision sensitivity (1 High, 2 Medium, 3 Low)
AntiCollisionSensitivity uint8 //TODO: Use iota

// AntiCollisionSensitivity indicates the anti-collision sensitivity level.
// Valid values: 1 = High, 2 = Medium, 3 = Low
AntiCollisionSensitivity uint8
}

// Init initializes a connection to a Jiecang desk controller via Bluetooth.
//
// The function performs the following steps:
// 1. Connects to the BLE device at the specified address
// 2. Discovers the Jiecang service (0xFE60)
// 3. Discovers data input/output characteristics (0xFE61, 0xFE62)
// 4. Enables notifications for receiving responses
// 5. Queries the desk for current height, height range, and memory presets
//
// Returns an error if any step fails (connection, service discovery,
// characteristic discovery, or initial queries).
//
// Example:
//
// adapter := bluetooth.DefaultAdapter
// adapter.Enable()
// address := bluetooth.MustParseMAC("AA:BB:CC:DD:EE:FF")
// desk, err := jiecang.Init(adapter, bluetooth.Address{MACAddress: bluetooth.MACAddress{MAC: address}})
// if err != nil {
// log.Fatal(err)
// }
// defer desk.Disconnect()
func Init(a *bluetooth.Adapter, addr bluetooth.Address) (*Jiecang, error) {
j := new(Jiecang)

Expand Down Expand Up @@ -138,6 +196,9 @@ func Init(a *bluetooth.Adapter, addr bluetooth.Address) (*Jiecang, error) {
return j, nil
}

// Disconnect closes the BLE connection to the desk controller.
// Should be called when done using the controller to free resources.
// Safe to call even if the connection is already closed.
func (j *Jiecang) Disconnect() error {
return j.device.Disconnect()
}
Expand All @@ -151,16 +212,20 @@ func (j *Jiecang) sendCommand(buf []byte) error {
return nil
}

// FetchStandTime requests the desk's standing time statistics from the controller.
// The command is sent twice as required by the protocol for reliability.
// Returns an error if the command transmission fails.
func (j *Jiecang) FetchStandTime() error {
// Implements fetch_stand_time command
if err := j.sendCommand(commands["fetch_stand_time"]); err != nil {
return err
}
return j.sendCommand(commands["fetch_stand_time"])
}

// FetchAllTime requests the desk's total usage time statistics from the controller.
// The command is sent twice as required by the protocol for reliability.
// Returns an error if the command transmission fails.
func (j *Jiecang) FetchAllTime() error {
// Implements fetch_all_time command
if err := j.sendCommand(commands["fetch_all_time"]); err != nil {
return err
}
Expand Down
Loading
Loading