Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
138edf9c52 | |||
8fdc409a39 | |||
cf11785f44 | |||
e961496973 | |||
d2e2ea21df | |||
8c957d043a |
82
README.md
82
README.md
@ -8,7 +8,7 @@ Networked bedside clock utilising an ESP32-SOLO-1.
|
|||||||
- [x] 7 segment display
|
- [x] 7 segment display
|
||||||
- [x] WiFi networking
|
- [x] WiFi networking
|
||||||
- [x] Console for configuration
|
- [x] Console for configuration
|
||||||
- [ ] TCP API for configuration
|
- [ ] TCP control protocol
|
||||||
- [x] SNTP synchronisation
|
- [x] SNTP synchronisation
|
||||||
- [x] Snooze and dismiss buttons
|
- [x] Snooze and dismiss buttons
|
||||||
- [x] Multiple alarms
|
- [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
|
The firmware is a standard ESP-IDF v5.0 project; refer to [Espressif's
|
||||||
ESP-IDF
|
ESP-IDF
|
||||||
docs](https://docs.espressif.com/projects/esp-idf/en/release-v5.0/esp32/index.html).
|
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* |
|
||||||
|
@ -264,7 +264,7 @@ void alarms_init(void)
|
|||||||
"alarms",
|
"alarms",
|
||||||
"List, add and remove alarms\n\nIf a dayspec is specified, it "
|
"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 "
|
"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.",
|
"and Fridays only.",
|
||||||
"alarms OR alarms add <hh:mm> [dayspec] OR alarms remove <index> OR "
|
"alarms OR alarms add <hh:mm> [dayspec] OR alarms remove <index> OR "
|
||||||
"alarms <clear>",
|
"alarms <clear>",
|
5
firmware/components/protocol/CMakeLists.txt
Normal file
5
firmware/components/protocol/CMakeLists.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
idf_component_register(
|
||||||
|
SRCS "protocol_interface.c"
|
||||||
|
INCLUDE_DIRS "."
|
||||||
|
REQUIRES alarms
|
||||||
|
)
|
244
firmware/components/protocol/protocol_interface.c
Normal file
244
firmware/components/protocol/protocol_interface.c
Normal 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 *)¶m->u8))
|
||||||
|
return false;
|
||||||
|
break;
|
||||||
|
case MESSAGE_PARAM_TYPE_U16:
|
||||||
|
if (!receive(socket, 2, (char *)¶m->u16))
|
||||||
|
return false;
|
||||||
|
param->u16 = ntohs(param->u16);
|
||||||
|
break;
|
||||||
|
case MESSAGE_PARAM_TYPE_U32:
|
||||||
|
if (!receive(socket, 4, (char *)¶m->u32))
|
||||||
|
return false;
|
||||||
|
param->u32 = ntohl(param->u32);
|
||||||
|
break;
|
||||||
|
case MESSAGE_PARAM_TYPE_U64:
|
||||||
|
if (!receive(socket, 8, (char *)¶m->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;
|
||||||
|
}
|
||||||
|
}
|
43
firmware/components/protocol/protocol_interface.h
Normal file
43
firmware/components/protocol/protocol_interface.h
Normal 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
|
69
firmware/components/protocol/protocol_messages.h
Normal file
69
firmware/components/protocol/protocol_messages.h
Normal 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
407
remote-cli/bclk.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user