diff --git a/server/src/hardware/interface/xpressnetinterface.cpp b/server/src/hardware/interface/xpressnetinterface.cpp index 70392f48..979c45fa 100644 --- a/server/src/hardware/interface/xpressnetinterface.cpp +++ b/server/src/hardware/interface/xpressnetinterface.cpp @@ -290,27 +290,41 @@ bool XpressNetInterface::setOnline(bool& value, bool simulation) setState(InterfaceState::Error); online = false; // communication no longer possible }); - m_kernel->setOnNormalOperationResumed( - [this]() + m_kernel->setOnTrackPowerChanged( + [this](bool powerOn, bool isStopped) { - if(!contains(m_world.state.value(), WorldState::PowerOn)) - m_world.powerOn(); - if(!contains(m_world.state.value(), WorldState::Run)) - m_world.run(); - }); - m_kernel->setOnTrackPowerOff( - [this]() - { - if(contains(m_world.state.value(), WorldState::PowerOn)) - m_world.powerOff(); - if(contains(m_world.state.value(), WorldState::Run)) - m_world.stop(); - }); - m_kernel->setOnEmergencyStop( - [this]() - { - if(contains(m_world.state.value(), WorldState::Run)) - m_world.stop(); + if(powerOn) + { + /* NOTE: + * Setting stop and powerOn together is not an atomic operation, + * so it would trigger 2 state changes with in the middle state. + * Fortunately this does not happen because at least one of the state is already set. + * Because if we are in Run state we go to PowerOn, + * and if we are on PowerOff then we go to PowerOn. + */ + + // First of all, stop if we have to, otherwhise we might inappropiately run trains + if(isStopped && contains(m_world.state.value(), WorldState::Run)) + { + m_world.stop(); + } + else if(!contains(m_world.state.value(), WorldState::Run) && !isStopped) + { + m_world.run(); // Run trains yay! + } + + // EmergencyStop in XpressNet also means power is still on + if(!contains(m_world.state.value(), WorldState::PowerOn) && isStopped) + { + m_world.powerOn(); // Just power on but keep stopped + } + } + else + { + // Power off regardless of stop state + if(contains(m_world.state.value(), WorldState::PowerOn)) + m_world.powerOff(); + } }); m_kernel->setDecoderController(this); @@ -324,12 +338,13 @@ bool XpressNetInterface::setOnline(bool& value, bool simulation) m_kernel->setConfig(xpressnet->config()); }); + // Avoid to set multiple power states in rapid succession if(!contains(m_world.state.value(), WorldState::PowerOn)) - m_kernel->stopOperations(); + m_kernel->stopOperations(); // Stop by powering off else if(!contains(m_world.state.value(), WorldState::Run)) - m_kernel->stopAllLocomotives(); + m_kernel->stopAllLocomotives(); // Emergency stop with power on else - m_kernel->resumeOperations(); + m_kernel->resumeOperations(); // Run trains Attributes::setEnabled({type, serialInterfaceType, device, baudrate, flowControl, hostname, port, s88StartAddress, s88ModuleCount}, false); } @@ -386,24 +401,38 @@ void XpressNetInterface::worldEvent(WorldState state, WorldEvent event) switch(event) { case WorldEvent::PowerOff: + { m_kernel->stopOperations(); break; - + } case WorldEvent::PowerOn: - m_kernel->resumeOperations(); - if(!contains(state, WorldState::Run)) - m_kernel->stopAllLocomotives(); + { + if(contains(state, WorldState::Run)) + m_kernel->resumeOperations(); + else + m_kernel->stopAllLocomotives(); // In XpressNet E-Stop means power on but not running break; - + } case WorldEvent::Stop: - m_kernel->stopAllLocomotives(); + { + if(contains(state, WorldState::PowerOn)) + { + // In XpressNet E-Stop means power is on but trains are not running + m_kernel->stopAllLocomotives(); + } + else + { + // This Stops everything by removing power + m_kernel->stopOperations(); + } break; - + } case WorldEvent::Run: + { if(contains(state, WorldState::PowerOn)) m_kernel->resumeOperations(); break; - + } default: break; } diff --git a/server/src/hardware/interface/z21interface.cpp b/server/src/hardware/interface/z21interface.cpp index 48034fd9..22b9c802 100644 --- a/server/src/hardware/interface/z21interface.cpp +++ b/server/src/hardware/interface/z21interface.cpp @@ -215,35 +215,35 @@ bool Z21Interface::setOnline(bool& value, bool simulation) { if(powerOn) { - /* NOTE: - * Setting stop and powerOn together is not an atomic operation, - * so it would trigger 2 state changes with in the middle state. - * Fortunately this does not happen because at least one of the state is already set. - * Because if we are in Run state we go to PowerOn, - * and if we are on PowerOff then we go to PowerOn. - */ + /* NOTE: + * Setting stop and powerOn together is not an atomic operation, + * so it would trigger 2 state changes with in the middle state. + * Fortunately this does not happen because at least one of the state is already set. + * Because if we are in Run state we go to PowerOn, + * and if we are on PowerOff then we go to PowerOn. + */ - // First of all, stop if we have to, otherwhise we might inappropiately run trains - if(isStopped && contains(m_world.state.value(), WorldState::Run)) - { - m_world.stop(); - } - else if(!contains(m_world.state.value(), WorldState::Run) && !isStopped) - { - m_world.run(); // Run trains yay! - } + // First of all, stop if we have to, otherwhise we might inappropiately run trains + if(isStopped && contains(m_world.state.value(), WorldState::Run)) + { + m_world.stop(); + } + else if(!contains(m_world.state.value(), WorldState::Run) && !isStopped) + { + m_world.run(); // Run trains yay! + } - // EmergencyStop in Z21 also means power is still on - if(!contains(m_world.state.value(), WorldState::PowerOn) && isStopped) - { - m_world.powerOn(); // Just power on but keep stopped - } + // EmergencyStop in Z21 also means power is still on + if(!contains(m_world.state.value(), WorldState::PowerOn) && isStopped) + { + m_world.powerOn(); // Just power on but keep stopped + } } else { - // Power off regardless of stop state - if(contains(m_world.state.value(), WorldState::PowerOn)) - m_world.powerOff(); + // Power off regardless of stop state + if(contains(m_world.state.value(), WorldState::PowerOn)) + m_world.powerOff(); } }); diff --git a/server/src/hardware/protocol/xpressnet/kernel.cpp b/server/src/hardware/protocol/xpressnet/kernel.cpp index 7db56f8b..55c238b1 100644 --- a/server/src/hardware/protocol/xpressnet/kernel.cpp +++ b/server/src/hardware/protocol/xpressnet/kernel.cpp @@ -181,84 +181,99 @@ void Kernel::receive(const Message& message) break; } case 0x60: - if(message == NormalOperationResumed()) + { + if(message == TrackPowerOff()) { - if(m_trackPowerOn != TriState::True || m_emergencyStop != TriState::False) - { - m_trackPowerOn = TriState::True; - m_emergencyStop = TriState::False; - - if(m_onNormalOperationResumed) - EventLoop::call( - [this]() - { - m_onNormalOperationResumed(); - }); - } + EventLoop::call( + [this]() + { + if(m_trackPowerOn != TriState::False) + { + m_trackPowerOn = TriState::False; + m_emergencyStop = TriState::False; + if(m_onTrackPowerChanged) + m_onTrackPowerChanged(false, false); + } + }); } - else if(message == TrackPowerOff()) + else if(message == NormalOperationResumed()) { - if(m_trackPowerOn != TriState::False) - { - m_trackPowerOn = TriState::False; - - if(m_onTrackPowerOff) - EventLoop::call( - [this]() - { - m_onTrackPowerOff(); - }); + EventLoop::call( + [this]() + { + if(m_trackPowerOn != TriState::True || m_emergencyStop != TriState::False) + { + m_trackPowerOn = TriState::True; + m_emergencyStop = TriState::False; + if(m_onTrackPowerChanged) + m_onTrackPowerChanged(true, false); + } + }); } - } break; - + } case 0x80: + { if(message == EmergencyStop()) { - if(m_emergencyStop != TriState::True) - { - m_emergencyStop = TriState::True; + EventLoop::call( + [this]() + { + if(m_emergencyStop != TriState::True) + { + m_emergencyStop = TriState::True; + m_trackPowerOn = TriState::True; - if(m_onEmergencyStop) - EventLoop::call( - [this]() - { - m_onEmergencyStop(); - }); - } + if(m_onTrackPowerChanged) + m_onTrackPowerChanged(true, true); + } + }); } break; + } } } void Kernel::resumeOperations() { - m_ioContext.post( - [this]() - { - if(m_trackPowerOn != TriState::True || m_emergencyStop != TriState::False) + assert(isEventLoopThread()); + + if(m_trackPowerOn != TriState::True || m_emergencyStop != TriState::False) + { + m_ioContext.post( + [this]() + { send(ResumeOperationsRequest()); - }); + }); + } } void Kernel::stopOperations() { - m_ioContext.post( - [this]() - { - if(m_trackPowerOn != TriState::False) + assert(isEventLoopThread()); + + if(m_trackPowerOn != TriState::False || m_emergencyStop != TriState::False) + { + m_ioContext.post( + [this]() + { send(StopOperationsRequest()); - }); + }); + } } void Kernel::stopAllLocomotives() { - m_ioContext.post( - [this]() - { - if(m_emergencyStop != TriState::True) + assert(isEventLoopThread()); + + if(m_trackPowerOn != TriState::True || m_emergencyStop != TriState::True) + { + m_ioContext.post( + [this]() + { send(StopAllLocomotivesRequest()); - }); + }); + } } void Kernel::decoderChanged(const Decoder& decoder, DecoderChangeFlags changes, uint32_t functionNumber) @@ -388,7 +403,7 @@ bool Kernel::setOutput(uint16_t address, OutputPairValue value) { assert(isEventLoopThread()); assert(address >= accessoryOutputAddressMin && address <= accessoryOutputAddressMax); - assert(value == OutputPairValue::First || value == OutputPairValue::First); + assert(value == OutputPairValue::First || value == OutputPairValue::Second); m_ioContext.post( [this, address, value]() { diff --git a/server/src/hardware/protocol/xpressnet/kernel.hpp b/server/src/hardware/protocol/xpressnet/kernel.hpp index c2422e98..0fa88b64 100644 --- a/server/src/hardware/protocol/xpressnet/kernel.hpp +++ b/server/src/hardware/protocol/xpressnet/kernel.hpp @@ -54,11 +54,37 @@ class Kernel : public ::KernelBase std::unique_ptr m_ioHandler; const bool m_simulation; - TriState m_trackPowerOn; - TriState m_emergencyStop; - std::function m_onNormalOperationResumed; - std::function m_onTrackPowerOff; - std::function m_onEmergencyStop; + /*! + * \brief m_trackPowerOn caches command station track power state. + * + * \note It must be accessed only from event loop thread or from + * XpressNet::Kernel::start(). + * + * \sa EventLoop + */ + TriState m_trackPowerOn = TriState::Undefined; + + /*! + * \brief m_emergencyStop caches command station emergency stop state. + * + * \note It must be accessed only from event loop thread or from + * XpressNet::Kernel::start(). + * + * \sa EventLoop + */ + TriState m_emergencyStop = TriState::Undefined; + + /*! + * \brief m_onTrackPowerChanged callback is called when XpressNet power state changes. + * + * \note It is always called from event loop thread + * \note First argument is powerOn, second argument is isStopped + * In XpressNet EmergencyStop is really PowerOn + EmergencyStop and + * PowerOn implicitly means Run so we cannot call \sa trackPowerOn() if world must be stopped + * + * \sa EventLoop + */ + std::function m_onTrackPowerChanged; DecoderController* m_decoderController; @@ -135,38 +161,13 @@ class Kernel : public ::KernelBase /** * @brief ... - * * @param[in] callback ... * @note This function may not be called when the kernel is running. */ - inline void setOnNormalOperationResumed(std::function callback) + inline void setOnTrackPowerChanged(std::function callback) { assert(!m_started); - m_onNormalOperationResumed = std::move(callback); - } - - /** - * @brief ... - * - * @param[in] callback ... - * @note This function may not be called when the kernel is running. - */ - inline void setOnTrackPowerOff(std::function callback) - { - assert(!m_started); - m_onTrackPowerOff = std::move(callback); - } - - /** - * @brief ... - * - * @param[in] callback ... - * @note This function may not be called when the kernel is running. - */ - inline void setOnEmergencyStop(std::function callback) - { - assert(!m_started); - m_onEmergencyStop = std::move(callback); + m_onTrackPowerChanged = std::move(callback); } /** diff --git a/server/src/hardware/protocol/xpressnet/messages.cpp b/server/src/hardware/protocol/xpressnet/messages.cpp index 2932e212..63640f74 100644 --- a/server/src/hardware/protocol/xpressnet/messages.cpp +++ b/server/src/hardware/protocol/xpressnet/messages.cpp @@ -44,13 +44,61 @@ bool isChecksumValid(const Message& msg, const int dataSize) return calcChecksum(msg, dataSize) == *(reinterpret_cast(&msg) + dataSize + 1); } -std::string toString(const Message& message) +std::string toString(const Message& message, bool raw) { - std::string s; + std::string s = toHex(message.identification()); // Human readable: switch(message.header) { + case 0x21: + { + if(message == ResumeOperationsRequest()) + { + s = "RESUME_OPERATIONS_REQUEST"; + } + else if(message == StopOperationsRequest()) + { + s = "STOP_OPERATIONS_REQUEST"; + } + else + raw = true; + break; + } + case 0x61: + { + if(message == NormalOperationResumed()) + { + s = "NORMAL_OPERATIONS_RESUMED"; + } + else if(message == TrackPowerOff()) + { + s = "TRACK_POWER_OFF"; + } + else + raw = true; + break; + } + case 0x80: + { + if(message == StopAllLocomotivesRequest()) + { + s = "STOP_ALL_LOCO_REQUEST"; + } + else + raw = true; + break; + } + case 0x81: + { + if(message == EmergencyStop()) + { + s = "EMERGENCY_STOP"; + } + else + raw = true; + break; + } case 0x52: { const auto& req = static_cast(message); @@ -60,13 +108,21 @@ std::string toString(const Message& message) s.append(req.activate() ? " activate" : " deactivate"); break; } + default: + { + raw = true; + break; + } // FIXME: add all messages } - // Raw data: - s.append(" ["); - s.append(toHex(reinterpret_cast(&message), message.size(), true)); - s.append("]"); + if(raw) + { + // Raw data: + s.append(" ["); + s.append(toHex(reinterpret_cast(&message), message.size(), true)); + s.append("]"); + } return s; } diff --git a/server/src/hardware/protocol/xpressnet/messages.hpp b/server/src/hardware/protocol/xpressnet/messages.hpp index a198d103..e22e8212 100644 --- a/server/src/hardware/protocol/xpressnet/messages.hpp +++ b/server/src/hardware/protocol/xpressnet/messages.hpp @@ -27,7 +27,10 @@ #include #include #include -#include "../../../enum/direction.hpp" +#include +#include "../../../utils/packed.hpp" +#include "../../../utils/endian.hpp" +#include "../../../utils/byte.hpp" namespace XpressNet { @@ -37,6 +40,7 @@ constexpr uint16_t longAddressMin = 100; constexpr uint16_t longAddressMax = 9999; constexpr uint8_t idFeedbackBroadcast = 0x40; +constexpr uint8_t idLocomotiveBusy = 0xE4; struct Message; @@ -48,8 +52,10 @@ void updateChecksum(Message& msg); inline bool isChecksumValid(const Message& msg); bool isChecksumValid(const Message& msg, const int dataSize); -std::string toString(const Message& message); +std::string toString(const Message& message, bool raw = true); +// Chapters are based on: +// Lenz Dokumentation XpressNet Version 4.0 02/2022 struct Message { uint8_t header; @@ -77,9 +83,10 @@ struct Message { return 2 + dataSize(); } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(Message) == 1); +// 2.4.1 struct NormalOperationResumed : Message { uint8_t db1 = 0x01; @@ -89,9 +96,10 @@ struct NormalOperationResumed : Message { header = 0x61; } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(NormalOperationResumed) == 3); +// 2.4.2 struct TrackPowerOff : Message { uint8_t db1 = 0x00; @@ -101,9 +109,10 @@ struct TrackPowerOff : Message { header = 0x61; } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(TrackPowerOff) == 3); +// 2.4.3 struct EmergencyStop : Message { uint8_t db1 = 0x00; @@ -113,9 +122,35 @@ struct EmergencyStop : Message { header = 0x81; } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(EmergencyStop) == 3); +// 2.13 +struct CommandStationBusy : Message +{ + uint8_t db1 = 0x81; + uint8_t checksum = 0xE0; + + CommandStationBusy() + { + header = 0x61; + } +} ATTRIBUTE_PACKED; +static_assert(sizeof(CommandStationBusy) == 3); + +// 2.14 +struct CommandUnknown : Message +{ + uint8_t db1 = 0x82; + uint8_t checksum = 0xE3; + + CommandUnknown() + { + header = 0x61; + } +} ATTRIBUTE_PACKED; +static_assert(sizeof(CommandUnknown) == 3); + struct FeedbackBroadcast : Message { struct Pair @@ -180,7 +215,7 @@ struct FeedbackBroadcast : Message else data &= ~static_cast(1 << index); } - }; + } ATTRIBUTE_PACKED; static_assert(sizeof(Pair) == 2); constexpr uint8_t pairCount() const @@ -205,8 +240,9 @@ struct FeedbackBroadcast : Message assert(index < pairCount()); return *(reinterpret_cast(&header + sizeof(header)) + index); } -}; +} ATTRIBUTE_PACKED; +// 3.2 struct ResumeOperationsRequest : Message { uint8_t db1 = 0x81; @@ -216,9 +252,10 @@ struct ResumeOperationsRequest : Message { header = 0x21; } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(ResumeOperationsRequest) == 3); +// 3.3 struct StopOperationsRequest : Message { uint8_t db1 = 0x80; @@ -228,9 +265,10 @@ struct StopOperationsRequest : Message { header = 0x21; } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(StopOperationsRequest) == 3); +// 3.4 struct StopAllLocomotivesRequest : Message { uint8_t checksum = 0x80; @@ -239,9 +277,10 @@ struct StopAllLocomotivesRequest : Message { header = 0x80; } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(StopAllLocomotivesRequest) == 2); +// 3.7 Emergency stop locomotive (from Central version 3.0) struct EmergencyStopLocomotive : Message { uint8_t addressHigh; @@ -264,7 +303,7 @@ struct EmergencyStopLocomotive : Message addressLow = address & 0x7f; } } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(EmergencyStopLocomotive) == 4); struct LocomotiveInstruction : Message @@ -279,17 +318,27 @@ struct LocomotiveInstruction : Message if(address >= longAddressMin) { assert(address >= longAddressMin && address <= longAddressMax); - addressHigh = 0xc0 | address >> 8; - addressLow = address & 0xff; + addressHigh = 0xC0 | address >> 8; + addressLow = address & 0xFF; } else { assert(address >= shortAddressMin && address <= shortAddressMax); addressHigh = 0x00; - addressLow = address & 0x7f; + addressLow = address & 0x7F; } } -}; + + inline uint16_t address() const + { + return (static_cast(addressHigh & 0x3F) << 8) | addressLow; + } + + inline bool isLongAddress() const + { + return (addressHigh & 0xC0) == 0xC0; + } +} ATTRIBUTE_PACKED; static_assert(sizeof(LocomotiveInstruction) == 4); struct SpeedAndDirectionInstruction : LocomotiveInstruction @@ -305,7 +354,7 @@ struct SpeedAndDirectionInstruction : LocomotiveInstruction if(direction == Direction::Forward) speedAndDirection |= 0x80; } -}; +} ATTRIBUTE_PACKED; struct SpeedAndDirectionInstruction14 : SpeedAndDirectionInstruction { @@ -320,7 +369,7 @@ struct SpeedAndDirectionInstruction14 : SpeedAndDirectionInstruction speedAndDirection |= 0x10; checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct SpeedAndDirectionInstruction27 : SpeedAndDirectionInstruction { @@ -333,7 +382,7 @@ struct SpeedAndDirectionInstruction27 : SpeedAndDirectionInstruction speedAndDirection |= (((speedStep + 1) & 0x01) << 4) | ((speedStep + 1) >> 1); checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct SpeedAndDirectionInstruction28 : SpeedAndDirectionInstruction { @@ -346,7 +395,7 @@ struct SpeedAndDirectionInstruction28 : SpeedAndDirectionInstruction speedAndDirection |= (((speedStep + 1) & 0x01) << 4) | ((speedStep + 1) >> 1); checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct SpeedAndDirectionInstruction128 : SpeedAndDirectionInstruction { @@ -359,7 +408,7 @@ struct SpeedAndDirectionInstruction128 : SpeedAndDirectionInstruction speedAndDirection |= speedStep + 1; checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct FunctionInstructionGroup : LocomotiveInstruction { @@ -372,7 +421,7 @@ struct FunctionInstructionGroup : LocomotiveInstruction assert(group >= 1 && group <= 5); identification = (group == 5) ? 0x28 : (0x1F + group); } -}; +} ATTRIBUTE_PACKED; struct FunctionInstructionGroup1 : FunctionInstructionGroup { @@ -392,7 +441,7 @@ struct FunctionInstructionGroup1 : FunctionInstructionGroup checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct FunctionInstructionGroup2 : FunctionInstructionGroup { @@ -410,7 +459,7 @@ struct FunctionInstructionGroup2 : FunctionInstructionGroup checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct FunctionInstructionGroup3 : FunctionInstructionGroup { @@ -428,7 +477,7 @@ struct FunctionInstructionGroup3 : FunctionInstructionGroup checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct FunctionInstructionGroup4 : FunctionInstructionGroup { @@ -454,7 +503,7 @@ struct FunctionInstructionGroup4 : FunctionInstructionGroup checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; struct FunctionInstructionGroup5 : FunctionInstructionGroup { @@ -480,7 +529,7 @@ struct FunctionInstructionGroup5 : FunctionInstructionGroup checksum = calcChecksum(*this); } -}; +} ATTRIBUTE_PACKED; /* struct setFunctionStateGroup : LocomotiveInstruction @@ -495,9 +544,23 @@ struct setFunctionStateGroup : LocomotiveInstruction identification = 0x23 + group; addressLowHigh(address, addressLow, addressHigh); } -} __attribute__((packed)); +} ATTRIBUTE_PACKED; */ +// 2.19.7 Locomotive is Occupied (from Central version 3.0) +struct LocomotiveBusy : LocomotiveInstruction +{ + uint8_t checksum; + + LocomotiveBusy(uint16_t address) + : LocomotiveInstruction(address) + { + identification = idLocomotiveBusy; + checksum = calcChecksum(*this); + } +} ATTRIBUTE_PACKED; +static_assert(sizeof(LocomotiveBusy) == 5); + struct AccessoryDecoderOperationRequest : Message { static constexpr uint8_t db2Port = 0x01; @@ -539,7 +602,7 @@ struct AccessoryDecoderOperationRequest : Message { return db2 & db2Activate; } -}; +} ATTRIBUTE_PACKED; static_assert(sizeof(AccessoryDecoderOperationRequest) == 4); namespace RocoMultiMAUS @@ -573,7 +636,7 @@ namespace RocoMultiMAUS checksum = calcChecksum(*this); } - }; + } ATTRIBUTE_PACKED; } namespace RoSoftS88XpressNetLI @@ -597,7 +660,7 @@ namespace RoSoftS88XpressNetLI assert((startAddress >= startAddressMin && startAddress <= startAddressMax) || startAddress == startAddressGet); checksum = calcChecksum(*this); } - }; + } ATTRIBUTE_PACKED; struct S88ModuleCount : Message { @@ -618,7 +681,7 @@ namespace RoSoftS88XpressNetLI assert((moduleCount >= moduleCountMin && moduleCount <= moduleCountMax) || moduleCount == moduleCountGet); checksum = calcChecksum(*this); } - }; + } ATTRIBUTE_PACKED; } inline uint8_t calcChecksum(const Message& msg)