Compare commits
No commits in common. "tcp-proto" and "main" have entirely different histories.
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 control protocol
|
- [ ] TCP API for configuration
|
||||||
- [x] SNTP synchronisation
|
- [x] SNTP synchronisation
|
||||||
- [x] Snooze and dismiss buttons
|
- [x] Snooze and dismiss buttons
|
||||||
- [x] Multiple alarms
|
- [x] Multiple alarms
|
||||||
@ -20,83 +20,3 @@ 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: \"x-x-x--\" means Mondays, Wednesdays "
|
"respectively). For example: \"*-*-*--\" 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>",
|
@ -1,5 +0,0 @@
|
|||||||
idf_component_register(
|
|
||||||
SRCS "protocol_interface.c"
|
|
||||||
INCLUDE_DIRS "."
|
|
||||||
REQUIRES alarms
|
|
||||||
)
|
|
@ -1,244 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
@ -1,69 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
@ -1,407 +0,0 @@
|
|||||||
# 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