Compare commits

..

6 Commits

52 changed files with 850 additions and 2 deletions

View File

@ -8,7 +8,7 @@ Networked bedside clock utilising an ESP32-SOLO-1.
- [x] 7 segment display
- [x] WiFi networking
- [x] Console for configuration
- [ ] TCP API for configuration
- [ ] TCP control protocol
- [x] SNTP synchronisation
- [x] Snooze and dismiss buttons
- [x] Multiple alarms
@ -20,3 +20,83 @@ Networked bedside clock utilising an ESP32-SOLO-1.
The firmware is a standard ESP-IDF v5.0 project; refer to [Espressif's
ESP-IDF
docs](https://docs.espressif.com/projects/esp-idf/en/release-v5.0/esp32/index.html).
## TCP Control Protocol
Simple, binary, command / reponse protocol. Port number 3498.
Capabilities:
- Get firmware version
- Set time
- List, add and remove alarms
- List and modify settings
Clients should wait for a response before sending another command.
### Message Format
The command and response message formats are the same, except commands
have an instruction where responses have a status code:
| Offset (bytes) | Description | Length (bytes) |
|----------------|---------------------------|----------------|
| 0 | Instruction / status code | 1 |
| 1 | Parameter count | 1 |
| 2 | Parameters | *Variable* |
Parameters are structured as a sequence of type, value tuples:
| Offset | Description | Length (bytes) |
|--------|----------------|------------------------------|
| 0 | Type | 1 |
| 1 | Value | *Determined by length field* |
A maximum of 16 parameters is permitted.
### Instructions
| Instruction | Description | Command parameters | Response parameters |
|-------------|---------------|------------------------------|----------------------------------|
| 0 | Get version | None | Major (u8), Minor (u8) |
| 1 | Set time | UNIX Timestamp (u64) | None |
| 2 | List alarms | None | Sequence of ID (u8), Alarm pairs |
| 3 | Add alarm | Alarm (alarm) | Alarm ID (u8) |
| 4 | Remove alarm | Alarm ID (u8) | None |
| 5 | List settings | None | Sequence of key (string), value |
| 6 | Set setting | Key (string), value (string) | None |
### Status codes
| Code | Description | Parameters |
|------|-----------------|-------------------------|
| 0 | OK / Success | *Instruction-dependent* |
| 1 | Busy | None |
| 2 | Invalid command | None |
| 3 | Internal error | None |
| 4 | Out of memory | None |
### Types
| Code | Name | Length (bytes) | Structure |
|------|--------|----------------|------------------------------------------|
| 0 | U8 | 1 | |
| 1 | U16 | 2 | |
| 2 | U32 | 4 | |
| 3 | U64 | 8 | |
| 4 | String | - | Length (u8), UTF-8 |
| 5 | Alarm | 3 | Hour (u8), minute (u8), day bitmask (u8) |
All integers are big-endian.
#### Day Bitmask
| Bit (0 is least-significant) | Day |
|------------------------------|-----------|
| 0 | Monday |
| 1 | Tuesday |
| 2 | Wednesday |
| 3 | Thursday |
| 4 | Friday |
| 5 | Saturday |
| 6 | Sunday |
| 7 | *Unused* |

View File

@ -264,7 +264,7 @@ void alarms_init(void)
"alarms",
"List, add and remove alarms\n\nIf a dayspec is specified, it "
"should be a sequence of 'x's and '-'s (for enabled or disabled "
"respectively). For example: \"*-*-*--\" means Mondays, Wednesdays "
"respectively). For example: \"x-x-x--\" means Mondays, Wednesdays "
"and Fridays only.",
"alarms OR alarms add <hh:mm> [dayspec] OR alarms remove <index> OR "
"alarms <clear>",

View File

@ -0,0 +1,5 @@
idf_component_register(
SRCS "protocol_interface.c"
INCLUDE_DIRS "."
REQUIRES alarms
)

View File

@ -0,0 +1,244 @@
/*
* SPDX-License-Identifier: AGPL-3.0-only
* Copyright (c) Camden Dixie O'Brien
*/
#include "protocol_interface.h"
#include "esp_log.h"
#include "lwip/sockets.h"
#include <string.h>
#define TAG "Protocol interface"
#define PORT 3498
static CommandHandler handlers[COMMAND_INSTRUCTION_COUNT];
static Message command;
static Message response;
static char string_buffer[CONFIG_MESSAGE_STRING_BUFFER_SIZE];
static char *string_buffer_free_ptr;
static bool receive(int socket, unsigned n, char *p)
{
int len;
do {
len = recv(socket, p, n, 0);
if (len < 0) {
ESP_LOGE(TAG, "Error reading from socket: %d", errno);
return false;
} else if (len == 0) {
ESP_LOGI(TAG, "Connection closed");
return false;
} else {
n -= len;
p += len;
}
} while (n > 0);
return true;
}
static bool receive_param(int socket, unsigned i)
{
MessageParam *param = &command.params[i];
uint8_t type_buf;
if (!receive(socket, 1, &type_buf))
return false;
// TODO handle invalid param type
param->type = (MessageParamType)type_buf;
uint8_t len, alarm_buf[3];
switch (param->type) {
case MESSAGE_PARAM_TYPE_U8:
if (!receive(socket, 1, (char *)&param->u8))
return false;
break;
case MESSAGE_PARAM_TYPE_U16:
if (!receive(socket, 2, (char *)&param->u16))
return false;
param->u16 = ntohs(param->u16);
break;
case MESSAGE_PARAM_TYPE_U32:
if (!receive(socket, 4, (char *)&param->u32))
return false;
param->u32 = ntohl(param->u32);
break;
case MESSAGE_PARAM_TYPE_U64:
if (!receive(socket, 8, (char *)&param->u64))
return false;
param->u64 = ntohll(param->u64);
break;
case MESSAGE_PARAM_TYPE_STRING:
if (!receive(socket, 1, &len))
return false;
char *buf = alloc_message_string(len);
// TODO handle out-of-memory
if (!receive(socket, len, buf))
return false;
param->string = buf;
break;
case MESSAGE_PARAM_TYPE_ALARM:
if (!receive(socket, 3, alarm_buf))
return false;
param->alarm.time.hour = alarm_buf[0];
param->alarm.time.minute = alarm_buf[1];
for (unsigned i = 0; i < WEEK_DAY_COUNT; ++i)
param->alarm.days[i] = (alarm_buf[2] >> i) & 1;
break;
}
}
static bool receive_command(int socket)
{
char header_buf[2];
if (!receive(socket, 2, header_buf))
return false;
// TODO handle invalid instruction
command.instruction = (CommandInstruction)header_buf[0];
command.param_count = (uint8_t)header_buf[1];
for (unsigned i = 0; i < command.param_count; ++i) {
if (!receive_param(socket, i))
return false;
}
return true;
}
static void send_response(int socket)
{
}
static void drop_message_strings(void)
{
string_buffer_free_ptr = string_buffer;
}
static void handle_command(void)
{
const CommandHandler handler = handlers[command.instruction];
if (handler == NULL) {
ESP_LOGW(
TAG, "Unhandled command, instruction: %u", command.instruction);
return;
}
const ResponseStatus status = handler(&command, &response);
if (status != RESPONSE_STATUS_OK) {
response.status = status;
response.param_count = 0;
}
send_response();
drop_message_strings();
}
void server_task(void *arg)
{
int status;
// Create listening socket
const int listen_socket = socket(AF_INET6, SOCK_STREAM, IPPROTO_IPV6);
if (listen_socket < 0) {
ESP_LOGE(TAG, "Error creating socket: %d", errno);
goto error_no_cleanup;
}
// Configure listening socket
int opt = 1;
setsockopt(listen_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// Bind socket to port (any address)
struct sockaddr_storage server_addr;
struct sockaddr_in6 *server_addr_ip6
= (struct sockaddr_in6 *)&server_addr;
memset(
&server_addr_ip6->sin6_addr.un, 0,
sizeof(server_addr_ip6->sin6_addr.un));
server_addr_ip6->sin6_family = AF_INET6;
server_addr_ip6->sin6_port = htons(PORT);
status = bind(
listen_socket, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (status != 0) {
ESP_LOGE(TAG, "Error binding socket: %d", errno);
goto error_cleanup_socket;
}
// Listen on socket
status = listen(listen_socket, 1);
if (status != 0) {
ESP_LOGE(TAG, "Error listening on socket: %d", errno);
goto error_cleanup_socket;
}
while (1) {
struct sockaddr_storage client_addr;
socklen_t addr_len = sizeof(client_addr);
// Accept incoming connection
int socket = accept(
listen_socket, (struct sockaddr *)&client_addr, &addr_len);
if (socket < 0) {
ESP_LOGE(TAG, "Error accepting connection: %d", errno);
continue;
}
// Log client address, run command-response loop until
// connection closed.
const char *addr_str
= ((struct sockaddr_in6 *)&client_addr)->sin6_addr;
ESP_LOGI(TAG, "Accepted connection from %s", addr_str);
while (1) {
if (!receive_command())
break;
handle_command();
}
// Cleanup connection
shutdown(socket, 0);
close(socket);
}
error_cleanup_socket:
close(listen_socket);
error_no_cleanup:
vTaskDelete(NULL);
}
void protocol_interface_init(void)
{
memset(handlers, 0, sizeof(handlers));
string_buffer_free = string_buffer;
memset(command, 0, sizeof(command));
memset(response, 0, sizeof(response));
}
void set_command_handler(
CommandInstruction instruction, CommandHandler handler)
{
if (instruction < COMMAND_INSTRUCTION_COUNT) {
if (handlers[instruction] != NULL)
ESP_LOGW(TAG, "Handler #%u overwritten", instruction);
handlers[instruction] = handler;
} else {
ESP_LOGE(TAG, "Invalid instruction passed to %s()", __func__);
}
}
char *alloc_message_string(unsigned length)
{
if (string_buffer_free_ptr + length + 1
< string_buffer + CONFIG_MESSAGE_STRING_BUFFER_SIZE) {
char *s = string_buffer_free_ptr;
string_buffer_free_ptr += length + 1;
s[length] = '\0';
return s;
} else {
return NULL;
}
}

View File

@ -0,0 +1,43 @@
/*
* SPDX-License-Identifier: AGPL-3.0-only
* Copyright (c) Camden Dixie O'Brien
*/
#ifndef PROTOCOL_INTERFACE_H
#define PROTOCOL_INTERFACE_H
#include "protocol_messages.h"
typedef ResponseStatus (*CommandHandler)(
const Message *command, Message *response_out);
/**
* Initialize the protocol interface and start listening for commands.
*/
void protocol_interface_init(void);
/**
* Set the handler for commands with a given intruction.
*
* The handler will be invoked when/if a command with the specified
* instruction is received. The response status should be returned,
* and upon success the response should be populated. Strings in the
* response must be allocated with alloc_message_string(); these will
* be freed by the interface when the response has been sent.
*
* Only a single handler can be set for a given instruction; if
* multiple are added then only the one added last will be used.
*/
void set_command_handler(
CommandInstruction instruction, CommandHandler handler);
/**
* Allocate a buffer for a string with the given length (in bytes),
* returning a pointer to it or NULL if space has ran out.
*
* The actual space allocated will be length + 1 bytes, and the last
* byte in this buffer will be set to the null character.
*/
char *alloc_message_string(unsigned length);
#endif

View File

@ -0,0 +1,69 @@
/*
* SPDX-License-Identifier: AGPL-3.0-only
* Copyright (c) Camden Dixie O'Brien
*/
#ifndef PROTOCOL_MESSAGES_H
#define PROTOCOL_MESSAGES_H
#include "alarm_types.h"
#include <stdint.h>
#define MESSAGE_MAX_PARAMS 16
typedef enum {
MESSAGE_PARAM_TYPE_U8 = 0,
MESSAGE_PARAM_TYPE_U16 = 1,
MESSAGE_PARAM_TYPE_U32 = 2,
MESSAGE_PARAM_TYPE_U64 = 3,
MESSAGE_PARAM_TYPE_STRING = 4,
MESSAGE_PARAM_TYPE_ALARM = 5,
MESSAGE_PARAM_TYPE_COUNT,
} MessageParamType;
typedef struct {
MessageParamType type;
union {
uint8_t u8;
uint16_t u16;
uint32_t u32;
uint64_t u64;
const char *string;
Alarm alarm;
};
} MessageParam;
typedef enum {
COMMAND_INSTRUCTION_GET_VERSION = 0,
COMMAND_INSTRUCTION_SET_TIME = 1,
COMMAND_INSTRUCTION_LIST_ALARMS = 2,
COMMAND_INSTRUCTION_ADD_ALARM = 3,
COMMAND_INSTRUCTION_REMOVE_ALARM = 4,
COMMAND_INSTRUCTION_LIST_SETTINGS = 5,
COMMAND_INSTRUCTION_SET_SETTING = 6,
COMMAND_INSTRUCTION_COUNT,
} CommandInstruction;
typedef enum {
RESPONSE_STATUS_OK = 0,
RESPONSE_STATUS_BUSY = 1,
RESPONSE_STATUS_INVALID_COMMAND = 2,
RESPONSE_STATUS_INTERNAL_ERROR = 3,
RESPONSE_STATUS_OUT_OF_SPACE = 4,
RESPONSE_STATUS_COUNT,
} ResponseStatus;
typedef struct {
union {
CommandInstruction instruction;
ResponseStatus status;
};
uint8_t param_count;
MessageParam params[MESSAGE_MAX_PARAMS];
} Message;
#endif

407
remote-cli/bclk.py Normal file
View File

@ -0,0 +1,407 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) Camden Dixie O'Brien
import abc
import argparse
import enum
import socket
import struct
import sys
import time
class Weekday:
MONDAY = 0
TUESDAY = 1
WEDNESDAY = 2
THURSDAY = 3
FRIDAY = 4
SATURDAY = 5
SUNDAY = 6
class Alarm:
SERIALIZED_LENGTH = 3
@staticmethod
def deserialize(alarm_bytes):
if len(alarm_bytes) != Alarm.SERIALIZED_LENGTH:
raise RuntimeError("Wrong number of bytes for alarm")
hour = alarm_bytes[0]
minute = alarm_bytes[1]
days = Alarm._read_day_bitmask(alarm_bytes[2])
return Alarm(hour, minute, days)
@staticmethod
def _read_day_bitmask(bitmask):
days = set()
for i in range(7):
if (bitmask >> i & 1) != 0:
days.add(Weekday(i))
return days
@staticmethod
def _make_day_bitmask(days):
bitmask = 0
for i in range(7):
if Weekday(i) in days:
bitmask |= 1 << i
return bitmask
def __init__(self, hour, minute, days):
self.hour = hour
self.minute = minute
self.days = days
def serialize(self):
day_bitmask = Alarm._make_day_bitmask(self.days)
return bytes([self.hour, self.minute, day_bitmask])
class Type(enum.Enum):
U8 = 0
U16 = 1
U32 = 2
U64 = 3
STRING = 4
ALARM = 5
@staticmethod
def length(type):
if type == Type.U8:
return 1
elif type == Type.U16:
return 2
elif type == Type.U32:
return 4
elif type == Type.U64:
return 8
elif type == Type.STRING:
return None
elif type == Type.ALARM:
return Alarm.SERIALIZED_LENGTH
else:
raise RuntimeError("Invalid param type")
class Param:
@staticmethod
def deserialize(self, type, value_bytes):
return Param(type, Param._deserialize_value(type, value_bytes))
@staticmethod
def _deserialize_value(self, type, value_bytes):
if type == Type.U8:
return value_bytes[0]
elif type == Type.U16:
return struct.unpack('!H', value_bytes)[0]
elif type == Type.U32:
return struct.pack('!I', value_bytes)[0]
elif type == Type.U64:
return struct.pack('!Q', value_bytes)[0]
elif type == Type.STRING:
# Strings handled separately due to variable length
raise NotImplemented
elif type == Type.ALARM:
return Alarm.deserialize(value_bytes)
else:
raise RuntimeError("Invalid param type")
def __init__(self, type, value):
self.type = type
self.value = value
def serialize(self):
if self.type == Type.U8:
return bytes([self.value])
elif self.type == Type.U16:
return struct.pack('!H', self.value)
elif self.type == Type.U32:
return struct.pack('!I', self.value)
elif self.type == Type.U64:
return struct.pack('!Q', self.value)
elif self.type == Type.STRING:
return bytes([len(self.value)]) + self.value.encode('utf-8')
elif self.type == Type.ALARM:
return self.value.serialize()
else:
raise RuntimeError("Invalid param type")
class Instruction(enum.Enum):
GET_VERSION = 0
SET_TIME = 1
LIST_ALARMS = 2
ADD_ALARM = 3
REMOVE_ALARM = 4
LIST_SETTINGS = 5
SET_SETTING = 6
class CommandFailed(Exception):
pass
class Command(abc.ABC):
def __init__(self, instruction, *params):
self.instruction = instruction
self.params = params
def serialize(self):
param_bytes = [param.serialize() for param in self.params]
return bytes([instruction]) + b''.join(param_bytes)
def run(self, connection):
response = connection.run(self)
if response.status != Status.OK:
raise CommandFailed()
return self.proc_response(response.params)
@abc.abstractmethod
def proc_response(self, params):
pass
class GetVersion(Command):
def __init__(self):
super().__init__(Instruction.GET_VERSION)
def proc_response(self, params):
return (params[0].value, params[1].value)
class SetTime(Command):
def __init__(self, timestamp):
super().__init__(Instruction.SET_TIME, Param(Type.U64, timestamp))
class ListAlarms(Command):
def __init__(self):
super().__init__(Instruction.LIST_ALARMS)
def proc_response(self, params):
pairs = zip(params[::2], params[1::2])
return {key.value: value.value for key, value in pairs}
class AddAlarm(Command):
def __init__(self, hour, minute, days):
alarm = Alarm(hour, minute, days)
super().__init__(Instruction.ADD_ALARM, Param(Type.ALARM, alarm))
def proc_response(self, params):
return params[0].value
class RemoveAlarm(Command):
def __init__(self, alarm_id):
super().__init__(Instruction.REMOVE_ALARM, Param(Type.U8, alarm_id))
class ListSettings(Command):
def __init__(self):
super().__init__(Instruction.LIST_SETTINGS)
def proc_response(self, params):
pairs = zip(params[::2], params[1::2])
return {key.value: value.value for key, value in pairs}
class SetSetting(Command):
def __init__(self, key, value):
key_param = Param(Type.STRING, key)
value_param = Param(Type.STRING, value)
super().__init__(Instruction.SET_SETTING, key_param, value_param)
class Status(enum.Enum):
OK = 0
BUSY = 1
INVALID_COMMAND = 2
INTERNAL_ERROR = 3
OUT_OF_SPACE = 4
@staticmethod
def description(status):
if status == OK:
return "OK"
elif status == BUSY:
return "Busy"
elif status == INVALID_COMMAND:
return "Invalid command"
elif status == INTERNAL_ERROR:
return "Internal error"
elif status == OUT_OF_SPACE:
return "Device out of space"
else:
raise RuntimeError("Invalid status")
class Response:
def __init__(self, status, params):
self.status = status
self.params = params
class Connection:
PORT = 3498
def __init__(self, host):
self.socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
self.socket.connect((host, self.PORT))
def run(self, command):
self._send(command)
return self._receive_response()
def close(self):
self.socket.close()
def _send(self, command):
command_bytes = command.serialize()
total_sent = 0
while total_sent < len(command_bytes):
sent = self.socket.send(command_bytes[total_sent:])
if sent == 0:
raise RuntimeError("Connection broken")
total_sent += sent
def _receive_response(self):
status, param_count = self._receive(2)
params = [self._receive_param() for _ in range(param_count)]
return Response(status, params)
def _receive_param(self):
type = self._receive(1)
if type == Type.STRING:
length = self._receive(1)
value = self._receive(length).decode('utf-8')
return Param(type, value)
else:
value_bytes = self._receive(Type.length(type))
return Param.deserialize(type, value_bytes)
def _receive(self, n):
chunks = []
remaining = n
while remaining > 0:
chunk = self.socket.recv(remaining)
if len(chunk) == 0:
raise RuntimeError("Connection broken")
remaining -= len(chunk)
chunks.append(chunk)
return b''.join(chunks)
def version_subcommand(connection, args):
major, minor = GetVersion().run(connection)
print(f"{major}.{minor}")
def sync_subcommand(connection, args):
timestamp = int(time.time())
SetTime(timestamp).run(connection)
def alarms_subcommand(connection, args):
def read_dayspec(dayspec):
days = set()
for i in range(7):
if dayspec[i] == 'x':
days.add(Weekday(i))
return days
def format_dayspec(days):
dayspec = ""
for i in range(7):
dayspec += 'x' if Weekday(i) in days else '-'
return dayspec
if args.action == None:
alarms = ListAlarms().run(connection)
for id, alarm in alarms.items():
dayspec = format_dayspec(alarm.days)
print(f"[{id:2}] {alarm.hour:02}:{alarm.minute:02} {dayspec}")
elif args.action == "add":
hour, minute = args.time.split(':')
days = read_dayspec(args.dayspec)
id = AddAlarm(int(hour), int(minute), days).run(connection)
print(f"{id}")
elif args.action == "rm":
RemoveAlarm(int(args.id)).run(connection)
def settings_subcommand(connection, args):
if args.action == None:
settings = ListSettings().run(connection)
for key, value in settings.items():
print(f"{key:15} {value}")
elif args.action == "set":
SetSetting(args.key, args.value).run(connection)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Control a bedside clock remotely")
parser.add_argument(
"--host", help="The hostname/IP of the clock",
default="bedside-clock")
subparsers = parser.add_subparsers(
help="Action to perform", dest="subcommand", required=True)
version_parser = subparsers.add_parser(
"version", help="Get the version of the clock",
description = "Get the version of the clock")
sync_parser = subparsers.add_parser(
"sync", help="Syncronise the clock to this machine",
description="Syncronise the clock to this machine")
alarms_parser = subparsers.add_parser(
"alarms", help="List and manage alarms",
description="List and manage alarms")
settings_parser = subparsers.add_parser(
"settings", help="List and manage settings",
description="List and manage settings")
alarms_subparsers = alarms_parser.add_subparsers(dest="action")
alarms_add_parser = alarms_subparsers.add_parser(
"add", help="Add an alarm", description="Add an alarm")
alarms_rm_parser = alarms_subparsers.add_parser(
"rm", help="Remove an alarm", description="Remove an alarm")
alarms_add_parser.add_argument(
"--workdays", const="xxxxx--", dest="dayspec", action="store_const")
alarms_add_parser.add_argument(
"--weekends", const="-----xx", dest="dayspec", action="store_const")
alarms_add_parser.add_argument(
"time", help="The time for the alarm (HH:MM)")
alarms_add_parser.add_argument(
"dayspec", help="""Days for the alarm to be active on.
Should be a sequence of 'x's and '-'s. For example,
\"x-x----\" means Mondays and Tuesdays only.""",
default="xxxxxxx")
alarms_rm_parser.add_argument(
"id", help="ID of the alarm to remove")
settings_subparsers = settings_parser.add_subparsers(dest="action")
settings_set_parser = settings_subparsers.add_parser(
"set", help="Set a setting", description="Set a setting")
settings_set_parser.add_argument(
"key", help="Key of the setting to set")
settings_set_parser.add_argument(
"value", help="Value of the setting to set")
args = parser.parse_args()
subcommand_map = {
"version": version_subcommand,
"sync": sync_subcommand,
"alarms": alarms_subcommand,
"settings": settings_subcommand,
}
connection = Connection(args.host)
subcommand_map[args.subcommand](connection, args)
connection.close()