fix: command op replies that said ok when nothing was done

From the review of #100:
- setMyPosition on a moving shade replies busy ("The shade is moving"),
  and setPositions with only a tilt, for a shade without one, replies
  bad request ("The shade has no tilt"). Both said ok before.
- PROTOCOL.md lists where the ops differ from the routes, and how
  sendRemoteCommand is answered.
- The LoopQueue hold comment covers protocol 2 replies and an unpair
  held through a frequency scan.
- One shadeRequestIsForGroup() for the group cases; ProtoOps shares one
  reader for "one or both" fields and one for ids, and the radio
  refused text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-24 12:49:23 +02:00
parent 598d708b12
commit 5737a58790
12 changed files with 103 additions and 49 deletions

View file

@ -43,11 +43,14 @@ struct loop_item_t {
inline bool loopItemUsesRadio(const loop_item_t &item) {
return item.type == loop_item_type_t::shade_request && shadeRequestUsesRadio(item.request.type);
}
// Whether the item at the head of the queue waits for the radio. The client
// was already told it was accepted so it stays queued until the radio has room
// rather than have the radio drop it. While TX is suspended nothing gets
// through anyway so nothing is held back then, except an unpair: the client was
// told the shade is unpaired, so it waits until TX resumes.
// Whether the item at the head of the queue waits for the radio. An HTTP or
// MQTT client was already told it was accepted, so it stays queued until the
// radio has room rather than have the radio drop it; a protocol 2 client gets
// its reply once it has run. While TX is suspended nothing gets through
// anyway so nothing is held back then, except an unpair: an HTTP client was
// told the shade is unpaired, so it waits until TX resumes. Everything queued
// behind it waits too, protocol 2 replies included, so a frequency scan
// started after an unpair delays them until it ends.
inline bool holdLoopItem(const loop_item_t &item, bool txSuspended, bool txHasRoom) {
if(txSuspended) return item.type == loop_item_type_t::shade_request && item.request.type == shade_request_type_t::unpair;
return loopItemUsesRadio(item) && !txHasRoom;

View file

@ -64,9 +64,10 @@ class OpReader {
return v.is<const char *>() ? v.as<const char *>() : nullptr;
}
};
// What the reply's desc points at once parseShadeOp has returned.
// The reader's problem, kept once the reader has gone so the reply can send
// it. One buffer for every parse: they all run on async_tcp, one at a time.
static char lastError[64];
static const char *result(OpReader &r) {
static const char *errorOf(OpReader &r) {
if(!r.error) return nullptr;
snprintf(lastError, sizeof(lastError), "%s", r.error);
return lastError;
@ -116,17 +117,17 @@ static void readStep(OpReader &r, shade_request_t &req) {
// Steps are 7 bits on air.
if(r.get(proto_field_t::stepSize, 0, 127, n)) req.stepSize = static_cast<uint8_t>(n);
}
static void readShade(OpReader &r, shade_request_t &req) {
static void readId(OpReader &r, proto_field_t field, shade_request_t &req) {
int32_t n;
if(r.need(proto_field_t::shadeId, 0, 255, n)) req.id = static_cast<uint8_t>(n);
if(r.need(field, 0, 255, n)) req.id = static_cast<uint8_t>(n);
}
static void readShade(OpReader &r, shade_request_t &req) { readId(r, proto_field_t::shadeId, req); }
// A shade or a group; req.type becomes the one that was given.
static void readShadeOrGroup(OpReader &r, shade_request_t &req, shade_request_type_t shade, shade_request_type_t group) {
if(!r.either(proto_field_t::shadeId, proto_field_t::groupId)) return;
int32_t n;
const bool isShade = r.has(proto_field_t::shadeId);
req.type = isShade ? shade : group;
if(r.need(isShade ? proto_field_t::shadeId : proto_field_t::groupId, 0, 255, n)) req.id = static_cast<uint8_t>(n);
readId(r, isShade ? proto_field_t::shadeId : proto_field_t::groupId, req);
}
// A command, or a position 0-100 to move to.
static void readCommandOrTarget(OpReader &r, shade_request_t &req) {
@ -135,15 +136,16 @@ static void readCommandOrTarget(OpReader &r, shade_request_t &req) {
if(r.has(proto_field_t::command)) r.command(req.command);
else if(r.get(proto_field_t::target, 0, 100, n)) req.target = static_cast<int8_t>(n);
}
// Two positions 0-100 of which at least one is given; -1 for the other.
static void readPositions(OpReader &r, proto_field_t lift, proto_field_t tilt, int8_t &liftOut, int8_t &tiltOut) {
if(!r.has(lift) && !r.has(tilt)) {
r.fail("Give %s, %s or both", protoField(lift).name, protoField(tilt).name);
// Two values in lo..hi of which at least one is given; the other is left as
// it was.
static void readOneOrBoth(OpReader &r, proto_field_t a, proto_field_t b, int32_t lo, int32_t hi, int8_t &aOut, int8_t &bOut) {
if(!r.has(a) && !r.has(b)) {
r.fail("Give %s, %s or both", protoField(a).name, protoField(b).name);
return;
}
int32_t n;
if(r.get(lift, 0, 100, n)) liftOut = static_cast<int8_t>(n);
if(r.get(tilt, 0, 100, n)) tiltOut = static_cast<int8_t>(n);
if(r.get(a, lo, hi, n)) aOut = static_cast<int8_t>(n);
if(r.get(b, lo, hi, n)) bOut = static_cast<int8_t>(n);
}
const char *parseShadeOp(uint8_t type, JsonObjectConst d, shade_request_t &req) {
@ -165,7 +167,7 @@ const char *parseShadeOp(uint8_t type, JsonObjectConst d, shade_request_t &req)
break;
case proto_type_t::groupCommand:
req.type = shade_request_type_t::group_command;
if(r.need(proto_field_t::groupId, 0, 255, n)) req.id = static_cast<uint8_t>(n);
readId(r, proto_field_t::groupId, req);
r.command(req.command);
readRepeat(r, req);
readStep(r, req);
@ -179,19 +181,17 @@ const char *parseShadeOp(uint8_t type, JsonObjectConst d, shade_request_t &req)
case proto_type_t::setPositions:
req.type = shade_request_type_t::set_positions;
readShade(r, req);
readPositions(r, proto_field_t::position, proto_field_t::tiltPosition, req.position, req.tiltPosition);
readOneOrBoth(r, proto_field_t::position, proto_field_t::tiltPosition, 0, 100, req.position, req.tiltPosition);
break;
case proto_type_t::setSensor:
readShadeOrGroup(r, req, shade_request_type_t::shade_sensor, shade_request_type_t::group_sensor);
if(!r.has(proto_field_t::sunny) && !r.has(proto_field_t::windy)) r.fail("Give sunny, windy or both");
if(r.get(proto_field_t::sunny, 0, 1, n)) req.sunny = static_cast<int8_t>(n);
if(r.get(proto_field_t::windy, 0, 1, n)) req.windy = static_cast<int8_t>(n);
readOneOrBoth(r, proto_field_t::sunny, proto_field_t::windy, 0, 1, req.sunny, req.windy);
readRepeat(r, req);
break;
case proto_type_t::setMyPosition:
req.type = shade_request_type_t::set_favorite;
readShade(r, req);
readPositions(r, proto_field_t::myPos, proto_field_t::myTiltPos, req.position, req.tiltPosition);
readOneOrBoth(r, proto_field_t::myPos, proto_field_t::myTiltPos, 0, 100, req.position, req.tiltPosition);
break;
case proto_type_t::unpairShade:
req.type = shade_request_type_t::unpair;
@ -206,7 +206,7 @@ const char *parseShadeOp(uint8_t type, JsonObjectConst d, shade_request_t &req)
r.fail("Not a shade op");
break;
}
return result(r);
return errorOf(r);
}
const char *parseRemoteCommandOp(JsonObjectConst d, somfy_frame_t &frame, uint8_t &repeats) {
@ -220,28 +220,25 @@ const char *parseRemoteCommandOp(JsonObjectConst d, somfy_frame_t &frame, uint8_
if(r.need(proto_field_t::lastRollingCode, 1, UINT16_MAX, n)) frame.rollingCode = static_cast<uint16_t>(n);
if(r.get(proto_field_t::encKey, 0, UINT8_MAX, n)) frame.encKey = static_cast<uint8_t>(n);
if(r.get(proto_field_t::repeats, 0, UINT8_MAX, n)) repeats = static_cast<uint8_t>(n);
return result(r);
return errorOf(r);
}
proto_error_t protoRequestError(shade_request_result_t result) {
switch(result) {
case shade_request_result_t::not_found: return proto_error_t::notFound;
case shade_request_result_t::radio_refused: return proto_error_t::radioUnavailable;
// At rest it would take it: the client may try again.
case shade_request_result_t::shade_moving: return proto_error_t::busy;
case shade_request_result_t::no_tilt: return proto_error_t::badRequest;
default: return proto_error_t::none;
}
}
const char *protoRequestErrorDesc(const shade_request_t &req, shade_request_result_t result) {
switch(result) {
case shade_request_result_t::not_found:
switch(req.type) {
case shade_request_type_t::group_command:
case shade_request_type_t::group_repeat:
case shade_request_type_t::group_sensor:
return "No group with that id";
default:
return "No shade with that id";
}
case shade_request_result_t::radio_refused: return "The radio didn't take the command";
case shade_request_result_t::not_found: return shadeRequestIsForGroup(req.type) ? "No group with that id" : "No shade with that id";
case shade_request_result_t::radio_refused: return PROTO_RADIO_REFUSED;
case shade_request_result_t::shade_moving: return "The shade is moving";
case shade_request_result_t::no_tilt: return "The shade has no tilt";
default: return nullptr;
}
}

View file

@ -27,6 +27,9 @@ const char *parseRemoteCommandOp(JsonObjectConst d, somfy_frame_t &frame, uint8_
// in any case. False for a name that isn't a command.
bool protoCommand(const char *name, somfy_commands &cmd);
// What a reply says when the radio didn't take a command.
#define PROTO_RADIO_REFUSED "The radio didn't take the command"
// The reply to a shade request that loop() has run: none when it was done.
proto_error_t protoRequestError(shade_request_result_t result);
// What that reply says.

View file

@ -10,6 +10,16 @@ bool shadeRequestUsesRadio(shade_request_type_t type) {
return true;
}
}
bool shadeRequestIsForGroup(shade_request_type_t type) {
switch(type) {
case shade_request_type_t::group_command:
case shade_request_type_t::group_repeat:
case shade_request_type_t::group_sensor:
return true;
default:
return false;
}
}
int8_t requestRepeat(int32_t repeat) {
if(repeat < 0) return -1;
return repeat > INT8_MAX ? INT8_MAX : static_cast<int8_t>(repeat);
@ -26,16 +36,10 @@ static shade_request_result_t sent(bool taken) { return taken ? shade_request_re
shade_request_result_t ShadeRequests::run(const shade_request_t &req) {
shade_info_t shade;
uint8_t groupRepeats = 0;
switch(req.type) {
case shade_request_type_t::group_command:
case shade_request_type_t::group_repeat:
case shade_request_type_t::group_sensor:
if(!this->ops.findGroup(req.id, groupRepeats)) return shade_request_result_t::not_found;
break;
default:
if(!this->ops.findShade(req.id, shade)) return shade_request_result_t::not_found;
break;
if(shadeRequestIsForGroup(req.type)) {
if(!this->ops.findGroup(req.id, groupRepeats)) return shade_request_result_t::not_found;
}
else if(!this->ops.findShade(req.id, shade)) return shade_request_result_t::not_found;
const bool flip = shade.flipPosition;
switch(req.type) {
case shade_request_type_t::shade_command:
@ -62,6 +66,8 @@ shade_request_result_t ShadeRequests::run(const shade_request_t &req) {
case shade_request_type_t::set_positions: {
const float pos = inRange(req.position) ? storedPosition(req.position, flip) : -1.0f;
const float tilt = inRange(req.tiltPosition) && shade.tiltType != tilt_types::none ? storedPosition(req.tiltPosition, flip) : -1.0f;
// A tilt on its own for a shade without one has nothing to set.
if(pos < 0.0f && inRange(req.tiltPosition) && shade.tiltType == tilt_types::none) return shade_request_result_t::no_tilt;
if(pos >= 0.0f || tilt >= 0.0f) this->ops.setPositions(req.id, pos, tilt);
return shade_request_result_t::done;
}
@ -70,6 +76,8 @@ shade_request_result_t ShadeRequests::run(const shade_request_t &req) {
case shade_request_type_t::group_sensor:
return sent(this->ops.sendGroupSensorCommand(req.id, req.windy, req.sunny, repeatsOr(req.repeat, groupRepeats)));
case shade_request_type_t::set_favorite: {
// The shade only takes a favorite at rest, and would ignore it.
if(!shade.idle) return shade_request_result_t::shade_moving;
const int8_t tilt = requestedFavoriteTilt(req.tiltPosition, shade.tiltType, shade.myTiltPos, flip);
// Without a lift position only the tilt changes: the lift favorite stays,
// or is set where the shade is when it has none.

View file

@ -40,6 +40,8 @@ struct shade_request_t {
// Whether the request sends anything on air.
bool shadeRequestUsesRadio(shade_request_type_t type);
// Whether the request's id is a group id rather than a shade id.
bool shadeRequestIsForGroup(shade_request_type_t type);
// A repeat count from a client: negative for the default, capped at what fits.
int8_t requestRepeat(int32_t repeat);
// A target from a client: -1 unless it is 0-100.
@ -55,6 +57,7 @@ struct shade_info_t {
float currentPos = 0.0f;
float myPos = -1.0f;
float myTiltPos = -1.0f;
bool idle = true; // not moving, and at its target
};
// The seam between ShadeRequests and the shades and groups it acts on. On the
@ -89,7 +92,9 @@ class ShadeOps {
enum class shade_request_result_t : uint8_t {
done,
not_found, // no shade or group with that id; nothing was done
radio_refused // the radio didn't take a command it sent
radio_refused, // the radio didn't take a command it sent
shade_moving, // a favorite is only set on a shade at rest; nothing was done
no_tilt // only a tilt was given, for a shade without one; nothing was done
};
// Carries out shade requests, whichever entry point they came from: finds

View file

@ -105,6 +105,7 @@ class SomfyShade : public SomfyRemote, public ShadeSettings, private MotorLink,
uint8_t getShadeId() { return shadeId; }
// Where the shade is and where it is going.
const ShadeBehaviour::State &state() const { return this->behaviour.state(); }
bool isIdle() const { return this->behaviour.isIdle(); }
uint8_t getFlags() override;
// Sets the setting and state bits from a saved or sent flags byte.
void setFlags(uint8_t flags);

View file

@ -16,6 +16,7 @@ bool SomfyShadeOps::findShade(uint8_t shadeId, shade_info_t &info) {
info.currentPos = shade->state().currentPos;
info.myPos = shade->state().myPos;
info.myTiltPos = shade->state().myTiltPos;
info.idle = shade->isIdle();
return true;
}
bool SomfyShadeOps::findGroup(uint8_t groupId, uint8_t &repeats) {

View file

@ -170,7 +170,7 @@ static void sendRemoteCommand(AsyncWebSocketClient *client, const proto_message_
const char *bad = parseRemoteCommandOp(d, frame, repeats);
if(bad) { replyError(client, req.id, proto_error_t::badRequest, bad); return; }
if(!somfy.sendFrame(frame, repeats)) {
replyError(client, req.id, proto_error_t::radioUnavailable, "The radio didn't take the command");
replyError(client, req.id, proto_error_t::radioUnavailable, PROTO_RADIO_REFUSED);
return;
}
replyOk(client, req.id);