ESPSomfy-RTS/src/ModelSnapshot.h
cjkas 89d3713d86 test: prove no read item can outgrow the snapshot unit
The read routes leave out a room, shade, group or linked shade that
doesn't fit the 3 KB unit, without telling the client (KNOWN_ISSUES.md).
test_snapshot_limits fills a snapshot with the largest possible items and
checks every read document written with the real unit is identical to
the same document written with an unlimited one. The largest item, a
shade, is 1,097 bytes. The unit is now SNAPSHOT_UNIT_SIZE in
ModelSnapshot.h, used by Web.cpp and the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:29:04 +02:00

205 lines
7.5 KiB
C++

#ifndef MODEL_SNAPSHOT_H
#define MODEL_SNAPSHOT_H
#include <atomic>
#include <stddef.h>
#include <stdint.h>
#include "ShadeTypes.h"
#include "JsonFormatter.h"
// A copy of the shade model taken on loop() for one read, so the reply
// describes a single moment (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md),
// and the JSON the read routes write from it. Kept free of the shade model and
// the web server so it can be unit tested on the PC (pio test -e native).
// Positions are as clients see them: whole percentages, flipped for a shade
// with flipPosition, -1 when unset.
// No default member values: with them the SnapshotSlots would be stored in
// flash as initialized data (11 KB). The snapshot functions set every field;
// anything else starts from {}.
struct room_snapshot_t {
uint8_t roomId;
char name[21];
int8_t sortOrder;
};
struct linked_remote_snapshot_t {
uint32_t remoteAddress;
uint16_t lastRollingCode;
};
struct shade_snapshot_t {
uint8_t shadeId;
uint8_t roomId;
char name[21];
uint32_t remoteAddress;
uint32_t upTime;
uint32_t downTime;
uint32_t tiltTime;
uint16_t stepSize;
bool paired;
uint16_t lastRollingCode;
int8_t position;
int8_t target;
int8_t myPos;
int8_t direction;
uint8_t tiltType;
int8_t tiltPosition;
int8_t tiltTarget;
int8_t myTiltPos;
int8_t tiltDirection;
uint8_t shadeType;
uint8_t bitLength;
uint8_t proto;
uint8_t flags;
bool flipCommands;
bool flipPosition;
bool inGroup;
bool sunSensor;
bool light;
bool simMy;
uint8_t repeats;
int8_t sortOrder;
uint8_t gpioUp;
uint8_t gpioDown;
uint8_t gpioMy;
bool gpioLLTrigger;
uint8_t linkedRemoteCount;
linked_remote_snapshot_t linkedRemotes[SOMFY_MAX_LINKED_REMOTES];
};
struct group_snapshot_t {
uint8_t groupId;
uint8_t roomId;
char name[21];
uint32_t remoteAddress;
uint16_t lastRollingCode;
uint8_t bitLength;
uint8_t proto;
bool sunSensor;
bool flipCommands;
uint8_t flags; // its linked shades' flags together
uint8_t repeats;
int8_t sortOrder;
uint8_t linkedShadeCount;
uint8_t linkedShades[SOMFY_MAX_GROUPED_SHADES];
};
// Only the rooms, shades, groups and repeaters that exist, in model order.
struct model_snapshot_t {
uint32_t startingAddress;
uint8_t radioBitLength; // what a new shade or group gets
uint8_t radioProto;
uint8_t roomCount;
uint8_t shadeCount;
uint8_t groupCount;
uint8_t repeaterCount;
room_snapshot_t rooms[SOMFY_MAX_ROOMS];
shade_snapshot_t shades[SOMFY_MAX_SHADES];
group_snapshot_t groups[SOMFY_MAX_GROUPS];
uint32_t repeaters[SOMFY_MAX_REPEATERS];
const room_snapshot_t *room(uint8_t roomId) const;
const shade_snapshot_t *shade(uint8_t shadeId) const;
const group_snapshot_t *group(uint8_t groupId) const;
};
// The fields of one item, as every route has always written them.
void writeRoom(JsonFormatter &json, const room_snapshot_t &room);
void writeShadeRef(JsonFormatter &json, const shade_snapshot_t &shade);
void writeShade(JsonFormatter &json, const shade_snapshot_t &shade);
void writeGroupRef(JsonFormatter &json, const group_snapshot_t &group);
// The shade a group links to, or nullptr when it has gone.
typedef const shade_snapshot_t *(*shade_lookup_t)(uint8_t shadeId, void *ctx);
// writeGroupRef plus linkedShades, the refs of the shades it links to.
void writeGroup(JsonFormatter &json, const group_snapshot_t &group, shade_lookup_t lookup, void *ctx);
// The documents the read routes send.
enum class snapshot_doc_t : uint8_t {
controller, // /controller
discovery, // /discovery
rooms, // /rooms
shades, // /shades
groups, // /groups
room, // /room?roomId=
shade, // /shade?shadeId=
group, // /group?groupId=
repeaters, // /repeaters
nextRoom, // /getNextRoom: the id and defaults a new room would get
nextShade, // /getNextShade
nextGroup, // /getNextGroup
groupOptions // /groupOptions?groupId=: the group and the shades it could link
};
// Writes the fields of /controller or /discovery that aren't in the snapshot
// (device, radio and firmware details) into the open top-level object.
typedef void (*snapshot_header_t)(JsonFormatter &json, const model_snapshot_t &snap);
// The buffer each room, shade, group and linked shade is formatted into on its
// own. test_snapshot_limits checks the largest possible item of each fits.
#define SNAPSHOT_UNIT_SIZE 3072
// Writes a document from a snapshot a piece at a time for a chunked response.
// Each room, shade, group and linked shade is formatted into unit on its own;
// one that doesn't fit is logged and left out so the JSON stays valid.
class SnapshotWriter {
public:
// id picks the item for the single-item documents. header is only called
// for controller and discovery. unit must outlive the writer.
SnapshotWriter(const model_snapshot_t &snap, snapshot_doc_t doc, uint8_t id, snapshot_header_t header, char *unit, size_t unitSize);
// Copies up to maxLen more bytes of the document into out. 0 once it is all
// out.
size_t generate(uint8_t *out, size_t maxLen);
private:
const model_snapshot_t &snap;
const uint8_t *steps;
uint8_t step = 0;
uint8_t id;
snapshot_header_t header;
BufferedJsonFormatter fmt;
char *unit;
size_t unitSize;
size_t unitLen = 0;
size_t consumed = 0;
uint8_t idx = 0;
uint8_t lsIdx = 0; // next linked shade of the group being written
bool groupOpen = false; // the group's ref is out, its linked shades are next
bool firstItem = true;
bool firstLinked = true;
void produceUnit();
void nextStep();
void literal(const char *text);
void resetFmt(size_t offset);
bool overflowed(const char *what, uint8_t itemId);
size_t itemStart();
void groupUnit(const group_snapshot_t &group, bool inList, const char *close = "]}");
void finish(const char *text);
};
// What the next room, shade or group added would get: its id (0 for a room and
// 255 for a shade or group when there is no room for one) and remote address.
uint8_t nextRoomId(const model_snapshot_t &snap);
uint8_t nextShadeId(const model_snapshot_t &snap);
uint8_t nextGroupId(const model_snapshot_t &snap);
uint32_t nextRemoteAddress(const model_snapshot_t &snap, uint8_t id);
// The snapshots reads are served from. A read takes a slot on the web server
// task and queues a fill; loop() fills it; the read streams it and lets it go.
// The read may go away before loop() gets to it, so the slot's state decides
// who frees it: whichever side is last. A slot's snapshot (about 5.6 KB) is
// only allocated while a read holds it.
#define SNAPSHOT_SLOTS 2
class SnapshotSlots {
public:
// A free slot with its snapshot allocated, or -1 when every slot is in use
// or there is no memory. Any task.
int8_t acquire();
// Fills the slot with fill, unless its read has gone. loop() only.
void fill(uint8_t slot, void (*fill)(model_snapshot_t &snap));
// Whether loop() has filled the slot. The read's task.
bool filled(uint8_t slot) const;
// Valid from acquire() until the read releases the slot.
model_snapshot_t &snapshot(uint8_t slot) { return *this->snaps[slot]; }
// The read is done with the slot, or gone. The read's task.
void release(uint8_t slot);
// Frees a slot whose fill was never queued. The read's task.
void cancel(uint8_t slot);
private:
enum : uint32_t { FREE, WAITING, FILLING, FILLED, ABANDONED };
model_snapshot_t *snaps[SNAPSHOT_SLOTS] = {};
std::atomic<uint32_t> state[SNAPSHOT_SLOTS] = {};
void freeSlot(uint8_t slot);
};
#endif