diff --git a/docs/Modbus_Application_Protocol_V1_1b3.pdf b/docs/Modbus_Application_Protocol_V1_1b3.pdf new file mode 100644 index 0000000..b3c61ca Binary files /dev/null and b/docs/Modbus_Application_Protocol_V1_1b3.pdf differ diff --git a/docs/Modbus_POE_ETH_Relay.md b/docs/Modbus_POE_ETH_Relay.md new file mode 100644 index 0000000..9b00629 --- /dev/null +++ b/docs/Modbus_POE_ETH_Relay.md @@ -0,0 +1,514 @@ +# Modbus POE ETH Relay + +Parsed from https://www.waveshare.com/wiki/Modbus_POE_ETH_Relay + +# Overview + +## Hardware Description + +### Hardware Connection + +- Connect the Modbus POE ETH Relay to the LAN via a network cable, and supply power through the power port or the POE. + + +### Electrical and Relay Safety Instructions + +- This product must be operated by professional electricians or qualified personnel. During use, ensure electrical safety, leakage protection, and proper insulation. +- Before installing, maintaining, or replacing the relay device, always turn off the power and unplug the device. +- Do not attempt to disassemble the relay device to avoid damage or the risk of electric shock. +- Properly install and place the relay device. Do not use it in humid, overheated, flammable, or explosive environments to prevent accidents caused by improper installation or use. + +#### 1. Load Matching + +- Ensure the relay's rated voltage and current match the load. Do not exceed the rated capacity. +- For inductive loads (motors, coils, lamps, etc.), the starting current may be much higher than the rated current. Choose a relay with sufficient current margin. + +#### 2. Short Circuit and Overcurrent Protection + +- Install a **fuse** or **circuit breaker** in the relay circuit to prevent damage due to short circuits or accidental overcurrent. - Ensure the load circuit has no short circuits during wiring, and select protection components with appropriate current ratings if necessary. + +#### 3. Arc and Switching Protection + +- Relay switching generates arcs, which can cause contact wear or welding. +- For inductive loads, it is recommended to use **RC snubber circuits** or **varistors** for arc suppression. + +#### 4. Installation Environment + +- Do not use the relay in humid, high-temperature, flammable, explosive, or dusty environments. +- Install the relay securely to avoid vibrations or shocks that may cause misoperation or damage. + +#### 5. Power-Off Operation + +- Always cut off power before maintenance, wiring, or replacing the relay to ensure personnel and device safety. +- Latching relays are only powered when changing state. Avoid strong vibrations or strong magnetic fields while the relay is unpowered. + +#### 6. Status Confirmation + +- After powering on, confirm or reset the relay status as needed to prevent abnormal operation caused by transportation, installation, or external disturbances. +- Avoid power interruption during relay operation to prevent uncertain status or contact damage. + +#### 7. Regular Inspection + +- Periodically inspect relay contacts, terminals, and insulation to ensure proper operation. +- If abnormal heating, odor, or burn marks are detected, immediately cut off power and replace the relay. + + +### Indicator Light Description + +| Indicator | Status description | +| RUN indicator | Ethernet port running indicator, outputs a square wave with a period of 2 seconds when the Ethernet port working normally. | +| STA indicator | MCU indicator, blinking when the MCU working normally. | +| TXD indicator | Send indicator, lights up when sending data. | +| RXD indicator | Receive indicator, lights up when receiving data. | +| Green indicator on Ethernet port | The green indicator will be on when TCP connection is established, which can be used to determine whether the module has established a communication link with the host software. | +| Yellow indicator on Ethernet port | Data activity indicator, when data is transmitted through the Ethernet port, the yellow indicator changes its state, which can be used to determine if there is data transmission | + +# Module Parameter Configuration + +The module needs to set the module parameters before communication, such as IP address, serial port format, Modbus protocol, etc. There are two modes of setting parameters: Vircom software configuration and web configuration. + +Vircom software configuration allows for setting more parameters, but requires software installation. Web configuration does not require installation, but you need to know the IP address first, and the configuration parameters are few. It is recommended to use Virom for configuration. + +Note: + +1. The configuration can be done in any way, and it is recommended to use Virom software for first test. + +2. It is recommended to modify only the IP address for the first configuration, other parameters are not recommended to be modified. The serial port parameters must be the default parameters; modifying the serial port parameters will result in no communication. + +3. The module supports both Modbus RTU and Modbus TCP protocols. In the Advanced Settings -> Transfer Protocol, you can choose "None", which means the Modbus RTU protocol. It is not recommended to modify during the first configuration. + +4. The selected Modbus TCP protocol must be configured using the Virom software and set to a non-storage Modbus gateway, otherwise the communication will not be normal. + +## Virom Software Mode Configuration + +### General Settings + +Connect the module to the hardware and connect it to the network. Run the VirCom software (the computer on which Vircom is installed must be on the same LAN as the module). + +The operation is as follows: + +- 1. Click `Device` +- 2. Click `Auto Search` +- 3. Software search recognizes the device connected to the LAN +- 4. Select the device, and then click `Edit Device` or double-click the searched device directly - 5. Set up the device parameters: + - Modify the "IP mode" to a static assigned address, set the IP address, note that the static IP address entered must not be used by other devices, and it needs to be on the same LAN as the computer. + - The working mode is TCP server. The serial port setting is 115200 by default and cannot be modified. + - The "Transfer Protocol" in "Advanced Settings" defaults to "None", which means using the Modbus RTU protocol; if you select "Modbus TCP protocol", then use the Modbus TCP communication protocol. + - Click on "More Advanced Settings..." and select the Modbus Gateway Type as a non-storage Modbus gateway. + - Modify the "IP mode" to a static assigned address, set the IP address, note that the static IP address entered must not be used by other devices, and it needs to be on the same LAN as the computer. +- 6. Once the settings are complete, click `Modify Setting` +- 7. Click `Restart Dev`, wait for the module to restart, and the new settings will take effect. + +Note: It is recommended to modify only the IP address for the first configuration, and do not modify other parameters. + +See the figure below for details: + + +Note: The default Modbus gateway type is storage type, which will automatically send query commands several times, which may cause the controller chip to fail to respond, resulting in no response to the query commands. Therefore, you need to set it as Multi-host non-storage type. + + + +### Protocol Setting + +Note: It is recommended to use the default Modbus RTU protocol for the first configuration and no modifications are needed. + +Although the module transmits data through the network port, it supports two Modbus protocols: Modbus RTU and Modbus TCP. By default, data is transparently transmitted, i.e. using the Modbus RTU protocol. + + +#### Modbus TCP Protocol Settings + +- The "Transfer Protocol" in the "Advanced Settings" can be set to "Modbus TCP protocol". In this case, the Modbus RTU protocol of the main controller will be converted to the Modbus TCP protocol and transmitted through the network port. +- In this case, the device port automatically changes to 502. Users can use the Modbus TCP tool to connect to the IP port 502 of the serial port server. +- Click on "More Advanced Settings..." and select the Modbus Gateway Type as a non-storage Modbus gateway. + +#### Modbus RTU Protocol Settings + +- Set "Transfer Protocol" in the "Advanced Settings" to "None", and change to use Modbus RTU protocol. +- Click on "More Advanced Settings..." and select the Modbus Gateway Type as a non-storage Modbus gateway. + +Note: The default Modbus gateway type is storage type, which will automatically send query commands several times, which may cause the controller chip to fail to respond, resulting in no response to the query commands. Therefore, you need to set it as Multi-host non-storage type. + + +### Virtual Serial Port Setting + +The module transmits data through a network port (TCP/UDP protocol). In order to enable users to use the PoE port communication even with developed serial port software, a virtual serial port needs to be added. If not needed, this part can be skipped. + + +- First, install the virtual serial driver Virtual serial port driver +- Run Vircom and the user program on the same computer. +- Vircom creates a virtual COM port and connects this COM port to the serial server. When the user program uses the COM communication, it can send data to the user's serial port device through the Vircom serial port server. + +The following steps demonstrate this operation: + + +- Click on "Serial Port & Device Management" on the Vircom main interface, then click "Add" and select to add COM2 (Among them, COM2 is the newly emerging COM port on the computer). + +- Then enter the device management and double-click the device that needs to be bound to COM2. As shown in the diagram, select COM2 from the Virtual Serial Port list in the top left corner. Then click on "Modify Setting" and then click on "Restart Device". + +- Return to the main interface of Vircom. It can be seen that COM2 has been connected to the device whose IP is 192.168.1.200. In this case, the virtual serial port COM2 can be used instead of the network port for communication. + +## WEB Configuration + +Using Vircom, you can search for and configure device parameters in different network segments. For Web configuration, you must first ensure that the computer and the serial server are in the same IP segment, and you need to know the IP address of the serial server in advance. + +But Web configuration can be done on any computer without Vircom. (Different products have different web interfaces, which can be switched between Chinese and English) + +1. Enter the IP address of the serial server in the browser, such as http://192.168.1.200 to open the following web page + + +2. In the Password field, enter your password: The default login password is not set or is set to 123456. If no password is set, you can enter any password and click the Login button to log in. After setting the password to log in, the settings at "Modify Web Login Key" will take effect: + + +3. The serial server parameters can be modified on the web page that appears. + +4. After modifying the parameters, click the "Submit" button. + +Attention: The system has added webpage settings function by default when it leaves the factory. If the configuration interface page file is overwritten and the webpage cannot be opened, the webpage file needs to be downloaded again. + +Please refer to RS485 TO ETH (B) Manual + +# Example Demonstration + +The demo shows how the following two software operate. + +SSCOM serial port debugging assistant is more convenient to operate, free of installation, and more convenient for complete display and analysis of instructions, but the disadvantage is that the data is not intuitive. + +Modbus Poll software is directly operated on the register, and the data display is more convenient to observe, but the disadvantage is that the instruction is not displayed completely, so you need to be familiar with the Modbus register operation. + +You can test using any method. It is recommended to use the SSCOM serial port debugging assistant software for the first test. + + +### SSCOM Serial Port Debugging Assistant + +Modbus RTU Command: The default configuration is the Modbus RTU command + +- 1. Open the serial port debugging assistant window +- 2. Select TCPClient for port number +- 3. Modify the remote IP and port number according to the Vircom settings above +- 4. Click the "Connect" button to connect to the TCP server +- 5. The green light of the network port will light up when the connection is successful +- 6. Click Multi-Char to open the Send Multi-Char window, the default display is the Modbus RTU command, click the corresponding function to send the corresponding command. +- 7. If you use the custom input box below to send the command, you need to set Verify as ModbusCRC16 + +Configure Modbus TCP Directives: If you want to set it as a Modbus TCP Directive, you need to change the commands + +- 1. Click on the Import ini button in the Send Multi-Char column +- 2. Select the modbus tcp.ini file to import the Modbus TCP command + + Note: If a popup error message says "A component named HEX0 already exists", then you need to close and reopen the software, which will reload the files and refresh the buttons. + +- 3. After successful import, the following is displayed, click on the function to send the corresponding command. + +Note: Modbus tcp does not require CRC checksum, select None for Verify. + +- For detailed Modubs commands, please see the development protocol. + +### Modbus Poll Software + +It is not convenient to use the SSCOM software for observing the data, you can select Modbus Poll software to read the data. Download and install the Modbus Poll software. + +- 1. Open Modbus Poll software +- 2. Select Setup->Read/Write Definition, select the actual device address for Slave ID, select 01 Read Coils function code for Function, and change Quantity to 8 channels. Click OK to confirm. +- 3. If: + - you are using the Modbus RTU protocol, select Connection->Connect Setup, select Modbus RTU/ASCII Over TCP/IP for Connection, select RTU for Mode, and enter the correct IP address and port number. Click OK to connect. + - you are using the Modbus TCP protocol, select Connection->Connect Setup, select Modbus TCP/IP for Connection, and enter the correct IP address and port number. Click OK to connect. + +4. After the connection is normal, you can check the current relay status. Select the corresponding channel, then double-click the status value to pop up the send page. Choose On or Off, then Click Send to control the relay opening and closing. + +# Demo + +### Raspberry Pi + +Connect the Raspberry Pi and the ModBus POE ETH Relay module to the same LAN. + +Open the Raspberry Pi terminal and run the program by entering the following command: + +```sh +sudo apt-get install unzip +wget https://files.waveshare.com/wiki/Modbus-POE-ETH-Relay/Modbus_POE_ETH_Relay_Code.zip +unzip Modbus_POE_ETH_Relay_Code.zip +cd Modbus_POE_ETH_Relay_Code + +#modbus rtu protocol +vi modbus_rtu.py #Change the IP address and port number according to the actual situation +sudo python3 modbus_rtu.py + +#modbus tcp protocol +vi modbus_tcp.py #Change the IP address and port number according to the actual situation +sudo python3 modbus_tcp.py +``` + +Note: To run this demo, you need to modify the demo file to change the IP address and port number to the actual IP address and port number of the ModBus POE ETH Relay. + + +# Modbus RTU Development Protocol V2 + +## Function Code Introduction + +| Function Code | Description | Note | +|---------------|-----------------------|-------------------------------| +| 01 | Read coil status | Read relay status | +| 03 | Read holding register | Read the address and version | +| 05 | Write single coil | Write single relay | +| 06 | Write single register | Set the baud rate and address | +| 0F | Write multiple coils | Write all relays | + +## Register Address Introduction + +| Address (HEX) | Address storage content | Register value | Permission | Modbus Function Code | +|------------------+----------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------+------------+----------------------| +| 0x0000 …… 0x0007 | Channel 1~8 relay address | 0xFF00: relay on / 0x0000: relay off / 0x5500: relay toggle | Read/Write | 0x01, 0x05, 0x0F | +| 0x00FF | Control all relays | 0xFF00: all relays on / 0x0000: all relays off / 0x5500: all relays toggle | Write | 0x05 | +| 0x0100 …… 0x0107 | Channel 1~8 relay toggle address | 0xFF00: relay toggle / 0x0000: relay unchanged | Write | 0x05, 0x0F | +| 0x01FF | Control all relays toggle | 0xFF00: all relays toggle / 0x0000: all relays unchanged | Write | 0x05 | +| 0x0200 …… 0x0207 | Channel 1~8 relay flash on | Interval time: data×100ms / Value: 0x0007, Interval time: 7×100MS = 700MS | Write | 0x05 | +| 0x0400 …… 0x0407 | Channel 1~8 relay flash off | Interval time: data×100ms / Value: 0x0007, Interval time: 7×100MS = 700MS | Write | 0x05 | +| 4x4000 | Device Address | Directly store Modbus address / Device address: 0x0001 | Read | 0x03 | +| 4x8000 | Software Version | Converting to decimal and then shifting the decimal point two places to the left will represent the software version / 0x0064 = 100 = V1.00 | Read | 0x03 | + +## Operation Command Introduction + +### Control Single Relay + +Send code: 01 05 00 00 FF 00 8C 3A + +| Field | Description | Note | +|-------|----------------|-------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Relay control | +| 00 00 | Address | The register address of the relay to be controlled, 0x0000-0x0007 | +| FF 00 | Command | 0xFF00: relay on; 0x0000: relay off; 0x5500: relay toggle | +| 8C 3A | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +Return code: 01 05 00 00 FF 00 8C 3A + +| Field | Description | Note | +|-------|----------------|-------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Relay control | +| 00 00 | Address | The register address of the relay to be controlled, 0x0000-0x0007 | +| FF 00 | Command | 0xFF00: relay on; 0x0000: relay off; 0x5500: relay toggle | +| 8C 3A | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +For example: +[Address 1 device]: + +``` +Relay 0 on: 01 05 00 00 FF 00 8C 3A +Relay 0 off: 01 05 00 00 00 00 CD CA +Relay 1 on: 01 05 00 01 FF 00 DD FA +Relay 1 off: 01 05 00 01 00 00 9C 0A +Relay 2 on: 01 05 00 02 FF 00 2D FA +Relay 2 off: 01 05 00 02 00 00 6C 0A +Relay 3 on: 01 05 00 03 FF 00 7C 3A +Relay 3 off: 01 05 00 03 00 00 3D CA +Relay 0 toggle: 01 05 00 00 55 00 F2 9A +Relay 1 toggle: 01 05 00 01 55 00 A3 5A +Relay 2 toggle: 01 05 00 02 55 00 53 5A +Relay 3 toggle: 01 05 00 03 55 00 02 9A +``` + +### Control All Relays + +Send code: 01 05 00 FF FF 00 BC 0A + +| Field | Description | Note | +|-------|----------------|-----------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Relay control | +| 00 FF | Address | Fixed 0x00FF | +| FF 00 | Command | 0xFF00: relay on; 0x0000: relay off; 0x5500: relay toggle | +| BC 0A | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +Return code: 01 05 00 FF FF 00 BC 0A + +| Field | Description | Note | +|-------|----------------|-----------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Relay control | +| 00 FF | Address | Fixed 0x00FF | +| FF 00 | Command | 0xFF00: relay on; 0x0000: relay off; 0x5500: relay toggle | +| BC 0A | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +For example: [Address 1 device]: + +All relays on: 01 05 00 FF FF 00 BC 0A All relays off: 01 05 00 FF 00 00 FD FA All relays toggle: 01 05 00 FF 55 00 C2 AA + +### Read Relay Status + +Send code: 01 01 00 00 00 08 3D CC + +| Field | Description | Note | +|-------|---------------------|-------------------------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 01 | 01 Command | Query relay status | +| 00 00 | Relay Start Address | The register address of the relay, 0x0000 - 0x0007 | +| 00 08 | Relay Number | The number of relays to be read, which must not exceed the maximum number of relays | +| 3D CC | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +Receive code: 01 01 01 00 51 88 + +| Field | Description | Note | +|-------|----------------|---------------------------------------------------------------------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 01 | 01 Command | Query relay status | +| 01 | Byte Number | The number of all bytes of the returned status information | +| 00 | Query status | Received relay status Bit0: the first relay status; Bit1: the second relay status; And so on, with the idle high bit being zero | +| 51 88 | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +For example: [Address 1 device] + +Send: 01 01 00 00 00 08 3D CC Receive: 01 01 01 00 51 88 //All relays off Send: 01 01 00 00 00 08 3D CC Receive: 01 01 01 01 90 48 //Relay 0 is on, others are off Send: 01 01 00 00 00 08 3D CC Receive: 01 01 01 41 91 B8 //Relay 0 and 6 are on, others are off + +### Write Relay Status + +Send code: 01 0F 00 00 00 08 01 FF BE D5 + +| Field | Description | Note | +|-------|---------------------|-----------------------------------------------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 0F | 0F Command | Write relay status | +| 00 00 | Relay Start Address | The register address of the relay to be controlled, 0x0000 - 0x0007 | +| 00 08 | Relay Number | The number of relays to be operated, which must not exceed the maximum number of relays | +| 01 | Byte Number | The byte number of the status | +| FF | Relay Status | Bit0: the first relay status; Bit1: the second relay status; And so on, with the idle high bit being zero | +| BE D5 | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +Receive code: 01 0F 00 00 00 08 54 0D + +| Field | Description | Note | +|-------|---------------------|---------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 0F | 0F Command | Control all registers | +| 00 00 | Relay Start Address | The register address of the relay to be controlled, 0x0000 - 0x0007 | +| 00 08 | Relay Number | The number of relays to be operated | +| 54 0D | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +For example: [Address 1 device] + +All relays on: 01 0F 00 00 00 08 01 FF BE D5 All relays off: 01 0F 00 00 00 08 01 00 FE 95 0-1 on; 2-7 off: 01 0F 00 00 00 08 01 03 BE 94 + +### Relay Flash ON/OFF Command + +Send code: 01 05 02 00 00 07 8D B0 + +| Field | Description | Note | +|-------|----------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Single control command | +| 02 | Command | 02: flash on, 04: flash off | +| 00 | Relay Address | The address of the relay to be controlled, 0x00~0x07 | +| 00 07 | Interval Time | The interval time: data*100ms Value: 0x0007, Interval time: 7*100MS = 700MS The maximum setting for the flash-on flash-off time is 0x7FFF | +| 8D B0 | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +Receive code: 01 05 02 00 00 07 8D B0 + +| Field | Description | Note | +|-------|----------------|-----------------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Single control command | +| 02 | Command | 02: flash on, 04: flash off | +| 00 | Relay Address | The address of the relay to be controlled, 0x00~0x07 | +| 00 07 | Interval Time | The interval time: data*100ms Value: 0x0007, Interval time: 7*100MS = 700MS | +| 8D B0 | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +For example: [Address 1 device] + +Relay 0 flash on: 01 05 02 00 00 07 8D B0 //700MS Relay 1 flash on: 01 05 02 01 00 08 9C 74 //800MS Relay 0 flash off: 01 05 04 00 00 05 0C F9 //500MS Relay 1 flash off: 01 05 04 01 00 06 1D 38 //600MS + +### Read Software Version Command + +Send code: 01 03 80 00 00 01 AD CA + +| Field | Description | Note | +|-------|------------------|-------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 03 | 03 Command | Read Holding Register | +| 80 00 | Command register | 0x8000: read software version | +| 00 01 | Byte Number | Fixed 0x0001 | +| AD CA | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +Receive code: 01 03 02 00 C8 B9 D2 + +| Field | Description | Note | +|-------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 03 | 03 Command | Read Holding Register | +| 02 | Byte Number | The number of bytes returned | +| 00 C8 | Software Version | Converting to decimal and then shifting the decimal point two places to the left will represent the software version 0x00C8 = 200 = V2.00 | +| B9 D2 | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +For example: + + +Send: 01 03 80 00 00 01 AD CA Receive: 01 03 02 00 C8 B9 D2 //0x00C8 = 200 =V2.00 + +### Exception Function Code + +When the received command is incorrect or the device is abnormal, an exception response will be returned in the following format: + +Receive: 01 85 03 02 91 + +| Field | Description | Note | +|-------|-------------------------|------------------------------------------------------------------------------| +| 01 | Device Address | 0x00 indicates the broadcast address, 0x01-0xFF indicates the device address | +| 85 | Exception Function Code | Exception function code = Request function code + 0x80 | +| 03 | Byte Number | Exception Code | +| 02 91 | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +An exception code is a single-byte value that indicates the type of error. Several commonly used exception codes defined by the Modbus protocol: + +| Exception Code | Name | Description | +|----------------|----------------------|-------------------------------------------------------------------------| +| 0x01 | Illegal Function | The requested function code is not supported | +| 0x02 | Illegal Data Address | The requested data address is incorrect | +| 0x03 | Illegal Data Value | The requested data value or operation cannot be executed | +| 0x04 | Server Failure | Server equipment failure | +| 0x05 | Response | The request has been received and is being processed | +| 0x06 | Device Busy | The device is currently busy and cannot perform the requested operation | + +## Modbus TCP Command Introduction + +Here is a brief introduction to Modbus TCP and Modbus RTU protocol conversion using the above commands to open the first relay as an example. + + +- Modbus RTU command: 01 05 00 00 FF 00 8C 3A + +| Field | Description | Note | +|-------|----------------|------------------------------------------------------------------------------------| +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Relay control | +| 00 00 | Address | The register address of the relay to be controlled, 0x00, that is, the first relay | +| FF 00 | Command | 0xFF00: Relay on | +| 8C 3A | CRC16 | The CRC16 checksum of the first 6 bytes of data | + +- Modbus TCP command: 00 00 00 00 00 06 01 05 00 00 FF 00 + +| Field | Description | Note | +|-------|----------------|------------------------------------------------------------------------------------| +| 00 00 | Message Label | Both be 0x00 | +| 00 00 | modbus Label | Must both be 0, which means this is Modbus communication | +| 00 06 | Byte Length | Indicates the number of all bytes that follow, followed by 6 bytes | +| 01 | Device Address | Fixed 0x01 | +| 05 | 05 Command | Relay control | +| 00 00 | Address | The register address of the relay to be controlled, 0x00, that is, the first relay | +| FF 00 | Command | 0xFF00: Relay on | + +By comparing the commands above, we can observe that to convert a Modbus RTU command to Modbus TCP protocol, the CRC check is removed, and the command is prefixed with five 0x00 bytes followed by a byte representing the length. + + +## Advanced Applications + +- Relay control through Alibaba Cloud MQTT +- Relay control through Waveshare Cloud +- Relay control through HTTP GET/POST + +# Resources + +### Software + +- Vircom configuration software +- Virtual serial port driver +- Sscom software +- Modbus Poll software +- SecureCRT software diff --git a/specs/001-modbus-relay-control/data-model.md b/specs/001-modbus-relay-control/data-model.md new file mode 100644 index 0000000..5447aa1 --- /dev/null +++ b/specs/001-modbus-relay-control/data-model.md @@ -0,0 +1,1031 @@ +# Data Model: Modbus Relay Control System + +**Created**: 2025-01-09 +**Feature**: [spec.md](./spec.md) +**Related**: [types-design.md](./types-design.md) - Domain type definitions +**Status**: Design + +## Overview + +This document defines the data model for the Modbus relay control system, including database schemas, API data transfer objects (DTOs), serialization formats, and persistence layer structures. This complements `types-design.md` which defines domain types and validation logic. + +## Scope + +**This document covers**: +- SQLite database schemas for persistent storage +- API request/response DTOs (JSON contracts) +- Modbus protocol data structures +- Configuration file formats (YAML) +- Serialization/deserialization mappings + +**Out of scope** (see `types-design.md`): +- Domain type validation logic +- Business rule enforcement +- Type safety guarantees + +--- + +## Database Schemas + +### SQLite Database: `relay_labels.db` + +**Purpose**: Persist custom relay labels across application restarts. + +**Location**: Configurable via `settings.database.path` (default: `relay_labels.db`) + +**Schema Version**: 1.0 + +--- + +#### Table: `relay_labels` + +**Purpose**: Store custom labels for each of the 8 relays. + +```sql +CREATE TABLE IF NOT EXISTS relay_labels ( + relay_id INTEGER PRIMARY KEY NOT NULL, + label TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Constraints + CHECK(relay_id >= 1 AND relay_id <= 8), + CHECK(length(label) >= 1 AND length(label) <= 50), + CHECK(label NOT GLOB '*[^a-zA-Z0-9 _-]*') -- Only alphanumeric, spaces, hyphens, underscores +); + +-- Index for timestamp queries (future analytics) +CREATE INDEX IF NOT EXISTS idx_relay_labels_updated_at + ON relay_labels(updated_at); +``` + +**Columns**: + +| Column | Type | Nullable | Description | +|--------------|---------|----------|---------------------------------------------| +| `relay_id` | INTEGER | NO | Relay identifier (1-8), PRIMARY KEY | +| `label` | TEXT | NO | Custom label (1-50 characters) | +| `created_at` | TEXT | NO | ISO 8601 timestamp when label was first set | +| `updated_at` | TEXT | NO | ISO 8601 timestamp of last label update | + +**Constraints**: +- `relay_id` range: 1-8 (enforced by CHECK constraint) +- `label` length: 1-50 characters (enforced by CHECK constraint) +- `label` characters: Alphanumeric, spaces, hyphens, underscores only (enforced by CHECK constraint) + +**Initial Data**: +```sql +-- Pre-populate with default labels on database initialization +INSERT OR IGNORE INTO relay_labels (relay_id, label) VALUES + (1, 'Relay 1'), + (2, 'Relay 2'), + (3, 'Relay 3'), + (4, 'Relay 4'), + (5, 'Relay 5'), + (6, 'Relay 6'), + (7, 'Relay 7'), + (8, 'Relay 8'); +``` + +**Migration Strategy**: +- For MVP: Schema auto-created by SQLx on first run +- For future versions: Use SQLx migrations with version tracking + +--- + +#### Example Queries + +**Get label for relay 3**: +```sql +SELECT label FROM relay_labels WHERE relay_id = 3; +``` + +**Set/update label for relay 5**: +```sql +INSERT OR REPLACE INTO relay_labels (relay_id, label, updated_at) +VALUES (5, 'Water Pump', datetime('now')); +``` + +**Get all labels**: +```sql +SELECT relay_id, label +FROM relay_labels +ORDER BY relay_id ASC; +``` + +**Get labels modified in last 24 hours**: +```sql +SELECT relay_id, label, updated_at +FROM relay_labels +WHERE updated_at >= datetime('now', '-1 day') +ORDER BY updated_at DESC; +``` + +--- + +## API Data Transfer Objects (DTOs) + +### JSON Serialization Format + +**Content-Type**: `application/json` +**Character Encoding**: UTF-8 +**Date Format**: ISO 8601 (e.g., `2025-01-09T14:30:00Z`) + +--- + +### RelayDto + +**Purpose**: Represents a single relay's complete state for API responses. + +**Used in**: +- `GET /api/relays` response (array) +- `GET /api/relays/{id}` response (single) +- `POST /api/relays/{id}/toggle` response (single) +- `PATCH /api/relays/{id}/label` response (single) + +**JSON Schema**: +```json +{ + "type": "object", + "required": ["id", "state"], + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 8, + "description": "Relay identifier (1-8)" + }, + "state": { + "type": "string", + "enum": ["on", "off"], + "description": "Current relay state" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9 _-]+$", + "description": "Custom relay label (optional)" + } + } +} +``` + +**Example**: +```json +{ + "id": 3, + "state": "on", + "label": "Water Pump" +} +``` + +**Rust Type Mapping**: +```rust +use serde::{Deserialize, Serialize}; +use poem_openapi::Object; + +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct RelayDto { + /// Relay identifier (1-8) + pub id: u8, + + /// Current relay state: "on" or "off" + pub state: String, + + /// Custom relay label (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} +``` + +**Domain → DTO Conversion**: +```rust +impl From for RelayDto { + fn from(relay: Relay) -> Self { + Self { + id: relay.id().as_u8(), + state: match relay.state() { + RelayState::On => "on".to_string(), + RelayState::Off => "off".to_string(), + }, + label: relay.label().map(|l| l.as_str().to_string()), + } + } +} +``` + +--- + +### RelayListResponse + +**Purpose**: Response for endpoints returning multiple relays. + +**Used in**: +- `GET /api/relays` response +- `POST /api/relays/bulk/on` response +- `POST /api/relays/bulk/off` response + +**JSON Schema**: +```json +{ + "type": "object", + "required": ["relays"], + "properties": { + "relays": { + "type": "array", + "minItems": 8, + "maxItems": 8, + "items": { "$ref": "#/components/schemas/RelayDto" }, + "description": "Array of all 8 relays in order (IDs 1-8)" + } + } +} +``` + +**Example**: +```json +{ + "relays": [ + { "id": 1, "state": "off", "label": "Garage Light" }, + { "id": 2, "state": "on", "label": "Water Pump" }, + { "id": 3, "state": "off", "label": "Relay 3" }, + { "id": 4, "state": "off", "label": "Relay 4" }, + { "id": 5, "state": "on", "label": "Relay 5" }, + { "id": 6, "state": "off", "label": "Relay 6" }, + { "id": 7, "state": "off", "label": "Relay 7" }, + { "id": 8, "state": "off", "label": "Relay 8" } + ] +} +``` + +**Rust Type Mapping**: +```rust +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct RelayListResponse { + /// Array of all 8 relays + pub relays: Vec, +} +``` + +--- + +### UpdateLabelRequest + +**Purpose**: Request body for updating a relay label. + +**Used in**: +- `PATCH /api/relays/{id}/label` request body + +**JSON Schema**: +```json +{ + "type": "object", + "required": ["label"], + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9 _-]+$", + "description": "New label for the relay" + } + } +} +``` + +**Example**: +```json +{ + "label": "Office Fan" +} +``` + +**Rust Type Mapping**: +```rust +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct UpdateLabelRequest { + /// New label for the relay (1-50 characters) + pub label: String, +} +``` + +--- + +### HealthResponse + +**Purpose**: Device health and connectivity status. + +**Used in**: +- `GET /api/health` response + +**JSON Schema**: +```json +{ + "type": "object", + "required": ["status", "device_connected"], + "properties": { + "status": { + "type": "string", + "enum": ["healthy", "degraded", "unhealthy"], + "description": "Overall health status" + }, + "device_connected": { + "type": "boolean", + "description": "Whether Modbus device is reachable" + }, + "firmware_version": { + "type": "string", + "maxLength": 20, + "description": "Device firmware version (optional)" + }, + "last_contact": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of last successful communication" + }, + "consecutive_errors": { + "type": "integer", + "minimum": 0, + "description": "Number of consecutive errors (for degraded/unhealthy status)" + } + } +} +``` + +**Examples**: + +**Healthy**: +```json +{ + "status": "healthy", + "device_connected": true, + "firmware_version": "v2.00", + "last_contact": "2025-01-09T14:30:00Z", + "consecutive_errors": 0 +} +``` + +**Degraded**: +```json +{ + "status": "degraded", + "device_connected": true, + "firmware_version": "v2.00", + "last_contact": "2025-01-09T14:28:00Z", + "consecutive_errors": 3 +} +``` + +**Unhealthy**: +```json +{ + "status": "unhealthy", + "device_connected": false, + "last_contact": "2025-01-09T14:00:00Z", + "consecutive_errors": 10 +} +``` + +**Rust Type Mapping**: +```rust +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct HealthResponse { + /// Overall health status + pub status: String, // "healthy" | "degraded" | "unhealthy" + + /// Whether Modbus device is currently reachable + pub device_connected: bool, + + /// Device firmware version (if available) + #[serde(skip_serializing_if = "Option::is_none")] + pub firmware_version: Option, + + /// ISO 8601 timestamp of last successful communication + #[serde(skip_serializing_if = "Option::is_none")] + pub last_contact: Option, + + /// Number of consecutive errors + #[serde(skip_serializing_if = "Option::is_none")] + pub consecutive_errors: Option, +} +``` + +--- + +### ErrorResponse + +**Purpose**: Standardized error response for all API errors. + +**Used in**: +- All API endpoints (4xx, 5xx responses) + +**JSON Schema**: +```json +{ + "type": "object", + "required": ["error", "message"], + "properties": { + "error": { + "type": "string", + "description": "Error code or type" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "details": { + "type": "object", + "description": "Additional error context (optional)" + } + } +} +``` + +**Examples**: + +**400 Bad Request** (Invalid relay ID): +```json +{ + "error": "InvalidRelayId", + "message": "Relay ID 9 out of range (valid: 1-8)", + "details": { + "value": 9, + "min": 1, + "max": 8 + } +} +``` + +**400 Bad Request** (Invalid label): +```json +{ + "error": "InvalidLabel", + "message": "Relay label too long (max: 50, got: 73)" +} +``` + +**500 Internal Server Error** (Modbus communication failure): +```json +{ + "error": "ModbusCommunicationError", + "message": "Failed to read relay state: Connection timeout after 3 seconds" +} +``` + +**504 Gateway Timeout** (Modbus timeout): +```json +{ + "error": "ModbusTimeout", + "message": "Modbus operation timed out after 3 seconds" +} +``` + +**Rust Type Mapping**: +```rust +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct ErrorResponse { + /// Error code or type + pub error: String, + + /// Human-readable error message + pub message: String, + + /// Additional error context (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} +``` + +--- + +## Modbus Protocol Data Structures + +### Modbus TCP Protocol + +**Protocol**: Modbus TCP (not RTU over serial) +**Port**: 502 (standard Modbus TCP port) +**Transport**: TCP/IP +**Framing**: MBAP header (no CRC validation needed) + +--- + +### Modbus Function Codes + +| Function Code | Name | Purpose | Request | Response | +|---------------|------------------------|-----------------------|----------------------------------------------|--------------------------------| +| `0x01` | Read Coils | Read relay states | Start address (0-7), quantity (1-8) | Coil values (bit array) | +| `0x05` | Write Single Coil | Toggle single relay | Address (0-7), value (0xFF00=ON, 0x0000=OFF) | Echo request | +| `0x0F` | Write Multiple Coils | Bulk relay control | Start address, quantity, byte count, values | Echo request | +| `0x03` | Read Holding Registers | Read firmware version | Register address (0x8000), quantity (1) | Register value (version * 100) | + +--- + +### Coil Address Mapping + +**User-facing RelayId (1-8) → Modbus Address (0-7)**: + +| Relay ID (User) | Modbus Coil Address | Description | +|-----------------|---------------------|-------------| +| 1 | 0x0000 (0) | Relay 1 | +| 2 | 0x0001 (1) | Relay 2 | +| 3 | 0x0002 (2) | Relay 3 | +| 4 | 0x0003 (3) | Relay 4 | +| 5 | 0x0004 (4) | Relay 5 | +| 6 | 0x0005 (5) | Relay 6 | +| 7 | 0x0006 (6) | Relay 7 | +| 8 | 0x0007 (7) | Relay 8 | + +**Conversion Formula**: +- User → Modbus: `modbus_address = relay_id - 1` +- Modbus → User: `relay_id = modbus_address + 1` + +--- + +### Read Coils Request (0x01) + +**Purpose**: Read current state of one or more relays. + +**Request Structure** (tokio-modbus handles framing): +```rust +// Read all 8 relays +ctx.read_coils(0x0000, 8).await? + +// Read single relay (ID 3 = address 2) +ctx.read_coils(0x0002, 1).await? +``` + +**Response**: `Result, tokio_modbus::Error>` +- `true` = Relay ON (coil energized) +- `false` = Relay OFF (coil de-energized) + +**Example**: +```rust +// Read all relays +let coils = client.read_coils(0x0000, 8).await?; +// coils = [false, true, false, false, true, false, false, false] +// Relays 2 and 5 are ON, others are OFF +``` + +--- + +### Write Single Coil Request (0x05) + +**Purpose**: Toggle a single relay on or off. + +**Request Structure** (tokio-modbus handles framing): +```rust +// Turn relay 3 ON (address 2) +ctx.write_single_coil(0x0002, true).await? + +// Turn relay 3 OFF +ctx.write_single_coil(0x0002, false).await? +``` + +**Response**: `Result<(), tokio_modbus::Error>` + +**Coil Value Encoding**: +- `true` = ON (0xFF00 in Modbus protocol) +- `false` = OFF (0x0000 in Modbus protocol) + +--- + +### Write Multiple Coils Request (0x0F) + +**Purpose**: Set state of multiple relays in one operation (bulk control). + +**Request Structure** (tokio-modbus handles framing): +```rust +// Turn all 8 relays ON +let all_on = vec![true; 8]; +ctx.write_multiple_coils(0x0000, &all_on).await? + +// Turn all 8 relays OFF +let all_off = vec![false; 8]; +ctx.write_multiple_coils(0x0000, &all_off).await? +``` + +**Response**: `Result<(), tokio_modbus::Error>` + +--- + +### Read Holding Registers Request (0x03) + +**Purpose**: Read firmware version from device. + +**Request Structure** (tokio-modbus handles framing): +```rust +// Read firmware version register +let registers = ctx.read_holding_registers(0x8000, 1).await?; +let version_raw = registers[0]; +let version = f32::from(version_raw) / 100.0; +let version_str = format!("v{:.2}", version); +// Example: version_raw = 200 → "v2.00" +``` + +**Response**: `Result, tokio_modbus::Error>` + +**Encoding**: +- Firmware version is stored as `u16` = version × 100 +- Example: `200` = version 2.00, `157` = version 1.57 + +**Note**: This may not be supported by all devices. Handle `ModbusException` gracefully. + +--- + +### Error Handling + +**tokio-modbus Error Types**: +```rust +// Nested Result structure +Result, io::Error> + +// Exception codes (from Modbus protocol) +pub enum Exception { + IllegalFunction = 0x01, + IllegalDataAddress = 0x02, + IllegalDataValue = 0x03, + ServerDeviceFailure = 0x04, + // ... (other codes) +} +``` + +**Mapping to Domain Errors**: +```rust +match result { + Ok(Ok(data)) => Ok(data), // Success + Ok(Err(Exception::IllegalDataAddress)) => + Err(ControllerError::InvalidRelayId), + Ok(Err(exception)) => + Err(ControllerError::ModbusException(format!("{:?}", exception))), + Err(io_error) => + Err(ControllerError::ConnectionError(io_error.to_string())), +} +``` + +--- + +## Configuration File Format (YAML) + +### settings/base.yaml + +**Purpose**: Base configuration shared across all environments. + +```yaml +application: + name: "STA - Smart Temperature & Appliance Control" + version: "1.0.0" + host: "0.0.0.0" + port: 8080 + +modbus: + # Modbus device IP address (update for your network) + host: "192.168.0.200" + + # Modbus TCP port (standard: 502) + port: 502 + + # Modbus slave/unit ID (typically 0 or 1) + slave_id: 0 + + # Operation timeout in seconds + timeout_secs: 5 + + # Number of retry attempts on failure + retry_attempts: 1 + +relay: + # Maximum label length (characters) + label_max_length: 50 + +database: + # SQLite database file path + path: "relay_labels.db" + +rate_limit: + # Requests per minute per IP + requests_per_minute: 100 + +cors: + # CORS allowed origins (production should override) + allowed_origins: [] + + # Allow credentials (cookies, authorization headers) + allow_credentials: false + + # Preflight cache duration (seconds) + max_age_secs: 3600 + +logging: + # Log level: trace, debug, info, warn, error + level: "info" + + # Enable structured JSON logging + json: false +``` + +--- + +### settings/development.yaml + +**Purpose**: Development environment overrides. + +```yaml +application: + host: "127.0.0.1" + port: 8080 + +modbus: + # Local test device or mock + host: "192.168.0.200" + timeout_secs: 3 + +cors: + # Permissive CORS for local development + allowed_origins: ["*"] + allow_credentials: false + +logging: + level: "debug" + json: false +``` + +--- + +### settings/production.yaml + +**Purpose**: Production environment configuration. + +```yaml +application: + host: "0.0.0.0" + port: 8080 + +modbus: + # Production device IP (configured during deployment) + host: "${MODBUS_DEVICE_IP}" + timeout_secs: 5 + +cors: + # Specific origin for production frontend + allowed_origins: ["https://sta.yourdomain.com"] + allow_credentials: true + +database: + path: "/var/lib/sta/relay_labels.db" + +logging: + level: "info" + json: true # Structured logging for production +``` + +--- + +## Persistence Layer Repository Interface + +### RelayLabelRepository Trait + +**Purpose**: Abstract interface for relay label persistence. + +```rust +use async_trait::async_trait; + +#[async_trait] +pub trait RelayLabelRepository: Send + Sync { + /// Get label for a specific relay + async fn get_label(&self, id: RelayId) + -> Result, RepositoryError>; + + /// Set label for a specific relay + async fn set_label(&self, id: RelayId, label: RelayLabel) + -> Result<(), RepositoryError>; + + /// Get all relay labels (IDs 1-8) + async fn get_all_labels(&self) + -> Result, RepositoryError>; + + /// Delete label for a relay (revert to default) + async fn delete_label(&self, id: RelayId) + -> Result<(), RepositoryError>; +} +``` + +--- + +### SQLite Repository Implementation + +**Implementation**: `SqliteRelayLabelRepository` + +```rust +use sqlx::{SqlitePool, Row}; + +pub struct SqliteRelayLabelRepository { + pool: SqlitePool, +} + +impl SqliteRelayLabelRepository { + pub async fn new(db_path: &str) -> Result { + let pool = SqlitePool::connect(db_path).await?; + + // Initialize schema + sqlx::query(include_str!("schema.sql")) + .execute(&pool) + .await?; + + Ok(Self { pool }) + } + + pub async fn in_memory() -> Result { + Self::new("sqlite::memory:").await + } +} + +#[async_trait] +impl RelayLabelRepository for SqliteRelayLabelRepository { + async fn get_label(&self, id: RelayId) + -> Result, RepositoryError> + { + let label_str: Option = sqlx::query_scalar( + "SELECT label FROM relay_labels WHERE relay_id = ?" + ) + .bind(id.as_u8()) + .fetch_optional(&self.pool) + .await?; + + match label_str { + Some(s) => Ok(Some(RelayLabel::new(s)?)), + None => Ok(None), + } + } + + async fn set_label(&self, id: RelayId, label: RelayLabel) + -> Result<(), RepositoryError> + { + sqlx::query( + "INSERT OR REPLACE INTO relay_labels (relay_id, label, updated_at) + VALUES (?, ?, datetime('now'))" + ) + .bind(id.as_u8()) + .bind(label.as_str()) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn get_all_labels(&self) + -> Result, RepositoryError> + { + let rows = sqlx::query( + "SELECT relay_id, label FROM relay_labels ORDER BY relay_id" + ) + .fetch_all(&self.pool) + .await?; + + let mut result = Vec::new(); + for row in rows { + let id_val: u8 = row.try_get("relay_id")?; + let label_str: String = row.try_get("label")?; + + let id = RelayId::new(id_val)?; + let label = RelayLabel::new(label_str)?; + result.push((id, label)); + } + + Ok(result) + } + + async fn delete_label(&self, id: RelayId) + -> Result<(), RepositoryError> + { + sqlx::query("DELETE FROM relay_labels WHERE relay_id = ?") + .bind(id.as_u8()) + .execute(&self.pool) + .await?; + + Ok(()) + } +} +``` + +--- + +## Domain to DTO Mapping Summary + +| Domain Type | DTO Type | Mapping | +|----------------------|---------------------|---------------------------------------------| +| `Relay` | `RelayDto` | Direct field mapping with string conversion | +| `RelayState::On` | `"on"` | Lowercase string | +| `RelayState::Off` | `"off"` | Lowercase string | +| `RelayId(3)` | `3` | Extract inner u8 | +| `RelayLabel("Pump")` | `"Pump"` | Extract inner String | +| `Vec` | `RelayListResponse` | Wrap in `relays` field | +| `HealthStatus` | `HealthResponse` | Convert enum to status string + fields | +| `DeviceHealth` | `HealthResponse` | Extract fields, format timestamps | + +--- + +## Serialization Examples + +### Relay State Transitions + +**Domain Event**: +```rust +let mut relay = Relay::new(RelayId::new(3)?, RelayState::Off); +relay.set_label(Some(RelayLabel::new("Water Pump".to_string())?)); +relay.toggle(); +``` + +**API Response**: +```json +{ + "id": 3, + "state": "on", + "label": "Water Pump" +} +``` + +--- + +### Database to Domain + +**SQLite Row**: +``` +relay_id | label | created_at | updated_at +---------|-------------|-------------------------|------------------------- +3 | Water Pump | 2025-01-08 10:00:00 | 2025-01-09 14:30:00 +``` + +**Domain Object**: +```rust +let relay_id = RelayId::new(3)?; +let label = RelayLabel::new("Water Pump".to_string())?; +``` + +--- + +### Modbus to Domain + +**Modbus Read Response** (Function Code 0x01): +``` +Read Coils(address=0, quantity=8) +Response: [false, true, false, false, true, false, false, false] +``` + +**Domain Mapping**: +```rust +let relay_states = vec![ + (RelayId::new(1)?, RelayState::Off), + (RelayId::new(2)?, RelayState::On), + (RelayId::new(3)?, RelayState::Off), + (RelayId::new(4)?, RelayState::Off), + (RelayId::new(5)?, RelayState::On), + (RelayId::new(6)?, RelayState::Off), + (RelayId::new(7)?, RelayState::Off), + (RelayId::new(8)?, RelayState::Off), +]; +``` + +--- + +## Data Flow Summary + +``` +┌─────────────────────────────────────────────────────────────┐ +│ API Layer (JSON) │ +│ RelayDto, UpdateLabelRequest, HealthResponse, etc. │ +└────────────────────────┬────────────────────────────────────┘ + │ Parse/Validate + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Domain Layer (Types) │ +│ RelayId, RelayState, Relay, RelayLabel, etc. │ +└────────────┬────────────────────────────────┬───────────────┘ + │ │ + ▼ ▼ +┌────────────────────────┐ ┌────────────────────────────┐ +│ Infrastructure │ │ Infrastructure │ +│ (Modbus Protocol) │ │ (SQLite Persistence) │ +│ │ │ │ +│ - Coil addresses (0-7) │ │ - relay_labels table │ +│ - Function codes │ │ - RelayLabelRepository │ +│ - Read/Write ops │ │ - SQLx queries │ +└────────────────────────┘ └────────────────────────────┘ +``` + +--- + +## References + +- [Feature Specification](./spec.md) - Complete requirements and user stories +- [Type Design](./types-design.md) - Domain type definitions and validation +- [Implementation Plan](./plan.md) - Technical architecture and implementation strategy +- [Modbus Hardware Documentation](../../docs/Modbus_POE_ETH_Relay.md) - Device protocol details + +--- + +## Revision History + +| Version | Date | Author | Changes | +|---------|------------|-------------------|----------------------------------| +| 1.0 | 2025-01-09 | Type Design Agent | Initial data model specification | diff --git a/specs/001-modbus-relay-control/decisions.md b/specs/001-modbus-relay-control/decisions.md new file mode 100644 index 0000000..824cba2 --- /dev/null +++ b/specs/001-modbus-relay-control/decisions.md @@ -0,0 +1,177 @@ +# Implementation Decisions + +**Date**: 2025-12-28 +**Feature**: Modbus Relay Control System + +## User Decisions + +### Q1: Communication Pattern +**Decision**: HTTP Polling (as specified in spec) +**Rationale**: WebSocket would be overkill for this project scale + +### Q2: Frontend Development Approach +**Decision**: Develop frontend alongside backend, but API endpoints must be implemented first before corresponding frontend features +**Approach**: API-first development - implement and test each endpoint before building UI for it + +### Q3: Hardware Availability +**Decision**: Physical hardware available for testing +**Details**: +- 8-channel Modbus relay device accessible now +- IP address: Variable (configurable) +- Port: 501 or 502 (confirm in docs: `docs/Modbus_POE_ETH_Relay.md`) +- Device will be available during development phase + +### Q4: Relay Label Persistence +**Decision**: SQLite database with SQLx +**Implementation Priority**: +1. **Preferred**: SQLite database with SQLx (compile-time SQL verification, async-native, type-safe) +2. **Alternative**: YAML file (read at startup, write on update) + +**Recommendation**: Use SQLite with SQLx for MVP - simpler than managing YAML file updates, good for future features, aligns with type-driven development principles + +### Q5: Error Recovery Strategy +**Decision**: Exponential retry with timeout +**Strategy**: +- When device becomes unhealthy/unavailable: attempt reconnection every 5 seconds +- Maximum retry duration: 5 minutes +- After 5 minutes: give up and mark device as unhealthy +- Resume connection attempts when user makes new API request +- Background task monitors connection health + +### Q6: Firmware Version +**Decision**: Check docs for availability, hide if unavailable +**Behavior**: +- If firmware version available via Modbus: Display in health endpoint +- If not available: Omit field entirely from health response (not null/empty string) +- Action: Verify in `docs/Modbus_POE_ETH_Relay.md` + +### Q7: Deployment Environment +**Development**: Thinkpad x220 (NixOS) +**Production Backend**: Raspberry Pi 3B+ (available next week) - on same network as relay device +**Production Frontend**: Cloudflare Pages (or equivalent static hosting) +**Reverse Proxy**: Traefik on Raspberry Pi with Authelia middleware for authentication +**Network**: Raspberry Pi on same network as relay device, frontend accesses backend via HTTPS through Traefik + +### Q8: Testing Approach +**Decision**: Implement both real hardware tests AND mocks +**Rationale**: +- Hardware available now for integration testing +- Mocks needed for future maintenance (after device shipped) +- Mocks enable fast unit tests without hardware dependency +- Follows TDD principles with mock-based development + +**Testing Strategy**: +1. **Unit Tests**: Use mocks (mockall) - fast, no hardware needed +2. **Integration Tests**: Use real hardware - verify actual Modbus communication +3. **CI/CD**: Use mocks (hardware not available in CI) +4. **Manual Testing**: Use real hardware during development + +## Derived Decisions + +### Deployment Architecture +**Decision**: Frontend on Cloudflare Pages, backend on Raspberry Pi behind Traefik reverse proxy +**Components**: +- **Frontend**: Static Vue 3 app hosted on Cloudflare Pages (fast global CDN delivery) +- **Backend**: Rust HTTP API on Raspberry Pi (same local network as Modbus relay device) +- **Reverse Proxy**: Traefik on Raspberry Pi providing: + - HTTPS termination (TLS certificates) + - Authelia middleware for user authentication + - Reverse proxy routing to backend HTTP service +- **Communication**: Frontend → HTTPS (via Traefik) → Backend → Modbus TCP → Relay Device + +**Rationale**: +- Frontend on CDN provides fast page loads from anywhere +- Backend must be local to Modbus device (local network communication) +- Traefik handles authentication/HTTPS without application-level complexity +- Backend runs HTTP internally, Traefik handles TLS termination + +**Security Layers**: +1. Authelia authentication at reverse proxy (user login) +2. HTTPS encryption for frontend-backend communication +3. Unencrypted Modbus TCP on local network only (acceptable for local-only device) + +### Architecture Approach +**Decision**: Hexagonal Architecture with trait-based abstraction +**Layers**: +- **Domain**: Pure business logic (RelayId, RelayState, Relay entity) +- **Application**: Use cases (GetRelayStatus, ToggleRelay, BulkControl) +- **Infrastructure**: Modbus client implementation + SQLite repository +- **Presentation**: HTTP API handlers (Poem) + +### Database Choice +**Decision**: SQLite with SQLx for relay labels and configuration + +**Why SQLx over rusqlite**: +- **Compile-time SQL verification**: Queries are checked against actual database schema during compilation +- **Type safety**: Column types verified to match Rust types at compile time +- **Async-native**: Built for tokio async/await (no need for `spawn_blocking` wrappers) +- **Type-driven development alignment**: "Parse, don't validate" - SQL errors caught at compile time, not runtime +- **Better observability**: Built-in query logging and tracing integration +- **Macro-based queries**: `query!` and `query_as!` macros provide ergonomic, safe database access + +**Benefits of SQLite**: +- No external dependencies (embedded) +- ACID transactions for label updates +- Simple schema (one table for relay labels) +- Easy to back up (single file) +- Works on both NixOS and Raspberry Pi + +**Schema**: +```sql +CREATE TABLE relay_labels ( + relay_id INTEGER PRIMARY KEY CHECK(relay_id >= 1 AND relay_id <= 8), + label TEXT NOT NULL CHECK(length(label) <= 50) +); +``` + +**Dependencies**: +```toml +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +``` + +### Modbus Port Discovery +**Confirmed from Documentation** (`docs/Modbus_POE_ETH_Relay.md`): +- **Modbus RTU over TCP**: Uses TCP server mode, port is configurable (typically 8234 or custom) +- **Modbus TCP**: Port automatically changes to **502** when "Modbus TCP protocol" is selected in Advanced Settings +- **Recommended**: Use Modbus RTU over TCP (default, simpler configuration) +- **Device must be configured as**: "Multi-host non-storage type" gateway (CRITICAL - storage type sends spurious queries) + +### Firmware Version Availability +**Confirmed from Documentation** (`docs/Modbus_POE_ETH_Relay.md:417-442`): +- **Available**: YES - Firmware version can be read via Modbus function code 0x03 +- **Register Address**: 0x8000 (Read Holding Register) +- **Command**: `01 03 80 00 00 01 AD CA` +- **Response Format**: 2-byte value, convert to decimal and divide by 100 (e.g., 0x00C8 = 200 = v2.00) +- **Implementation**: Read once at startup and cache, update on successful reconnection + +### Connection Management +**Decision**: Background connection health monitor +**Behavior**: +- Monitor task checks connection every 5 seconds +- On failure: retry with exponential backoff (max 5 seconds interval) +- After 5 minutes of failures: mark unhealthy, stop retrying +- On new API request: resume connection attempts +- On successful reconnection: reset retry counter, mark healthy + +### Frontend Technology Stack +**Decision**: Vue 3 + TypeScript + Vite +**Components**: +- OpenAPI TypeScript client generation (type-safe API calls) +- HTTP polling with `setInterval` (2-second intervals) +- Reactive state management (ref/reactive, no Pinia needed for this simple app) +- UI library: TBD (Nuxt UI, Vuetify, or custom - decide during frontend implementation) + +## Next Steps + +1. ✅ Verify Modbus port in documentation +2. ✅ Design architecture approaches (minimal, clean, pragmatic) +3. ✅ Select approach with user +4. ✅ Create detailed implementation plan +5. ✅ Begin TDD implementation + +## Notes + +- User has hardware access now, but device will ship after first version +- Mocks are critical for long-term maintainability +- SQLite preferred over YAML for runtime updates +- Connection retry strategy balances responsiveness with resource usage diff --git a/specs/001-modbus-relay-control/plan.md b/specs/001-modbus-relay-control/plan.md new file mode 100644 index 0000000..164065c --- /dev/null +++ b/specs/001-modbus-relay-control/plan.md @@ -0,0 +1,2219 @@ +# Implementation Plan: Modbus Relay Control System + +**Branch**: `001-modbus-relay-control` | **Date**: 2025-12-29 | **Spec**: [spec.md](./spec.md) + +## Summary + +**Primary Requirement**: Web-based control system for 8-channel Modbus relay device with real-time state monitoring and remote control capabilities. + +**Technical Approach**: +- **Architecture**: Pragmatic Balance (Service Layer Pattern) - Hexagonal architecture with domain/application/infrastructure/presentation layers +- **Backend**: Rust with tokio-modbus 0.17.0 for Modbus RTU over TCP, Poem 3.1 for HTTP API with OpenAPI +- **Frontend**: Vue 3 + TypeScript with HTTP polling (2-second intervals), deployed to Cloudflare Pages +- **Reverse Proxy**: Traefik on Raspberry Pi with Authelia middleware for authentication and HTTPS termination +- **Persistence**: SQLite for relay labels +- **Testing**: TDD with mockall for unit tests, real hardware for integration tests +- **Timeline**: 7 days (5 days backend + 2 days frontend) + +## Technical Context + +**Language/Version**: Rust 1.75+ +**Primary Dependencies**: +- tokio-modbus 0.17.0 (Modbus RTU over TCP) +- Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI) +- Tokio 1.48 (async runtime) +- sqlx 0.8 (SQLite persistence with compile-time verification) +- mockall + async-trait (testing) +- Vue 3 + TypeScript + Vite (frontend) + +**Storage**: SQLite (relay labels, device configuration) +**Testing**: cargo test + mockall (mocks) + real hardware integration tests (marked `#[ignore]` for CI) +**Target Platform**: +- Backend: Linux (NixOS development, Raspberry Pi 3B+ production with Traefik reverse proxy) +- Frontend: Cloudflare Pages (static hosting with CDN) +**Project Type**: Web (backend + frontend) +**Performance Goals**: +- API response: <100ms (excluding Modbus communication) +- Relay toggle operation: <1s end-to-end +- Concurrent users: 10 +- Frontend polling: 2-second intervals + +**Constraints**: +- Modbus timeout: 3 seconds (FR-006) +- Test coverage: >90% (constitution requirement) +- Retry strategy: Retry once on Modbus failure (FR-007) +- Graceful degradation: Backend starts even when device unavailable (FR-023) + +**Scale/Scope**: +- 8 relays per device +- Single device support (MVP) +- 5 core API endpoints + 1 health endpoint +- Backend: Local network (Raspberry Pi) behind Traefik reverse proxy +- Frontend: CDN-hosted (Cloudflare Pages), accesses backend via HTTPS + +## Constitution Check + +*GATE: Must pass before implementation. Verified against `specs/constitution.md` v1.1.0* + +✅ **Hexagonal Architecture**: Enforced through domain/application/infrastructure/presentation layers with inward-pointing dependencies +✅ **Domain-Driven Design**: Rich domain models with value objects (RelayId, RelayState, RelayLabel), entities (Relay), repositories +✅ **Test-First Development**: TDD mandatory - write failing tests before implementation for every component +✅ **API-First Design**: RESTful HTTP with OpenAPI specification, contracts defined before implementation +✅ **Observability & Monitoring**: Structured logging with tracing crate at all architectural boundaries +✅ **SOLID Principles**: +- SRP: Each module has single responsibility (domain types, services, repositories) +- OCP: Trait-based abstractions allow extension without modification +- LSP: Mock and real implementations substitutable through traits +- ISP: Focused traits (RelayController, RelayLabelRepository) +- DIP: High-level use cases depend on abstractions, not concrete implementations + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-modbus-relay-control/ +├── plan.md # This file +├── spec.md # Feature specification +├── decisions.md # Architecture and technical decisions +├── research.md # Technical research findings +└── types-design.md # Type system design (TyDD) +``` + +### Source Code (repository root) + +```text +sta/ (repository root) +├── src/ +│ ├── domain/ +│ │ └── relay/ +│ │ ├── mod.rs # Module exports +│ │ ├── types.rs # RelayId, RelayState, RelayLabel (newtypes) +│ │ ├── entity.rs # Relay entity, RelayCollection +│ │ ├── repository.rs # RelayLabelRepository trait +│ │ ├── controller.rs # RelayController trait +│ │ └── error.rs # Domain-specific errors +│ │ +│ ├── application/ +│ │ └── relay/ +│ │ ├── mod.rs # Use case exports +│ │ ├── get_status.rs # GetRelayStatus use case +│ │ ├── toggle_relay.rs # ToggleRelay use case +│ │ ├── bulk_control.rs # BulkControl use cases +│ │ ├── update_label.rs # UpdateLabel use case +│ │ └── get_health.rs # GetDeviceHealth use case +│ │ +│ ├── infrastructure/ +│ │ ├── modbus/ +│ │ │ ├── mod.rs # Module exports +│ │ │ ├── client.rs # ModbusRelayController (real impl) +│ │ │ ├── mock.rs # MockRelayController (testing) +│ │ │ ├── config.rs # Modbus configuration +│ │ │ └── connection.rs # Connection management +│ │ │ +│ │ └── persistence/ +│ │ ├── mod.rs # Module exports +│ │ ├── sqlite_repository.rs # SqliteRelayLabelRepository +│ │ └── schema.sql # Database schema +│ │ +│ ├── route/ +│ │ ├── mod.rs # Update: Add Relay API category +│ │ └── relay.rs # New: Relay API handlers +│ │ +│ ├── settings.rs # Update: Add ModbusSettings +│ ├── startup.rs # Update: Wire relay dependencies +│ └── (existing files...) +│ +├── tests/ +│ ├── unit/ +│ │ └── relay/ +│ │ ├── domain_types_test.rs +│ │ ├── entity_test.rs +│ │ └── use_cases_test.rs +│ │ +│ ├── integration/ +│ │ ├── modbus_mock_test.rs +│ │ ├── modbus_real_hardware_test.rs # marked #[ignore] for CI +│ │ ├── sqlite_repository_test.rs +│ │ └── api_integration_test.rs +│ │ +│ └── contract/ +│ └── relay_api_contract_test.rs +│ +└── frontend/ (new directory) + ├── src/ + │ ├── components/ + │ │ ├── RelayGrid.vue + │ │ ├── RelayCard.vue + │ │ ├── BulkControls.vue + │ │ └── HealthStatus.vue + │ ├── services/ + │ │ └── api-client.ts # OpenAPI generated + │ ├── composables/ + │ │ └── useRelayPolling.ts + │ ├── types/ + │ │ └── relay.ts + │ ├── App.vue + │ └── main.ts + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + └── index.html +``` + +**Structure Decision**: Web application structure with backend (existing `src/`) and new `frontend/` directory. Backend follows hexagonal architecture with domain/application/infrastructure/presentation layers. Frontend is separate Vue 3 project with Vite. + +--- + +## Phase Breakdown + +### Phase 0: Setup & Dependencies (0.5 days) + +**Objective**: Set up project dependencies and infrastructure for both backend and frontend. + +**Prerequisites**: +- Existing codebase (`sta` repository) +- Rust toolchain 1.75+ +- Node.js 18+ (for frontend) + +**Tasks**: + +#### Task 0.1: Add Rust Dependencies +**File**: `Cargo.toml` + +**Action**: Add the following dependencies: +```toml +[dependencies] +tokio-modbus = { version = "0.17.0", features = ["rtu", "tcp"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +mockall = "0.13" +async-trait = "0.1" +``` + +**Verification**: Run `cargo check` - should compile without errors. + +#### Task 0.2: Create Domain Module Structure +**Files to create**: +- `src/domain/mod.rs` +- `src/domain/relay/mod.rs` + +**Action**: Create empty module files with proper visibility: +```rust +// src/domain/mod.rs +pub mod relay; +``` + +```rust +// src/domain/relay/mod.rs +pub mod types; +pub mod entity; +pub mod repository; +pub mod controller; +pub mod error; +``` + +**Verification**: `cargo check` passes, modules are accessible. + +#### Task 0.3: Update Settings for Modbus Configuration +**File**: `src/settings.rs` + +**Action**: Add `ModbusSettings` struct: +```rust +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ModbusSettings { + pub host: String, + pub port: u16, + pub slave_id: u8, + pub timeout_secs: u64, +} +``` + +Update `Settings` struct to include `modbus: ModbusSettings`. + +Update `settings/base.yaml`: +```yaml +modbus: + host: "192.168.1.100" # Replace with actual IP + port: 502 + slave_id: 1 + timeout_secs: 3 +``` + +**Verification**: Run `cargo run` - settings should load without errors. + +**Deliverables**: +- All dependencies added and compiling +- Module structure created +- Modbus configuration in settings + +--- + +### Phase 1: Domain Layer - Types & Entities (1 day) + +**Objective**: Implement pure domain logic with type-driven design (TyDD). No external dependencies. + +**Prerequisites**: Phase 0 complete + +#### Task 1.1: Write Tests for RelayId (TDD) +**File**: `tests/unit/relay/domain_types_test.rs` + +**Action**: Write failing tests FIRST: +```rust +#[cfg(test)] +mod relay_id_tests { + use sta::domain::relay::types::RelayId; + + #[test] + fn valid_relay_id_succeeds() { + for id in 1..=8 { + assert!(RelayId::new(id).is_ok()); + } + } + + #[test] + fn relay_id_zero_fails() { + assert!(RelayId::new(0).is_err()); + } + + #[test] + fn relay_id_above_8_fails() { + assert!(RelayId::new(9).is_err()); + } + + #[test] + fn relay_id_to_modbus_address() { + let id = RelayId::new(1).unwrap(); + assert_eq!(id.to_modbus_address(), 0); + + let id = RelayId::new(8).unwrap(); + assert_eq!(id.to_modbus_address(), 7); + } +} +``` + +**Verification**: Run `cargo test` - tests should FAIL (not compile). + +#### Task 1.2: Implement RelayId Newtype +**File**: `src/domain/relay/types.rs` + +**Action**: Implement to make tests pass: +```rust +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct RelayId(u8); + +#[derive(Debug, Error)] +pub enum RelayIdError { + #[error("Relay ID must be between 1 and 8, got {0}")] + OutOfRange(u8), +} + +impl RelayId { + /// Creates a new RelayId (1-8 for user-facing) + pub fn new(value: u8) -> Result { + if value < 1 || value > 8 { + return Err(RelayIdError::OutOfRange(value)); + } + Ok(Self(value)) + } + + /// Converts user-facing ID (1-8) to Modbus address (0-7) + pub fn to_modbus_address(self) -> u16 { + u16::from(self.0 - 1) + } + + pub fn value(self) -> u8 { + self.0 + } +} +``` + +**Acceptance Criteria**: +- [ ] All RelayId tests pass +- [ ] `cargo clippy` shows no warnings +- [ ] Type is `#[repr(transparent)]` for zero-cost + +**Verification**: Run `cargo test domain_types_test` - all tests PASS. + +#### Task 1.3: Write Tests for RelayState and RelayLabel +**File**: `tests/unit/relay/domain_types_test.rs` + +**Action**: Add tests for RelayState enum and RelayLabel newtype (following same TDD pattern). + +#### Task 1.4: Implement RelayState and RelayLabel +**File**: `src/domain/relay/types.rs` + +**Action**: Implement types to pass tests: +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayState { + On, + Off, +} + +impl RelayState { + pub fn toggle(self) -> Self { + match self { + Self::On => Self::Off, + Self::Off => Self::On, + } + } + + pub fn to_modbus_value(self) -> u16 { + match self { + Self::On => 0xFF00, + Self::Off => 0x0000, + } + } + + pub fn from_modbus_coil(coil: bool) -> Self { + if coil { Self::On } else { Self::Off } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[repr(transparent)] +pub struct RelayLabel(String); + +#[derive(Debug, Error)] +pub enum RelayLabelError { + #[error("Label cannot be empty")] + Empty, + #[error("Label exceeds maximum length of 50 characters")] + TooLong, +} + +impl RelayLabel { + pub fn new(value: String) -> Result { + if value.is_empty() { + return Err(RelayLabelError::Empty); + } + if value.len() > 50 { + return Err(RelayLabelError::TooLong); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for RelayLabel { + fn default() -> Self { + Self(String::from("Unlabeled")) + } +} +``` + +**Verification**: All domain type tests pass. + +#### Task 1.5: Write Tests for Relay Entity +**File**: `tests/unit/relay/entity_test.rs` + +**Action**: Write tests for Relay entity: +```rust +#[cfg(test)] +mod relay_entity_tests { + use sta::domain::relay::{entity::Relay, types::*}; + + #[test] + fn new_relay_defaults_to_off_and_unlabeled() { + let relay = Relay::new(RelayId::new(1).unwrap()); + assert_eq!(relay.state(), RelayState::Off); + assert_eq!(relay.label().as_str(), "Unlabeled"); + } + + #[test] + fn toggle_changes_state() { + let mut relay = Relay::new(RelayId::new(1).unwrap()); + assert_eq!(relay.state(), RelayState::Off); + + relay.toggle(); + assert_eq!(relay.state(), RelayState::On); + + relay.toggle(); + assert_eq!(relay.state(), RelayState::Off); + } + + #[test] + fn update_label_changes_label() { + let mut relay = Relay::new(RelayId::new(1).unwrap()); + let label = RelayLabel::new("Garage Door".to_string()).unwrap(); + relay.update_label(label.clone()); + assert_eq!(relay.label(), &label); + } +} +``` + +**Verification**: Tests FAIL (entity not yet implemented). + +#### Task 1.6: Implement Relay Entity +**File**: `src/domain/relay/entity.rs` + +**Action**: Implement Relay entity: +```rust +use super::types::{RelayId, RelayLabel, RelayState}; + +#[derive(Debug, Clone, PartialEq)] +pub struct Relay { + id: RelayId, + state: RelayState, + label: RelayLabel, +} + +impl Relay { + pub fn new(id: RelayId) -> Self { + Self { + id, + state: RelayState::Off, + label: RelayLabel::default(), + } + } + + pub fn with_state(id: RelayId, state: RelayState) -> Self { + Self { + id, + state, + label: RelayLabel::default(), + } + } + + pub fn with_label(id: RelayId, state: RelayState, label: RelayLabel) -> Self { + Self { id, state, label } + } + + pub fn id(&self) -> RelayId { + self.id + } + + pub fn state(&self) -> RelayState { + self.state + } + + pub fn label(&self) -> &RelayLabel { + &self.label + } + + pub fn toggle(&mut self) { + self.state = self.state.toggle(); + } + + pub fn set_state(&mut self, state: RelayState) { + self.state = state; + } + + pub fn update_label(&mut self, label: RelayLabel) { + self.label = label; + } +} +``` + +**Acceptance Criteria**: +- [ ] All Relay entity tests pass +- [ ] Entity has no external dependencies +- [ ] Methods follow domain logic only + +**Verification**: `cargo test entity_test` passes. + +#### Task 1.7: Define Repository and Controller Traits +**File**: `src/domain/relay/repository.rs` and `src/domain/relay/controller.rs` + +**Action**: Define traits (no tests needed for traits themselves): + +```rust +// repository.rs +use super::types::{RelayId, RelayLabel}; +use async_trait::async_trait; + +#[derive(Debug, thiserror::Error)] +pub enum RepositoryError { + #[error("Database error: {0}")] + DatabaseError(String), + #[error("Relay not found: {0}")] + NotFound(RelayId), +} + +#[async_trait] +pub trait RelayLabelRepository: Send + Sync { + async fn get_label(&self, id: RelayId) -> Result, RepositoryError>; + async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError>; + async fn get_all_labels(&self) -> Result, RepositoryError>; +} +``` + +```rust +// controller.rs +use super::types::{RelayId, RelayState}; +use async_trait::async_trait; + +#[derive(Debug, thiserror::Error)] +pub enum ControllerError { + #[error("Connection error: {0}")] + ConnectionError(String), + #[error("Timeout after {0} seconds")] + Timeout(u64), + #[error("Modbus exception: {0}")] + ModbusException(String), + #[error("Invalid relay ID: {0}")] + InvalidRelayId(u8), +} + +#[async_trait] +pub trait RelayController: Send + Sync { + async fn read_relay_state(&self, id: RelayId) -> Result; + async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError>; + async fn read_all_states(&self) -> Result, ControllerError>; + async fn write_all_states(&self, states: Vec) -> Result<(), ControllerError>; + async fn check_connection(&self) -> Result<(), ControllerError>; + async fn get_firmware_version(&self) -> Result, ControllerError>; +} +``` + +**Verification**: `cargo check` passes, traits compile. + +**Deliverables**: +- Domain types (RelayId, RelayState, RelayLabel) fully tested and implemented +- Relay entity implemented with unit tests +- Repository and Controller traits defined +- 100% test coverage for domain layer +- No external dependencies in domain layer + +--- + +### Phase 2: Infrastructure - Mock Implementation (0.5 days) + +**Objective**: Create mock implementations for testing without hardware. + +**Prerequisites**: Phase 1 complete (traits defined) + +#### Task 2.1: Implement MockRelayController +**File**: `src/infrastructure/modbus/mock.rs` + +**Action**: Create mock using in-memory state: +```rust +use crate::domain::relay::{ + controller::{ControllerError, RelayController}, + types::{RelayId, RelayState}, +}; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Debug, Clone)] +pub struct MockRelayController { + states: Arc>, + firmware_version: Option, + simulate_timeout: bool, +} + +impl MockRelayController { + pub fn new() -> Self { + Self { + states: Arc::new(Mutex::new([RelayState::Off; 8])), + firmware_version: Some("v2.00".to_string()), + simulate_timeout: false, + } + } + + pub fn with_timeout_simulation(mut self) -> Self { + self.simulate_timeout = true; + self + } +} + +#[async_trait] +impl RelayController for MockRelayController { + async fn read_relay_state(&self, id: RelayId) -> Result { + if self.simulate_timeout { + tokio::time::sleep(tokio::time::Duration::from_secs(4)).await; + return Err(ControllerError::Timeout(3)); + } + let states = self.states.lock().await; + let index = (id.value() - 1) as usize; + Ok(states[index]) + } + + async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> { + if self.simulate_timeout { + return Err(ControllerError::Timeout(3)); + } + let mut states = self.states.lock().await; + let index = (id.value() - 1) as usize; + states[index] = state; + Ok(()) + } + + async fn read_all_states(&self) -> Result, ControllerError> { + let states = self.states.lock().await; + Ok(states.to_vec()) + } + + async fn write_all_states(&self, new_states: Vec) -> Result<(), ControllerError> { + let mut states = self.states.lock().await; + for (i, state) in new_states.iter().enumerate() { + states[i] = *state; + } + Ok(()) + } + + async fn check_connection(&self) -> Result<(), ControllerError> { + Ok(()) + } + + async fn get_firmware_version(&self) -> Result, ControllerError> { + Ok(self.firmware_version.clone()) + } +} +``` + +**Verification**: `cargo check` passes. + +#### Task 2.2: Write Integration Tests with Mock +**File**: `tests/integration/modbus_mock_test.rs` + +**Action**: Test mock behavior: +```rust +use sta::domain::relay::{controller::RelayController, types::*}; +use sta::infrastructure::modbus::mock::MockRelayController; + +#[tokio::test] +async fn mock_relay_read_write_cycle() { + let controller = MockRelayController::new(); + let id = RelayId::new(1).unwrap(); + + // Read initial state (should be OFF) + let state = controller.read_relay_state(id).await.unwrap(); + assert_eq!(state, RelayState::Off); + + // Write ON + controller.write_relay_state(id, RelayState::On).await.unwrap(); + + // Read again (should be ON) + let state = controller.read_relay_state(id).await.unwrap(); + assert_eq!(state, RelayState::On); +} + +#[tokio::test] +async fn mock_timeout_simulation() { + let controller = MockRelayController::new().with_timeout_simulation(); + let id = RelayId::new(1).unwrap(); + + let result = controller.read_relay_state(id).await; + assert!(result.is_err()); +} +``` + +**Acceptance Criteria**: +- [ ] Mock controller passes all integration tests +- [ ] Mock supports timeout simulation +- [ ] Mock thread-safe (Arc) + +**Verification**: `cargo test modbus_mock_test` passes. + +**Deliverables**: +- MockRelayController fully functional +- Integration tests with mocks passing +- Foundation for TDD without hardware + +--- + +### Phase 3: Infrastructure - SQLite Repository (1 day) + +**Objective**: Implement persistent label storage with SQLite. + +**Prerequisites**: Phase 1 complete (repository trait defined) + +#### Task 3.1: Create Database Schema +**File**: `src/infrastructure/persistence/schema.sql` + +**Action**: Define schema: +```sql +-- Relay label storage +CREATE TABLE IF NOT EXISTS relay_labels ( + relay_id INTEGER PRIMARY KEY CHECK(relay_id >= 1 AND relay_id <= 8), + label TEXT NOT NULL CHECK(length(label) > 0 AND length(label) <= 50) +); + +-- Pre-populate with defaults +INSERT OR IGNORE INTO relay_labels (relay_id, label) VALUES + (1, 'Relay 1'), + (2, 'Relay 2'), + (3, 'Relay 3'), + (4, 'Relay 4'), + (5, 'Relay 5'), + (6, 'Relay 6'), + (7, 'Relay 7'), + (8, 'Relay 8'); +``` + +**Verification**: Schema is valid SQL. + +#### Task 3.2: Write Tests for SqliteRelayLabelRepository (TDD) +**File**: `tests/integration/sqlite_repository_test.rs` + +**Action**: Write failing tests: +```rust +use sta::domain::relay::{repository::RelayLabelRepository, types::*}; +use sta::infrastructure::persistence::sqlite_repository::SqliteRelayLabelRepository; + +#[tokio::test] +async fn get_label_returns_default_for_new_relay() { + let repo = SqliteRelayLabelRepository::in_memory().await.unwrap(); + let id = RelayId::new(1).unwrap(); + + let label = repo.get_label(id).await.unwrap(); + assert!(label.is_some()); + assert_eq!(label.unwrap().as_str(), "Relay 1"); +} + +#[tokio::test] +async fn save_and_get_label_persists() { + let repo = SqliteRelayLabelRepository::in_memory().await.unwrap(); + let id = RelayId::new(3).unwrap(); + let label = RelayLabel::new("Water Pump".to_string()).unwrap(); + + repo.save_label(id, label.clone()).await.unwrap(); + + let retrieved = repo.get_label(id).await.unwrap().unwrap(); + assert_eq!(retrieved, label); +} + +#[tokio::test] +async fn get_all_labels_returns_all_eight() { + let repo = SqliteRelayLabelRepository::in_memory().await.unwrap(); + + let labels = repo.get_all_labels().await.unwrap(); + assert_eq!(labels.len(), 8); +} +``` + +**Verification**: Tests FAIL (repository not implemented). + +#### Task 3.3: Implement SqliteRelayLabelRepository +**File**: `src/infrastructure/persistence/sqlite_repository.rs` + +**Action**: Implement repository: +```rust +use crate::domain::relay::{ + repository::{RelayLabelRepository, RepositoryError}, + types::{RelayId, RelayLabel}, +}; +use async_trait::async_trait; +use sqlx::{sqlite::SqlitePool, Row}; + +pub struct SqliteRelayLabelRepository { + pool: SqlitePool, +} + +impl SqliteRelayLabelRepository { + pub async fn new(db_path: &str) -> Result { + let pool = SqlitePool::connect(db_path) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + let repo = Self { pool }; + + repo.initialize_schema().await?; + Ok(repo) + } + + pub async fn in_memory() -> Result { + Self::new("sqlite::memory:").await + } + + async fn initialize_schema(&self) -> Result<(), RepositoryError> { + sqlx::query(include_str!("schema.sql")) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + Ok(()) + } +} + +#[async_trait] +impl RelayLabelRepository for SqliteRelayLabelRepository { + async fn get_label(&self, id: RelayId) -> Result, RepositoryError> { + let label_str: Option = sqlx::query_scalar( + "SELECT label FROM relay_labels WHERE relay_id = ?1" + ) + .bind(id.value()) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + match label_str { + Some(s) => Ok(Some( + RelayLabel::new(s).map_err(|e| RepositoryError::DatabaseError(e.to_string()))?, + )), + None => Ok(None), + } + } + + async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError> { + sqlx::query("INSERT OR REPLACE INTO relay_labels (relay_id, label) VALUES (?1, ?2)") + .bind(id.value()) + .bind(label.as_str()) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + Ok(()) + } + + async fn get_all_labels(&self) -> Result, RepositoryError> { + let rows = sqlx::query("SELECT relay_id, label FROM relay_labels ORDER BY relay_id") + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + let mut result = Vec::new(); + for row in rows { + let id_val: u8 = row.try_get("relay_id") + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + let label_str: String = row.try_get("label") + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + let id = RelayId::new(id_val) + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + let label = RelayLabel::new(label_str) + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + result.push((id, label)); + } + + Ok(result) + } +} +``` + +**Acceptance Criteria**: +- [ ] All SQLite repository tests pass +- [ ] Schema initializes automatically +- [ ] Labels persist across repository instances (file-based) +- [ ] Connection pool handles concurrency automatically + +**Verification**: `cargo test sqlite_repository_test` passes. + +**Deliverables**: +- SQLite repository fully functional +- Schema auto-initialization +- Persistence tests passing + +--- + +### Phase 4: Infrastructure - Real Modbus Client (1.5 days) + +**Objective**: Implement real Modbus RTU over TCP communication using tokio-modbus. + +**Prerequisites**: Phase 1 complete (controller trait), hardware available for testing + +#### Task 4.1: Implement ModbusRelayController +**File**: `src/infrastructure/modbus/client.rs` + +**Action**: Implement real Modbus controller: +```rust +use crate::domain::relay::{ + controller::{ControllerError, RelayController}, + types::{RelayId, RelayState}, +}; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::time::{timeout, Duration}; +use tokio_modbus::prelude::*; + +pub struct ModbusRelayController { + ctx: Arc>, + timeout_duration: Duration, +} + +impl ModbusRelayController { + pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64) -> Result { + let socket_addr = format!("{}:{}", host, port) + .parse() + .map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?; + + let ctx = tcp::connect_slave(socket_addr, Slave(slave_id)) + .await + .map_err(|e| ControllerError::ConnectionError(e.to_string()))?; + + Ok(Self { + ctx: Arc::new(Mutex::new(ctx)), + timeout_duration: Duration::from_secs(timeout_secs), + }) + } + + async fn read_coils_with_timeout(&self, addr: u16, count: u16) -> Result, ControllerError> { + let ctx = self.ctx.lock().await; + + // tokio-modbus returns Result, io::Error> + let result = timeout(self.timeout_duration, ctx.read_coils(addr, count)) + .await + .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? + .map_err(|e| ControllerError::ConnectionError(e.to_string()))? + .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; + + Ok(result) + } + + async fn write_single_coil_with_timeout(&self, addr: u16, value: bool) -> Result<(), ControllerError> { + let ctx = self.ctx.lock().await; + + timeout(self.timeout_duration, ctx.write_single_coil(addr, value)) + .await + .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? + .map_err(|e| ControllerError::ConnectionError(e.to_string()))? + .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; + + Ok(()) + } +} + +#[async_trait] +impl RelayController for ModbusRelayController { + async fn read_relay_state(&self, id: RelayId) -> Result { + let addr = id.to_modbus_address(); + let coils = self.read_coils_with_timeout(addr, 1).await?; + + let state = RelayState::from_modbus_coil(coils[0]); + tracing::debug!(target: "modbus", relay_id = id.value(), ?state, "Read relay state"); + + Ok(state) + } + + async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> { + let addr = id.to_modbus_address(); + let value = state == RelayState::On; + + self.write_single_coil_with_timeout(addr, value).await?; + tracing::info!(target: "modbus", relay_id = id.value(), ?state, "Wrote relay state"); + + Ok(()) + } + + async fn read_all_states(&self) -> Result, ControllerError> { + let coils = self.read_coils_with_timeout(0x0000, 8).await?; + + let states: Vec = coils + .into_iter() + .map(RelayState::from_modbus_coil) + .collect(); + + tracing::debug!(target: "modbus", "Read all relay states"); + Ok(states) + } + + async fn write_all_states(&self, states: Vec) -> Result<(), ControllerError> { + if states.len() != 8 { + return Err(ControllerError::ConnectionError( + "Must provide exactly 8 states".to_string(), + )); + } + + let ctx = self.ctx.lock().await; + let coils: Vec = states.iter().map(|s| *s == RelayState::On).collect(); + + timeout(self.timeout_duration, ctx.write_multiple_coils(0x0000, &coils)) + .await + .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? + .map_err(|e| ControllerError::ConnectionError(e.to_string()))? + .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; + + tracing::info!(target: "modbus", "Wrote all relay states"); + Ok(()) + } + + async fn check_connection(&self) -> Result<(), ControllerError> { + // Try reading first coil as health check + self.read_coils_with_timeout(0x0000, 1).await?; + Ok(()) + } + + async fn get_firmware_version(&self) -> Result, ControllerError> { + let ctx = self.ctx.lock().await; + + // Read firmware version from register 0x8000 + let result = timeout( + self.timeout_duration, + ctx.read_holding_registers(0x8000, 1), + ) + .await + .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? + .map_err(|e| ControllerError::ConnectionError(e.to_string()))? + .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; + + if let Some(&version_raw) = result.first() { + let version = f32::from(version_raw) / 100.0; + Ok(Some(format!("v{:.2}", version))) + } else { + Ok(None) + } + } +} +``` + +**Verification**: `cargo check` passes. + +#### Task 4.2: Write Real Hardware Integration Tests +**File**: `tests/integration/modbus_real_hardware_test.rs` + +**Action**: Create hardware tests (marked `#[ignore]` for CI): +```rust +use sta::domain::relay::{controller::RelayController, types::*}; +use sta::infrastructure::modbus::client::ModbusRelayController; + +#[tokio::test] +#[ignore] // Only run with real hardware: cargo test --ignored +async fn real_hardware_read_all_states() { + let controller = ModbusRelayController::new("192.168.1.100", 502, 1, 3) + .await + .expect("Failed to connect to Modbus device"); + + let states = controller.read_all_states().await.unwrap(); + assert_eq!(states.len(), 8); +} + +#[tokio::test] +#[ignore] +async fn real_hardware_toggle_relay() { + let controller = ModbusRelayController::new("192.168.1.100", 502, 1, 3) + .await + .expect("Failed to connect"); + + let id = RelayId::new(1).unwrap(); + + // Read current state + let initial = controller.read_relay_state(id).await.unwrap(); + + // Toggle + let new_state = initial.toggle(); + controller.write_relay_state(id, new_state).await.unwrap(); + + // Verify + let final_state = controller.read_relay_state(id).await.unwrap(); + assert_eq!(final_state, new_state); + + // Toggle back + controller.write_relay_state(id, initial).await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn real_hardware_firmware_version() { + let controller = ModbusRelayController::new("192.168.1.100", 502, 1, 3) + .await + .expect("Failed to connect"); + + let version = controller.get_firmware_version().await.unwrap(); + assert!(version.is_some()); + println!("Firmware version: {}", version.unwrap()); +} +``` + +**Acceptance Criteria**: +- [ ] Connection to real hardware succeeds +- [ ] Read operations return valid data +- [ ] Write operations physically toggle relays +- [ ] Timeout handling works (tested manually with disconnected device) +- [ ] Firmware version reads correctly + +**Verification**: +- `cargo test` passes (hardware tests skipped) +- `cargo test --ignored` passes WITH real hardware connected + +**Deliverables**: +- Real Modbus controller fully functional +- Hardware integration tests +- Timeout and error handling verified + +--- + +### Phase 5: Application Layer - Use Cases (1 day) + +**Objective**: Implement business logic orchestration (use cases). + +**Prerequisites**: Phases 1-4 complete (domain, mock, repository, real controller) + +#### Task 5.1: Write Tests for GetRelayStatus Use Case (TDD) +**File**: `tests/unit/relay/use_cases_test.rs` + +**Action**: Write failing tests: +```rust +use sta::application::relay::get_status::GetRelayStatus; +use sta::domain::relay::{controller::RelayController, repository::RelayLabelRepository, types::*}; +use sta::infrastructure::modbus::mock::MockRelayController; +use sta::infrastructure::persistence::sqlite_repository::SqliteRelayLabelRepository; + +#[tokio::test] +async fn get_relay_status_combines_state_and_label() { + let controller = MockRelayController::new(); + let repository = SqliteRelayLabelRepository::in_memory().await.unwrap(); + + let id = RelayId::new(1).unwrap(); + let label = RelayLabel::new("Test Relay".to_string()).unwrap(); + repository.save_label(id, label.clone()).await.unwrap(); + + controller.write_relay_state(id, RelayState::On).await.unwrap(); + + let use_case = GetRelayStatus::new(Box::new(controller), Box::new(repository)); + let relay = use_case.execute(id).await.unwrap(); + + assert_eq!(relay.id(), id); + assert_eq!(relay.state(), RelayState::On); + assert_eq!(relay.label(), &label); +} +``` + +**Verification**: Test FAILS (use case not implemented). + +#### Task 5.2: Implement GetRelayStatus Use Case +**File**: `src/application/relay/get_status.rs` + +**Action**: Implement use case: +```rust +use crate::domain::relay::{ + controller::{ControllerError, RelayController}, + entity::Relay, + repository::{RelayLabelRepository, RepositoryError}, + types::RelayId, +}; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub enum GetRelayStatusError { + #[error("Controller error: {0}")] + Controller(#[from] ControllerError), + #[error("Repository error: {0}")] + Repository(#[from] RepositoryError), +} + +pub struct GetRelayStatus { + controller: Arc, + repository: Arc, +} + +impl GetRelayStatus { + pub fn new( + controller: Arc, + repository: Arc, + ) -> Self { + Self { + controller, + repository, + } + } + + pub async fn execute(&self, id: RelayId) -> Result { + tracing::debug!(target: "use_case", relay_id = id.value(), "Getting relay status"); + + // Read state from Modbus hardware + let state = self.controller.read_relay_state(id).await?; + + // Read label from repository + let label = self.repository.get_label(id).await?.unwrap_or_default(); + + let relay = Relay::with_label(id, state, label); + + tracing::debug!(target: "use_case", relay_id = id.value(), ?state, + label = relay.label().as_str(), "Retrieved relay status"); + + Ok(relay) + } + + pub async fn execute_all(&self) -> Result, GetRelayStatusError> { + tracing::debug!(target: "use_case", "Getting all relay statuses"); + + // Read all states from Modbus + let states = self.controller.read_all_states().await?; + + // Read all labels from repository + let labels = self.repository.get_all_labels().await?; + + let relays: Vec = (1..=8) + .map(|id_val| { + let id = RelayId::new(id_val).unwrap(); + let state = states[(id_val - 1) as usize]; + let label = labels + .iter() + .find(|(label_id, _)| *label_id == id) + .map(|(_, l)| l.clone()) + .unwrap_or_default(); + Relay::with_label(id, state, label) + }) + .collect(); + + tracing::debug!(target: "use_case", "Retrieved all relay statuses"); + Ok(relays) + } +} +``` + +**Acceptance Criteria**: +- [ ] Use case tests pass +- [ ] Combines controller (state) + repository (label) +- [ ] Structured logging at boundaries +- [ ] Both single and bulk operations work + +**Verification**: `cargo test use_cases_test` passes. + +#### Task 5.3: Implement ToggleRelay and BulkControl Use Cases +**Files**: +- `src/application/relay/toggle_relay.rs` +- `src/application/relay/bulk_control.rs` +- `src/application/relay/update_label.rs` + +**Action**: Follow same TDD pattern (write tests, then implementation). + +**ToggleRelay**: Read current state → toggle → write new state +**BulkControl**: Write all ON or all OFF +**UpdateLabel**: Save label to repository + +**Verification**: All use case tests pass. + +**Deliverables**: +- All use cases implemented with TDD +- Use cases combine controller + repository +- >95% test coverage for application layer +- Structured logging throughout + +--- + +### Phase 6: Presentation Layer - HTTP API (1.5 days) + +**Objective**: Expose use cases via RESTful HTTP API with OpenAPI. + +**Prerequisites**: Phase 5 complete (use cases) + +#### Task 6.1: Define API DTOs and Responses +**File**: `src/route/relay.rs` + +**Action**: Create DTOs: +```rust +use poem_openapi::{Object, ApiResponse, payload::Json}; +use serde::{Deserialize, Serialize}; + +#[derive(Object, Debug, Clone, Serialize, Deserialize)] +pub struct RelayDto { + pub id: u8, + pub state: String, // "on" | "off" + pub label: String, +} + +#[derive(Object, Debug, Clone, Serialize, Deserialize)] +pub struct RelayListResponse { + pub relays: Vec, +} + +#[derive(Object, Debug, Clone, Serialize, Deserialize)] +pub struct ToggleRequest { + // Empty body - toggle action implied by endpoint +} + +#[derive(Object, Debug, Clone, Serialize, Deserialize)] +pub struct UpdateLabelRequest { + pub label: String, +} + +#[derive(Object, Debug, Clone, Serialize, Deserialize)] +pub struct HealthResponse { + pub status: String, // "healthy" | "unhealthy" + pub device_connected: bool, + pub firmware_version: Option, +} + +#[derive(ApiResponse)] +pub enum RelayApiResponse { + #[oai(status = 200)] + Ok(Json), + #[oai(status = 400)] + BadRequest(Json), + #[oai(status = 500)] + InternalServerError(Json), + #[oai(status = 504)] + GatewayTimeout(Json), +} + +#[derive(ApiResponse)] +pub enum RelayListApiResponse { + #[oai(status = 200)] + Ok(Json), + #[oai(status = 500)] + InternalServerError(Json), +} + +#[derive(Object, Debug, Clone, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: String, +} +``` + +#### Task 6.2: Implement API Endpoints +**File**: `src/route/relay.rs` (continued) + +**Action**: Implement handlers: +```rust +use poem_openapi::{OpenApi, param::Path, payload::Json}; +use crate::application::relay::*; +use crate::domain::relay::types::*; + +pub struct RelayApi { + get_status: Arc, + toggle_relay: Arc, + bulk_control: Arc, + update_label: Arc, + get_health: Arc, +} + +impl RelayApi { + pub fn new( + controller: Arc, + repository: Arc, + ) -> Self { + Self { + get_status: Arc::new(get_status::GetRelayStatus::new( + controller.clone(), + repository.clone(), + )), + toggle_relay: Arc::new(toggle_relay::ToggleRelay::new( + controller.clone(), + repository.clone(), + )), + bulk_control: Arc::new(bulk_control::BulkControl::new(controller.clone())), + update_label: Arc::new(update_label::UpdateLabel::new(repository.clone())), + get_health: Arc::new(get_health::GetDeviceHealth::new(controller.clone())), + } + } +} + +#[OpenApi(tag = "ApiCategory::Relay")] +impl RelayApi { + /// Get all relay statuses + #[oai(path = "/relays", method = "get")] + async fn get_all_relays(&self) -> RelayListApiResponse { + tracing::info!(target: "api", "GET /api/relays"); + + match self.get_status.execute_all().await { + Ok(relays) => { + let dtos: Vec = relays.iter().map(|r| RelayDto { + id: r.id().value(), + state: match r.state() { + RelayState::On => "on".to_string(), + RelayState::Off => "off".to_string(), + }, + label: r.label().as_str().to_string(), + }).collect(); + + RelayListApiResponse::Ok(Json(RelayListResponse { relays: dtos })) + } + Err(e) => { + tracing::error!(target: "api", error = %e, "Failed to get all relays"); + RelayListApiResponse::InternalServerError(Json(ErrorResponse { + error: e.to_string(), + })) + } + } + } + + /// Get single relay status + #[oai(path = "/relays/:id", method = "get")] + async fn get_relay(&self, id: Path) -> RelayApiResponse { + tracing::info!(target: "api", relay_id = id.0, "GET /api/relays/{}", id.0); + + let relay_id = match RelayId::new(id.0) { + Ok(id) => id, + Err(e) => { + return RelayApiResponse::BadRequest(Json(ErrorResponse { + error: e.to_string(), + })); + } + }; + + match self.get_status.execute(relay_id).await { + Ok(relay) => RelayApiResponse::Ok(Json(RelayDto { + id: relay.id().value(), + state: match relay.state() { + RelayState::On => "on".to_string(), + RelayState::Off => "off".to_string(), + }, + label: relay.label().as_str().to_string(), + })), + Err(e) => { + tracing::error!(target: "api", relay_id = id.0, error = %e, "Failed to get relay"); + RelayApiResponse::InternalServerError(Json(ErrorResponse { + error: e.to_string(), + })) + } + } + } + + /// Toggle relay state + #[oai(path = "/relays/:id/toggle", method = "post")] + async fn toggle_relay(&self, id: Path) -> RelayApiResponse { + tracing::info!(target: "api", relay_id = id.0, "POST /api/relays/{}/toggle", id.0); + + let relay_id = match RelayId::new(id.0) { + Ok(id) => id, + Err(e) => { + return RelayApiResponse::BadRequest(Json(ErrorResponse { + error: e.to_string(), + })); + } + }; + + match self.toggle_relay.execute(relay_id).await { + Ok(relay) => RelayApiResponse::Ok(Json(RelayDto { + id: relay.id().value(), + state: match relay.state() { + RelayState::On => "on".to_string(), + RelayState::Off => "off".to_string(), + }, + label: relay.label().as_str().to_string(), + })), + Err(e) => { + tracing::error!(target: "api", relay_id = id.0, error = %e, "Failed to toggle relay"); + RelayApiResponse::InternalServerError(Json(ErrorResponse { + error: e.to_string(), + })) + } + } + } + + /// Turn all relays ON + #[oai(path = "/relays/bulk/on", method = "post")] + async fn all_on(&self) -> RelayListApiResponse { + tracing::info!(target: "api", "POST /api/relays/bulk/on"); + // Implementation... + } + + /// Turn all relays OFF + #[oai(path = "/relays/bulk/off", method = "post")] + async fn all_off(&self) -> RelayListApiResponse { + tracing::info!(target: "api", "POST /api/relays/bulk/off"); + // Implementation... + } + + /// Update relay label + #[oai(path = "/relays/:id/label", method = "patch")] + async fn update_label(&self, id: Path, req: Json) -> RelayApiResponse { + tracing::info!(target: "api", relay_id = id.0, label = %req.0.label, "PATCH /api/relays/{}/label", id.0); + // Implementation... + } + + /// Get device health status + #[oai(path = "/health", method = "get")] + async fn health(&self) -> poem_openapi::payload::Json { + tracing::info!(target: "api", "GET /api/health"); + // Implementation... + } +} +``` + +#### Task 6.3: Register RelayApi in Route Aggregator +**File**: `src/route/mod.rs` + +**Action**: Add Relay category and register API: +```rust +#[derive(Tags)] +enum ApiCategory { + Health, + Meta, + Relay, // Add this +} + +pub(crate) struct Api { + health: health::HealthApi, + meta: meta::MetaApi, + relay: relay::RelayApi, // Add this +} + +impl From<&Settings> for Api { + fn from(value: &Settings) -> Self { + let health = health::HealthApi; + let meta = meta::MetaApi::from(&value.application); + + // Initialize relay dependencies + let controller = // ... create based on settings + let repository = // ... create based on settings + let relay = relay::RelayApi::new(controller, repository); + + Self { health, meta, relay } + } +} + +impl Api { + pub fn apis(self) -> (health::HealthApi, meta::MetaApi, relay::RelayApi) { + (self.health, self.meta, self.relay) + } +} +``` + +#### Task 6.4: Write API Contract Tests +**File**: `tests/contract/relay_api_contract_test.rs` + +**Action**: Test API contracts: +```rust +use poem::test::TestClient; +use sta::get_test_app; + +#[tokio::test] +async fn get_all_relays_returns_200() { + let app = get_test_app(); + let cli = TestClient::new(app); + + let resp = cli.get("/api/relays").send().await; + resp.assert_status_is_ok(); + + let json: serde_json::Value = resp.json().await.value().deserialize(); + assert!(json["relays"].is_array()); + assert_eq!(json["relays"].as_array().unwrap().len(), 8); +} + +#[tokio::test] +async fn toggle_relay_returns_200() { + let app = get_test_app(); + let cli = TestClient::new(app); + + let resp = cli.post("/api/relays/1/toggle").send().await; + resp.assert_status_is_ok(); + + let json: serde_json::Value = resp.json().await.value().deserialize(); + assert_eq!(json["id"], 1); + assert!(json["state"] == "on" || json["state"] == "off"); +} + +#[tokio::test] +async fn invalid_relay_id_returns_400() { + let app = get_test_app(); + let cli = TestClient::new(app); + + let resp = cli.get("/api/relays/9").send().await; + resp.assert_status(400); +} +``` + +**Acceptance Criteria**: +- [ ] All 6 endpoints implemented +- [ ] OpenAPI spec auto-generated +- [ ] Swagger UI accessible at `/` +- [ ] All contract tests pass +- [ ] Error responses include meaningful messages +- [ ] Logging at all API boundaries + +**Verification**: +- `cargo test contract` passes +- Visit `http://localhost:8000/` and test via Swagger UI + +**Deliverables**: +- Complete HTTP API with OpenAPI +- All endpoints tested +- Route registration complete +- API documentation auto-generated + +--- + +### Phase 7: Frontend - Vue 3 Application (2 days) + +**Objective**: Build responsive web interface with HTTP polling. + +**Prerequisites**: Phase 6 complete (API endpoints working) + +#### Task 7.1: Initialize Vue 3 Project +**Directory**: `frontend/` + +**Action**: +```bash +npm create vite@latest frontend -- --template vue-ts +cd frontend +npm install +npm install axios +``` + +**Verification**: `npm run dev` starts development server. + +#### Task 7.2: Generate OpenAPI TypeScript Client +**File**: `frontend/src/services/api-client.ts` + +**Action**: Use openapi-typescript-codegen or create manual client: +```typescript +import axios, { type AxiosInstance } from 'axios'; + +export interface RelayDto { + id: number; + state: 'on' | 'off'; + label: string; +} + +export interface RelayListResponse { + relays: RelayDto[]; +} + +export interface HealthResponse { + status: 'healthy' | 'unhealthy'; + device_connected: boolean; + firmware_version?: string; +} + +export class RelayApiClient { + private client: AxiosInstance; + + constructor(baseURL: string = 'http://localhost:8000/api') { + this.client = axios.create({ baseURL }); + } + + async getAllRelays(): Promise { + const response = await this.client.get('/relays'); + return response.data; + } + + async getRelay(id: number): Promise { + const response = await this.client.get(`/relays/${id}`); + return response.data; + } + + async toggleRelay(id: number): Promise { + const response = await this.client.post(`/relays/${id}/toggle`); + return response.data; + } + + async allOn(): Promise { + const response = await this.client.post('/relays/bulk/on'); + return response.data; + } + + async allOff(): Promise { + const response = await this.client.post('/relays/bulk/off'); + return response.data; + } + + async updateLabel(id: number, label: string): Promise { + const response = await this.client.patch(`/relays/${id}/label`, { label }); + return response.data; + } + + async getHealth(): Promise { + const response = await this.client.get('/health'); + return response.data; + } +} + +export const apiClient = new RelayApiClient(); +``` + +**Verification**: TypeScript compiles without errors. + +#### Task 7.3: Implement HTTP Polling Composable +**File**: `frontend/src/composables/useRelayPolling.ts` + +**Action**: +```typescript +import { ref, onMounted, onUnmounted } from 'vue'; +import { apiClient, type RelayDto, type HealthResponse } from '@/services/api-client'; + +export function useRelayPolling(intervalMs: number = 2000) { + const relays = ref([]); + const health = ref(null); + const isLoading = ref(true); + const error = ref(null); + + let pollingInterval: number | null = null; + + const fetchData = async () => { + try { + const [relayData, healthData] = await Promise.all([ + apiClient.getAllRelays(), + apiClient.getHealth(), + ]); + + relays.value = relayData.relays; + health.value = healthData; + error.value = null; + } catch (err: any) { + error.value = err.message || 'Failed to fetch data'; + console.error('Polling error:', err); + } finally { + isLoading.value = false; + } + }; + + const startPolling = () => { + fetchData(); // Immediate fetch + pollingInterval = window.setInterval(fetchData, intervalMs); + }; + + const stopPolling = () => { + if (pollingInterval !== null) { + clearInterval(pollingInterval); + pollingInterval = null; + } + }; + + onMounted(startPolling); + onUnmounted(stopPolling); + + return { + relays, + health, + isLoading, + error, + refresh: fetchData, + }; +} +``` + +#### Task 7.4: Implement RelayCard Component +**File**: `frontend/src/components/RelayCard.vue` + +**Action**: +```vue + + + + + +``` + +#### Task 7.5: Implement RelayGrid and App +**Files**: +- `frontend/src/components/RelayGrid.vue` +- `frontend/src/components/BulkControls.vue` +- `frontend/src/components/HealthStatus.vue` +- `frontend/src/App.vue` + +**Action**: Create grid layout with bulk controls and health status display. + +**Verification**: Frontend displays all 8 relays, polling works, toggles update state. + +#### Task 7.6: Responsive Design and Cross-Browser Testing +**Action**: Test on Chrome, Firefox, Safari, Edge. Test mobile/tablet layouts. + +**Acceptance Criteria**: +- [ ] HTTP polling every 2 seconds +- [ ] Relay state updates within 2 seconds +- [ ] Toggle actions complete within 1 second +- [ ] Label editing works (double-click, Enter/blur to save) +- [ ] Bulk controls work +- [ ] Health status displays correctly +- [ ] Responsive on mobile/tablet/desktop +- [ ] Works on Chrome, Firefox, Safari, Edge + +**Deliverables**: +- Complete Vue 3 frontend +- HTTP polling implemented +- All user stories functional +- Responsive design + +--- + +### Phase 8: Integration & Testing (0.5 days) + +**Objective**: End-to-end testing and coverage verification. + +#### Task 8.1: Manual Testing Against Real Hardware +**Action**: Test all user stories from spec.md with real device: +- [ ] US1: Monitor relay status +- [ ] US2: Toggle individual relay +- [ ] US3: Bulk relay control +- [ ] US4: System health monitoring +- [ ] US5: Relay labeling + +#### Task 8.2: Load Testing +**Action**: Test with 10 concurrent users (use `wrk` or Apache Bench): +```bash +wrk -t10 -c10 -d30s http://localhost:8000/api/relays +``` + +**Acceptance Criteria**: <100ms API response under load. + +#### Task 8.3: Coverage Verification +**Action**: +```bash +just coverage +``` + +**Acceptance Criteria**: >90% coverage for domain + application layers. + +#### Task 8.4: Error Scenario Testing +**Action**: Test error handling: +- [ ] Device disconnected during operation +- [ ] Modbus timeout (simulate with blocked network) +- [ ] Invalid relay IDs via API +- [ ] Database file permissions issue +- [ ] Frontend error display when backend down + +**Deliverables**: +- All user stories verified +- Load testing passed +- >90% coverage achieved +- Error handling verified + +--- + +## Testing Strategy + +### Test Coverage Targets + +| Layer | Coverage Target | Test Type | Tooling | +|-------|----------------|-----------|---------| +| Domain | 100% | Unit tests | `cargo test` | +| Application | >95% | Unit tests with mocks | `mockall` | +| Infrastructure | >80% | Integration tests | mocks + real hardware | +| Presentation | >90% | Contract tests | `poem::test::TestClient` | +| Frontend | >80% | Component tests | Vitest | + +### Mock Strategy + +**When to use mocks**: +- All CI/CD tests (no hardware available) +- Unit tests for use cases +- Fast feedback during development + +**When to use real hardware**: +- Integration tests (marked `#[ignore]`) +- Manual testing before deployment +- Debugging Modbus protocol issues + +**Mock Implementation Locations**: +- `src/infrastructure/modbus/mock.rs` - MockRelayController +- Test files: Use `MockRelayController::new()` in tests + +### Test Organization + +``` +tests/ +├── unit/ # Fast, no I/O, use mocks +│ └── relay/ +│ ├── domain_types_test.rs +│ ├── entity_test.rs +│ └── use_cases_test.rs +│ +├── integration/ # I/O allowed, can use real resources +│ ├── modbus_mock_test.rs +│ ├── modbus_real_hardware_test.rs # cargo test --ignored +│ ├── sqlite_repository_test.rs +│ └── api_integration_test.rs +│ +└── contract/ # API contract validation + └── relay_api_contract_test.rs +``` + +--- + +## Dependencies & Setup + +### Rust Dependencies (Cargo.toml) + +```toml +[dependencies] +# Existing dependencies... +tokio-modbus = { version = "0.17.0", features = ["rtu", "tcp"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +mockall = "0.13" +async-trait = "0.1" +``` + +### Frontend Dependencies (package.json) + +```json +{ + "dependencies": { + "vue": "^3.4.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vitest": "^1.0.0" + } +} +``` + +### Database Setup + +**Location**: `relay_labels.db` (configurable in settings) + +**Schema**: Auto-initialized by `SqliteRelayLabelRepository` on first run using `schema.sql`. + +**Migrations**: Not needed for MVP (simple schema, auto-create). + +### Environment Variables + +Add to `settings/development.yaml`: +```yaml +modbus: + host: "192.168.1.100" # Update with actual IP + port: 502 + slave_id: 1 + timeout_secs: 3 + +database: + path: "relay_labels.db" +``` + +--- + +## Integration Points + +### Dependency Injection in Startup + +**File**: `src/startup.rs` + +**Changes needed**: +1. Create Modbus controller based on settings +2. Create SQLite repository +3. Pass both to `RelayApi::new()` +4. Register `RelayApi` in route aggregator + +Example: +```rust +// In Application::build() +let modbus_settings = &settings.modbus; +let controller: Arc = if cfg!(test) { + Arc::new(MockRelayController::new()) +} else { + Arc::new( + ModbusRelayController::new( + &modbus_settings.host, + modbus_settings.port, + modbus_settings.slave_id, + modbus_settings.timeout_secs, + ) + .await + .expect("Failed to connect to Modbus device"), + ) +}; + +let repository: Arc = Arc::new( + SqliteRelayLabelRepository::new(&settings.database.path) + .await + .expect("Failed to initialize database"), +); + +let relay_api = RelayApi::new(controller, repository); +``` + +### Route Registration + +**File**: `src/route/mod.rs` + +Update `Api::apis()` return type to include `relay::RelayApi`. + +### Graceful Degradation (FR-023) + +Backend must start even when Modbus device unreachable. Implement connection retry logic in background task: + +```rust +// Spawn background task in startup +tokio::spawn(async move { + loop { + if let Err(e) = controller.check_connection().await { + tracing::warn!("Modbus device unavailable: {}", e); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } +}); +``` + +--- + +## Verification Checklist + +Before marking implementation complete, verify: + +### Backend +- [ ] All domain unit tests pass (`cargo test domain`) +- [ ] All application use case tests pass (`cargo test application`) +- [ ] All infrastructure tests pass (`cargo test infrastructure`) +- [ ] All API contract tests pass (`cargo test contract`) +- [ ] Real hardware integration tests pass (`cargo test --ignored`) +- [ ] `cargo clippy` shows no warnings +- [ ] `cargo fmt --check` passes +- [ ] Test coverage >90% (`just coverage`) +- [ ] OpenAPI spec available at `/specs` +- [ ] Swagger UI works at `/` +- [ ] Backend starts successfully when Modbus device unreachable +- [ ] Structured logging outputs to console + +### Frontend +- [ ] `npm run build` succeeds +- [ ] All components render correctly +- [ ] HTTP polling updates state every 2 seconds +- [ ] Toggle actions complete within 1 second +- [ ] Bulk controls work (All ON, All OFF) +- [ ] Label editing works (double-click, save on Enter/blur) +- [ ] Health status displays correctly +- [ ] Error messages display when backend unavailable +- [ ] Responsive design works on mobile/tablet/desktop +- [ ] Cross-browser compatibility (Chrome, Firefox, Safari, Edge) + +### Integration +- [ ] All user stories from spec.md verified with real hardware +- [ ] Load testing: 10 concurrent users, <100ms API response +- [ ] Error scenarios tested (disconnect, timeout, invalid input) +- [ ] Labels persist across backend restarts +- [ ] Firmware version displays (if available) + +### Deployment Readiness +- [ ] Configuration documented in README +- [ ] Environment variables documented +- [ ] Database location configurable +- [ ] Systemd service files created for Raspberry Pi backend +- [ ] Traefik configuration documented (reverse proxy + HTTPS + Authelia) +- [ ] Frontend production build tested +- [ ] Cloudflare Pages deployment configuration ready +- [ ] Backend CORS settings configured for frontend origin + +--- + +## Timeline Summary + +| Phase | Duration | Deliverables | +|-------|----------|--------------| +| 0: Setup | 0.5 days | Dependencies, module structure, settings | +| 1: Domain Layer | 1 day | Types, entities, traits (100% coverage) | +| 2: Mock Infrastructure | 0.5 days | MockRelayController, integration tests | +| 3: SQLite Repository | 1 day | Database persistence, schema, tests | +| 4: Real Modbus Client | 1.5 days | tokio-modbus integration, hardware tests | +| 5: Application Use Cases | 1 day | Business logic orchestration, >95% coverage | +| 6: HTTP API | 1.5 days | Poem endpoints, OpenAPI, contract tests | +| 7: Frontend | 2 days | Vue 3 app, polling, responsive design | +| 8: Integration & Testing | 0.5 days | E2E testing, coverage verification | +| **TOTAL** | **9 days** | **Production-ready MVP** | + +*Note: Original estimate was 7 days. Revised to 9 days accounting for real hardware integration testing and comprehensive coverage.* + +--- + +## Next Steps After Implementation + +Once this implementation plan is complete: +1. Deploy backend to Raspberry Pi 3B+ with Traefik reverse proxy and Authelia authentication +2. Deploy frontend to Cloudflare Pages with environment variable for backend API URL +3. Configure Traefik to handle HTTPS termination and route to backend +4. Consider future enhancements (P3 features): + - Scheduling (turn relay on/off at specific times) + - Automation rules (turn relay on if another relay state changes) + - Metrics and logging (relay toggle history) + - Multi-device support (control multiple 8-relay boards) +3. Monitor production performance and reliability +4. Gather user feedback for UX improvements + +--- + +## References + +- [Feature Specification](./spec.md) - Complete requirements and user stories +- [Architecture Decisions](./decisions.md) - Technical choices and rationale +- [Research Findings](./research.md) - tokio-modbus patterns, WebSocket vs polling +- [Type Design](./types-design.md) - Type-Driven Development (TyDD) details +- [Project Constitution](../constitution.md) - Hexagonal architecture, SOLID, TDD principles +- [Modbus Hardware Documentation](../../docs/Modbus_POE_ETH_Relay.md) - Device protocol details diff --git a/specs/001-modbus-relay-control/research.md b/specs/001-modbus-relay-control/research.md new file mode 100644 index 0000000..7973e97 --- /dev/null +++ b/specs/001-modbus-relay-control/research.md @@ -0,0 +1,718 @@ +# Research Document: Modbus Relay Control System + +**Created**: 2025-12-28 +**Feature**: [spec.md](./spec.md) +**Status**: Complete + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Tokio-Modbus Research](#tokio-modbus-research) +3. [WebSocket vs HTTP Polling](#websocket-vs-http-polling) +4. [Existing Codebase Patterns](#existing-codebase-patterns) +5. [Integration Recommendations](#integration-recommendations) + +--- + +## Executive Summary + +### Key Decisions + +| Decision Area | Recommendation | Rationale | +|---------------------------|--------------------------------------|---------------------------------------------------------| +| **Modbus Library** | tokio-modbus 0.17.0 | Native async/await, production-ready, good testability | +| **Communication Pattern** | HTTP Polling (as in spec) | Simpler, reliable, adequate for 10 users @ 2s intervals | +| **Connection Management** | Arc> for MVP | Single device, simple, can upgrade later if needed | +| **Retry Strategy** | Simple retry-once helper | Matches FR-007 requirement | +| **Testing Approach** | Trait-based abstraction with mockall | Enables >90% coverage without hardware | + +### User Input Analysis + +**User requested**: "Use tokio-modbus crate, poem-openapi for REST API, Vue.js with WebSocket for real-time updates" + +**Findings**: +- ✅ tokio-modbus 0.17.0: Excellent choice, validated by research +- ✅ poem-openapi: Already in use, working well +- ⚠️ **WebSocket vs HTTP Polling**: Spec says HTTP polling (FR-028). WebSocket adds 43x complexity for negligible benefit at this scale. + +**RECOMMENDATION**: Maintain HTTP polling as specified. WebSocket complexity not justified for 10 concurrent users with 2-second update intervals. + +### Deployment Architecture + +**User clarification (2025-12-29)**: Frontend on Cloudflare Pages, backend on Raspberry Pi behind Traefik with Authelia + +**Architecture**: +- **Frontend**: Cloudflare Pages (Vue 3 static build) - global CDN delivery +- **Backend**: Raspberry Pi HTTP API (same local network as Modbus device) +- **Reverse Proxy**: Traefik on Raspberry Pi + - HTTPS termination (TLS certificates) + - Authelia middleware for authentication + - Routes frontend requests to backend HTTP service +- **Communication Flow**: + - Frontend (CDN) → HTTPS → Traefik (HTTPS termination + auth) → Backend (HTTP) → Modbus TCP → Device + +**Security**: +- Frontend-Backend: HTTPS via Traefik (encrypted, authenticated) +- Backend-Device: Modbus TCP on local network (unencrypted, local only) + +--- + +## Tokio-Modbus Research + +### Decision: Recommended Patterns + +**Primary Recommendation**: Use tokio-modbus 0.17.0 with a custom trait-based abstraction layer (`RelayController` trait) for testability. Implement connection management using Arc> for MVP. + +### Technical Details + +**Version**: tokio-modbus 0.17.0 (latest stable, released 2025-10-22) + +**Protocol**: Modbus RTU over TCP (NOT Modbus TCP) +- Hardware uses RTU protocol tunneled over TCP +- Includes CRC16 validation +- Different from native Modbus TCP (no CRC, different framing) + +**Connection Strategy**: +- Shared `Arc>` for simplicity +- Single persistent connection (only one device) +- Can migrate to dedicated async task pattern if reconnection logic needed + +**Timeout Handling**: +- Wrap all operations with `tokio::time::timeout(Duration::from_secs(3), ...)` +- **CRITICAL**: tokio-modbus has NO built-in timeouts + +**Retry Logic**: +- Implement simple retry-once helper per FR-007 +- Matches specification requirement + +**Testing**: +- Use `mockall` crate with `async-trait` for unit testing +- Trait abstraction enables testing without hardware +- Supports >90% test coverage target (NFR-013) + +### Critical Gotchas + +1. **Device Gateway Configuration**: Hardware MUST be set to "Multi-host non-storage type" - default storage type sends spurious queries causing failures + +2. **No Built-in Timeouts**: tokio-modbus has NO automatic timeouts - must wrap every operation with `tokio::time::timeout` + +3. **RTU vs TCP Confusion**: Device uses Modbus RTU protocol over TCP (with CRC), not native Modbus TCP protocol + +4. **Address Indexing**: Relays labeled 1-8, but Modbus addresses are 0-7 (use newtype pattern with conversion methods) + +5. **Nested Result Handling**: Returns `Result, std::io::Error>` - must handle both layers (use `???` triple-question-mark pattern) + +6. **Concurrent Access**: Context is not thread-safe - requires `Arc` or dedicated task serialization + +### Code Examples + +**Basic Connection Setup**: +```rust +use tokio_modbus::prelude::*; +use tokio::time::{timeout, Duration}; + +// Connect to device +let socket_addr = "192.168.1.200:8234".parse()?; +let mut ctx = tcp::connect(socket_addr).await?; + +// Set slave ID (unit identifier) +ctx.set_slave(Slave(0x01)); + +// Read all 8 relay states with timeout +let states = timeout( + Duration::from_secs(3), + ctx.read_coils(0x0000, 8) +).await???; // Triple-? handles timeout + transport + exception errors +``` + +**Toggle Relay with Retry**: +```rust +async fn toggle_relay( + ctx: &mut Context, + relay_id: u8, // 1-8 +) -> Result<(), RelayError> { + let addr = (relay_id - 1) as u16; // Convert to 0-7 + + // Read current state + let states = timeout(Duration::from_secs(3), ctx.read_coils(addr, 1)) + .await???; + let current = states[0]; + + // Write opposite state with retry + let new_state = !current; + let write_op = || async { + timeout(Duration::from_secs(3), ctx.write_single_coil(addr, new_state)) + .await + }; + + // Retry once on failure (FR-007) + match write_op().await { + Ok(Ok(Ok(()))) => Ok(()), + Err(_) | Ok(Err(_)) | Ok(Ok(Err(_))) => { + tracing::warn!("Write failed, retrying"); + write_op().await??? + } + } +} +``` + +**Trait-Based Abstraction for Testing**: +```rust +use async_trait::async_trait; + +#[async_trait] +pub trait RelayController: Send + Sync { + async fn read_all_states(&mut self) -> Result, RelayError>; + async fn write_state(&mut self, relay_id: RelayId, state: RelayState) -> Result<(), RelayError>; +} + +// Real implementation with tokio-modbus +pub struct ModbusRelayController { + ctx: Arc>, +} + +#[async_trait] +impl RelayController for ModbusRelayController { + async fn read_all_states(&mut self) -> Result, RelayError> { + let mut ctx = self.ctx.lock().await; + timeout(Duration::from_secs(3), ctx.read_coils(0, 8)) + .await + .map_err(|_| RelayError::Timeout)? + .map_err(RelayError::Transport)? + .map_err(RelayError::Exception) + } + // ... other methods +} + +// Mock for testing (using mockall) +mock! { + pub RelayController {} + + #[async_trait] + impl RelayController for RelayController { + async fn read_all_states(&mut self) -> Result, RelayError>; + async fn write_state(&mut self, relay_id: RelayId, state: RelayState) -> Result<(), RelayError>; + } +} +``` + +### Alternatives Considered + +1. **modbus-robust**: Provides auto-reconnection but lacks retry logic and timeouts - insufficient for production +2. **bb8 connection pool**: Overkill for single-device scenario, adds unnecessary complexity +3. **Synchronous modbus-rs**: Would block Tokio threads, poor scalability for concurrent users +4. **Custom Modbus implementation**: Reinventing wheel, error-prone, significant development time + +### Resources + +- [GitHub - slowtec/tokio-modbus](https://github.com/slowtec/tokio-modbus) +- [tokio-modbus on docs.rs](https://docs.rs/tokio-modbus/) +- [Context7 MCP: `/slowtec/tokio-modbus`](mcp://context7/slowtec/tokio-modbus) +- [Context7 MCP: `/websites/rs_tokio-modbus_0_16_3_tokio_modbus`](mcp://context7/websites/rs_tokio-modbus_0_16_3_tokio_modbus) + +--- + +## WebSocket vs HTTP Polling + +### Recommendation: HTTP Polling (as specified) + +The specification's decision to use HTTP polling is technically sound. **HTTP polling is the better choice** for this specific use case. + +### Performance at Your Scale (10 users, 2-second intervals) + +**Bandwidth Comparison:** +- HTTP Polling: ~20 Kbps (10 users × 0.5 req/sec × 500 bytes × 8) +- WebSocket: ~2.4 Kbps sustained +- **Difference: 17.6 Kbps** - negligible on any modern network + +**Server Load:** +- HTTP Polling: 5 requests/second system-wide (trivial) +- WebSocket: 10 persistent connections (~80-160 KB memory) +- **Verdict: Both are trivial at this scale** + +### Implementation Complexity + +**HTTP Polling:** +- Backend: 0 lines (reuse existing `GET /api/relays`) +- Frontend: ~10 lines (simple setInterval) +- **Total effort: 15 minutes** + +**WebSocket:** +- Backend: ~115 lines (handler + background poller + channel setup) +- Frontend: ~135 lines (WebSocket manager + reconnection logic) +- Testing: ~180 lines (connection lifecycle + reconnection tests) +- **Total effort: 2-3 days + ongoing maintenance** + +**Complexity ratio: 43x more code for WebSocket** + +### Reliability & Error Handling + +**HTTP Polling Advantages:** +- Stateless (automatic recovery on next poll) +- Standard HTTP error codes +- Works everywhere (proxies, firewalls, old browsers) +- No connection state management +- Simple testing + +**WebSocket Challenges:** +- Connection lifecycle management +- Exponential backoff reconnection logic +- State synchronization on reconnect +- Thundering herd problem (all clients reconnect after server restart) +- May fail behind corporate proxies (requires fallback to HTTP polling anyway) + +### Decision Matrix + +| Criterion | HTTP Polling | WebSocket | Weight | +|-----------|--------------|-----------|--------| +| Simplicity | 5 | 2 | 3x | +| Reliability | 5 | 3 | 3x | +| Testing | 5 | 2 | 2x | +| Performance @ 10 users | 4 | 5 | 1x | +| Scalability to 100+ | 3 | 5 | 1x | +| Architecture fit | 5 | 3 | 2x | + +**Weighted Scores:** +- **HTTP Polling: 4.56/5** +- **WebSocket: 3.19/5** + +HTTP Polling scores **43% higher** when complexity, reliability, and testing are properly weighted for this project's scale. + +### When WebSocket Makes Sense + +WebSocket advantages manifest at: +- **100+ concurrent users** (4x throughput advantage becomes meaningful) +- **Sub-second update requirements** (<1 second intervals) +- **High-frequency updates** where latency matters +- **Bidirectional communication** (chat, gaming, trading systems) + +For relay control with 2-second polling: +- Latency: 0-4 seconds (avg 2 sec) - **acceptable for lights/pumps** +- Not a real-time critical system (not chat, gaming, or trading) + +### Migration Path (If Needed Later) + +Starting with HTTP polling does NOT prevent WebSocket adoption later: + +1. **Phase 1:** Add `/api/ws` endpoint (non-breaking change) +2. **Phase 2:** Progressive enhancement (detect WebSocket support) +3. **Phase 3:** Gradual rollout with monitoring + +**Key Point:** HTTP polling provides a baseline. Adding WebSocket later is straightforward, but removing WebSocket complexity is harder. + +### Poem WebSocket Support (For Reference) + +Poem has excellent WebSocket support through `poem::web::websocket`: + +```rust +use poem::web::websocket::{WebSocket, Message}; + +#[handler] +async fn ws_handler( + ws: WebSocket, + state_tx: Data<&watch::Sender>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| async move { + let (mut sink, mut stream) = socket.split(); + let mut rx = state_tx.subscribe(); + + // Send initial state + let initial = rx.borrow().clone(); + sink.send(Message::text(serde_json::to_string(&initial)?)).await?; + + // Stream updates + while rx.changed().await.is_ok() { + let state = rx.borrow().clone(); + sink.send(Message::text(serde_json::to_string(&state)?)).await?; + } + }) +} +``` + +**Broadcasting Pattern**: Use `tokio::sync::watch` channel: +- Maintains only most recent value (perfect for relay state) +- Automatic deduplication of identical states +- New connections get immediate state snapshot +- Memory-efficient (single state copy) + +### Resources + +- [Poem WebSocket API Documentation](https://docs.rs/poem/latest/poem/web/websocket/) +- [HTTP vs WebSockets Performance](https://blog.feathersjs.com/http-vs-websockets-a-performance-comparison-da2533f13a77) +- [Tokio Channels Tutorial](https://tokio.rs/tokio/tutorial/channels) + +--- + +## Existing Codebase Patterns + +### Architecture Overview + +The current codebase is a well-structured Rust backend API using Poem framework with OpenAPI support, following clean architecture principles. + +**Current Structure**: +``` +src/ +├── lib.rs - Library entry point, orchestrates application setup +├── main.rs - Binary entry point, calls lib::run() +├── startup.rs - Application builder, server configuration, route setup +├── settings.rs - Configuration from YAML files + environment variables +├── telemetry.rs - Logging and tracing setup +├── route/ - HTTP endpoint handlers +│ ├── mod.rs - API aggregation and OpenAPI tags +│ ├── health.rs - Health check endpoints +│ └── meta.rs - Application metadata endpoints +└── middleware/ - Custom middleware implementations + ├── mod.rs + └── rate_limit.rs - Rate limiting middleware using governor +``` + +### Key Patterns Discovered + +#### 1. Route Registration Pattern + +**Location**: `src/startup.rs:95-107` + +```rust +fn setup_app(settings: &Settings) -> poem::Route { + let api_service = OpenApiService::new( + Api::from(settings).apis(), + settings.application.clone().name, + settings.application.clone().version, + ) + .url_prefix("/api"); + let ui = api_service.swagger_ui(); + poem::Route::new() + .nest("/api", api_service.clone()) + .nest("/specs", api_service.spec_endpoint_yaml()) + .nest("/", ui) +} +``` + +**Key Insights**: +- OpenAPI service created with all API handlers via `.apis()` tuple +- URL prefix `/api` applied to all API routes +- Swagger UI automatically mounted at root `/` +- OpenAPI spec YAML available at `/specs` + +#### 2. API Handler Organization Pattern + +**Location**: `src/route/mod.rs:14-37` + +```rust +#[derive(Tags)] +enum ApiCategory { + Health, + Meta, +} + +pub(crate) struct Api { + health: health::HealthApi, + meta: meta::MetaApi, +} + +impl From<&Settings> for Api { + fn from(value: &Settings) -> Self { + let health = health::HealthApi; + let meta = meta::MetaApi::from(&value.application); + Self { health, meta } + } +} + +impl Api { + pub fn apis(self) -> (health::HealthApi, meta::MetaApi) { + (self.health, self.meta) + } +} +``` + +**Key Insights**: +- `Tags` enum groups APIs into categories for OpenAPI documentation +- Aggregator struct (`Api`) holds all API handler instances +- Dependency injection via `From<&Settings>` trait +- `.apis()` method returns tuple of all handlers + +#### 3. OpenAPI Handler Definition Pattern + +**Location**: `src/route/health.rs:7-29` + +```rust +#[derive(ApiResponse)] +enum HealthResponse { + #[oai(status = 200)] + Ok, + #[oai(status = 429)] + TooManyRequests, +} + +#[derive(Default, Clone)] +pub struct HealthApi; + +#[OpenApi(tag = "ApiCategory::Health")] +impl HealthApi { + #[oai(path = "/health", method = "get")] + async fn ping(&self) -> HealthResponse { + tracing::event!(target: "backend::health", tracing::Level::DEBUG, + "Accessing health-check endpoint"); + HealthResponse::Ok + } +} +``` + +**Key Insights**: +- Response types are enums with `#[derive(ApiResponse)]` +- Each variant maps to HTTP status code via `#[oai(status = N)]` +- Handlers use `#[OpenApi(tag = "...")]` for categorization +- Type-safe responses at compile time +- Tracing at architectural boundaries + +#### 4. JSON Response Pattern with DTOs + +**Location**: `src/route/meta.rs:9-56` + +```rust +#[derive(Object, Debug, Clone, serde::Serialize, serde::Deserialize)] +struct Meta { + version: String, + name: String, +} + +#[derive(ApiResponse)] +enum MetaResponse { + #[oai(status = 200)] + Meta(Json), + #[oai(status = 429)] + TooManyRequests, +} + +#[OpenApi(tag = "ApiCategory::Meta")] +impl MetaApi { + #[oai(path = "/meta", method = "get")] + async fn meta(&self) -> Result { + Ok(MetaResponse::Meta(Json(self.into()))) + } +} +``` + +**Key Insights**: +- DTOs use `#[derive(Object)]` for OpenAPI schema generation +- Response variants can hold `Json` payloads +- Handler struct holds state/configuration +- Returns `Result` for error handling + +#### 5. Middleware Composition Pattern + +**Location**: `src/startup.rs:59-91` + +```rust +let app = value + .app + .with(RateLimit::new(&rate_limit_config)) + .with(Cors::new()) + .data(value.settings); +``` + +**Key Insights**: +- Middleware applied via `.with()` method chaining +- Order matters: RateLimit → CORS → data injection +- Settings injected as shared data via `.data()` +- Configuration drives middleware behavior + +#### 6. Configuration Management Pattern + +**Location**: `src/settings.rs:40-62` + +```rust +let settings = config::Config::builder() + .add_source(config::File::from(settings_directory.join("base.yaml"))) + .add_source(config::File::from( + settings_directory.join(environment_filename), + )) + .add_source( + config::Environment::with_prefix("APP") + .prefix_separator("__") + .separator("__"), + ) + .build()?; +``` + +**Key Insights**: +- Three-tier configuration: base → environment-specific → env vars +- Environment detected via `APP_ENVIRONMENT` variable +- Environment variables use `APP__` prefix with double underscore separators +- Type-safe deserialization + +#### 7. Testing Pattern + +**Location**: `src/route/health.rs:31-38` + +```rust +#[tokio::test] +async fn health_check_works() { + let app = crate::get_test_app(); + let cli = poem::test::TestClient::new(app); + let resp = cli.get("/api/health").send().await; + resp.assert_status_is_ok(); +} +``` + +**Key Insights**: +- Test helper creates full application with random port +- `TestClient` provides fluent assertion API +- Tests are async with `#[tokio::test]` +- Real application used in tests + +### Type System Best Practices + +Current code demonstrates excellent TyDD: +- `Environment` enum instead of strings +- `RateLimitConfig` newtype instead of raw numbers +- `ApiResponse` enums for type-safe HTTP responses + +### Architecture Compliance + +**Current Layers**: +1. **Presentation Layer**: `src/route/*` - HTTP adapters +2. **Infrastructure Layer**: `src/middleware/*`, `src/startup.rs`, `src/telemetry.rs` + +**Missing Layers** (to be added for Modbus): +3. **Domain Layer**: Pure relay logic, no Modbus knowledge +4. **Application Layer**: Use cases (get status, toggle) + +--- + +## Integration Recommendations + +### Recommended Architecture for Modbus Feature + +Following hexagonal architecture principles from constitution: + +``` +src/ +├── domain/ +│ └── relay/ +│ ├── mod.rs - Domain types (RelayId, RelayState, Relay) +│ ├── relay.rs - Relay entity +│ ├── error.rs - Domain errors +│ └── repository.rs - RelayRepository trait +├── application/ +│ └── relay/ +│ ├── mod.rs - Use case exports +│ ├── get_status.rs - GetRelayStatus use case +│ ├── toggle.rs - ToggleRelay use case +│ └── bulk_control.rs - BulkControl use case +├── infrastructure/ +│ └── modbus/ +│ ├── mod.rs - Modbus exports +│ ├── client.rs - ModbusRelayRepository implementation +│ ├── config.rs - Modbus configuration +│ └── error.rs - Modbus-specific errors +└── route/ + └── relay.rs - HTTP adapter (presentation layer) +``` + +### Integration Points + +| Component | File | Action | +|-----------|------|--------| +| **API Category** | `src/route/mod.rs` | Add `Relay` to `ApiCategory` enum | +| **API Aggregator** | `src/route/mod.rs` | Add `relay: RelayApi` field to `Api` struct | +| **API Tuple** | `src/route/mod.rs` | Add `RelayApi` to `.apis()` return tuple | +| **Settings** | `src/settings.rs` | Add `ModbusSettings` struct and `modbus` field | +| **Config Files** | `settings/base.yaml` | Add `modbus:` section | +| **Shared State** | `src/startup.rs` | Inject `ModbusClient` via `.data()` | +| **Dependencies** | `Cargo.toml` | Add `tokio-modbus`, `async-trait`, `mockall` | + +### Example: New Route Handler + +```rust +// src/route/relay.rs +use poem::Result; +use poem_openapi::{ApiResponse, Object, OpenApi, payload::Json, param::Path}; +use crate::domain::relay::{RelayId, RelayState, Relay}; + +#[derive(Object, Serialize, Deserialize)] +struct RelayDto { + id: u8, + state: String, // "on" or "off" + label: Option, +} + +#[derive(ApiResponse)] +enum RelayResponse { + #[oai(status = 200)] + Status(Json), + #[oai(status = 400)] + BadRequest, + #[oai(status = 503)] + ServiceUnavailable, +} + +#[OpenApi(tag = "ApiCategory::Relay")] +impl RelayApi { + #[oai(path = "/relays/:id", method = "get")] + async fn get_status(&self, id: Path) -> Result { + let relay_id = RelayId::new(id.0) + .map_err(|_| poem::Error::from_status(StatusCode::BAD_REQUEST))?; + + // Use application layer use case + match self.get_status_use_case.execute(relay_id).await { + Ok(relay) => Ok(RelayResponse::Status(Json(relay.into()))), + Err(_) => Ok(RelayResponse::ServiceUnavailable), + } + } +} +``` + +### Example: Settings Extension + +```rust +// src/settings.rs +#[derive(Debug, serde::Deserialize, Clone)] +pub struct ModbusSettings { + pub host: String, + pub port: u16, + pub slave_id: u8, + pub timeout_seconds: u64, +} + +#[derive(Debug, serde::Deserialize, Clone)] +pub struct Settings { + pub application: ApplicationSettings, + pub debug: bool, + pub frontend_url: String, + pub rate_limit: RateLimitSettings, + pub modbus: ModbusSettings, // New field +} +``` + +```yaml +# settings/base.yaml +modbus: + host: "192.168.1.100" + port: 502 + slave_id: 1 + timeout_seconds: 3 +``` + +--- + +## Summary + +### Key Takeaways + +1. **tokio-modbus 0.17.0**: Excellent choice, use trait abstraction for testability +2. **HTTP Polling**: Maintain spec decision, simpler and adequate for scale +3. **Hexagonal Architecture**: Add domain/application layers following existing patterns +4. **Type-Driven Development**: Apply newtype pattern (RelayId, RelayState) +5. **Testing**: Use mockall with async-trait for >90% coverage without hardware + +### Next Steps + +1. **Clarifying Questions**: Resolve ambiguities in requirements +2. **Architecture Design**: Create multiple implementation approaches +3. **Final Plan**: Select approach and create detailed implementation plan +4. **Implementation**: Follow TDD workflow with types-first design + +--- + +**End of Research Document** diff --git a/specs/001-modbus-relay-control/spec-checklist.md b/specs/001-modbus-relay-control/spec-checklist.md new file mode 100644 index 0000000..b66a419 --- /dev/null +++ b/specs/001-modbus-relay-control/spec-checklist.md @@ -0,0 +1,51 @@ +# Specification Quality Checklist: Modbus Relay Control System + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-12-28 +**Feature**: [spec.md](./spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) + - **Note**: Specification intentionally includes some implementation constraints (Rust, Poem, tokio-modbus) per project constitution requirements (NFR-009, NFR-014, NFR-015). These are architectural constraints, not implementation details of business logic. +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain + - **Resolution**: FR-023 clarified by user - backend starts successfully even when device unhealthy, frontend displays error as part of Health story +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) + - **Note**: SC-010 references cargo tarpaulin as measurement tool, which is acceptable for NFR validation +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Quality Assessment + +**Overall Status**: ✅ **READY FOR PLANNING** + +### Strengths +- Comprehensive coverage of 5 prioritized, independently testable user stories +- 37 functional + 21 non-functional requirements provide clear scope +- Edge cases thoroughly documented with specific mitigation strategies +- Success criteria are measurable and aligned with user stories +- Clear boundaries with explicit "Out of Scope" section +- Risk matrix identifies key concerns with mitigation approaches + +### Notes +- Specification includes architectural constraints (hexagonal architecture, TDD, TyDD) per project constitution +- These constraints are non-negotiable project requirements, not arbitrary implementation details +- User clarification resolved FR-023 regarding startup behavior when device is unhealthy +- Specification ready for `/sdd:02-plan` stage diff --git a/specs/001-modbus-relay-control/spec.md b/specs/001-modbus-relay-control/spec.md new file mode 100644 index 0000000..d8e5f7f --- /dev/null +++ b/specs/001-modbus-relay-control/spec.md @@ -0,0 +1,315 @@ +# Feature Specification: Modbus Relay Control System + +**Feature Branch**: `001-modbus-relay-control` +**Created**: 2025-12-28 +**Status**: Draft +**Input**: User description: "Modbus relay control system: backend reads relay and writes states via Modbus, exposes REST API, frontend displays relay states and allows toggling." + +## Executive Summary + +### Problem Statement + +Users currently require specialized Modbus software (Modbus Poll, SSCOM) to interact with an 8-channel relay device, creating barriers to adoption and limiting remote access capabilities. The lack of a web-based interface prevents non-technical users from controlling relays and limits integration possibilities. + +### Proposed Solution + +A web application consisting of: +- **Rust Backend**: Modbus RTU over TCP integration + RESTful HTTP API (deployed on Raspberry Pi) +- **Vue.js Frontend**: Real-time relay status display and control interface (deployed on Cloudflare Pages) +- **Reverse Proxy**: Traefik with Authelia middleware for authentication and HTTPS termination +- **Local Network**: Raspberry Pi on same network as Modbus relay device + +### Value Proposition + +- **Accessibility**: Control relays from any browser without specialized software +- **Usability**: Intuitive UI eliminates need for Modbus protocol knowledge +- **Foundation**: Enables future automation, scheduling, and integration capabilities +- **Deployment**: Self-contained system with no external dependencies + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Monitor Relay Status (Priority: P1) + +As a user, I want to see the current state (on/off) of all 8 relays in real-time so I can verify the physical system state without being physically present. + +**Why this priority**: Foundation capability - all other features depend on accurate state visibility. Delivers immediate value by eliminating need for physical inspection or specialized software. + +**Independent Test**: Can be fully tested by loading the web interface and verifying displayed states match physical relay states (verified with multimeter or visual indicators). Delivers value even without control capabilities. + +**Acceptance Scenarios**: + +1. **Given** all relays are OFF, **When** I load the web interface, **Then** I see 8 relays each displaying "OFF" state +2. **Given** relay #3 is ON and others are OFF, **When** I load the interface, **Then** I see relay #3 showing "ON" and others showing "OFF" +3. **Given** the interface is loaded, **When** relay state changes externally (via Modbus Poll), **Then** the interface updates within 2 seconds to reflect the new state +4. **Given** the Modbus device is unreachable, **When** I load the interface, **Then** I see an error message indicating the device is unavailable + +--- + +### User Story 2 - Toggle Individual Relay (Priority: P1) + +As a user, I want to toggle any relay on or off with a single click so I can control connected devices remotely. + +**Why this priority**: Core use case - enables remote control capability. Combined with Story 1, creates a complete minimal viable product. + +**Independent Test**: Can be tested by clicking any relay toggle button and observing both UI update and physical relay click/LED change. Delivers standalone value for remote control. + +**Acceptance Scenarios**: + +1. **Given** relay #5 is OFF, **When** I click the toggle button for relay #5, **Then** relay #5 turns ON and the UI reflects this within 1 second +2. **Given** relay #2 is ON, **When** I click the toggle button for relay #2, **Then** relay #2 turns OFF and the UI reflects this within 1 second +3. **Given** the Modbus device is unreachable, **When** I attempt to toggle a relay, **Then** I see an error message and the UI does not change +4. **Given** I toggle relay #1, **When** the Modbus command times out, **Then** I see a timeout error and can retry + +--- + +### User Story 3 - Bulk Relay Control (Priority: P2) + +As a user, I want to turn all relays ON or OFF simultaneously so I can quickly reset the entire system or enable/disable all connected devices at once. + +**Why this priority**: Efficiency improvement for common scenarios (system shutdown, initialization). Not critical for MVP but significantly improves user experience. + +**Independent Test**: Can be tested by clicking "All ON" or "All OFF" buttons and verifying all 8 physical relays respond. Delivers value for batch operations without requiring individual story implementations. + +**Acceptance Scenarios**: + +1. **Given** relays have mixed states (some ON, some OFF), **When** I click "All ON", **Then** all 8 relays turn ON within 2 seconds +2. **Given** all relays are ON, **When** I click "All OFF", **Then** all 8 relays turn OFF within 2 seconds +3. **Given** I click "All ON" and relay #4 fails to respond, **Then** I see an error for relay #4 but other relays still turn ON +4. **Given** the Modbus device is unreachable, **When** I click "All ON", **Then** I see an error message and no state changes occur + +--- + +### User Story 4 - System Health Monitoring (Priority: P2) + +As a user, I want to see device connectivity status and firmware version so I can diagnose issues and verify device compatibility. + +**Why this priority**: Operational value for troubleshooting. Not required for basic control but critical for production reliability and maintenance. + +**Independent Test**: Can be tested by viewing the health status section, disconnecting the Modbus device, and observing status change. Delivers standalone diagnostic value. + +**Acceptance Scenarios**: + +1. **Given** the Modbus device is connected and responsive, **When** I view the health status, **Then** I see "Healthy" status with firmware version displayed +2. **Given** the Modbus device is unreachable, **When** the backend starts, **Then** the backend starts successfully and the frontend displays "Unhealthy - Device Unreachable" status +3. **Given** the Modbus device becomes unreachable during operation, **When** I view the health status, **Then** I see "Unhealthy - Connection Lost" with timestamp of last successful communication +4. **Given** the Modbus device responds but with CRC errors, **When** I view health status, **Then** I see "Degraded - Communication Errors" with error count + +--- + +### User Story 5 - Relay Labeling (Priority: P3) + +As a user, I want to assign custom labels to each relay (e.g., "Garage Light", "Water Pump") so I can identify relays by purpose instead of numbers. + +**Why this priority**: Usability enhancement - makes system more intuitive for production use. Not required for MVP but improves long-term user experience. + +**Independent Test**: Can be tested by assigning a label to relay #1, refreshing the page, and verifying the label persists. Delivers value for multi-relay installations without requiring other stories. + +**Acceptance Scenarios**: + +1. **Given** I am viewing relay #3, **When** I click "Edit Label" and enter "Office Fan", **Then** relay #3 displays "Office Fan (Relay 3)" +2. **Given** relay #7 has label "Water Pump", **When** I refresh the page, **Then** relay #7 still shows "Water Pump (Relay 7)" +3. **Given** I have labeled multiple relays, **When** I toggle a relay by label, **Then** the correct physical relay responds +4. **Given** two relays have similar labels, **When** I search for a label, **Then** both matching relays are highlighted + +--- + +### Edge Cases + +- **Network Partition**: What happens when the Raspberry Pi loses connectivity to the Modbus device mid-operation? + - Backend marks device unhealthy, frontend displays error state, pending operations fail gracefully with clear error messages + +- **Concurrent Control**: How does system handle multiple users toggling the same relay simultaneously? + - Last-write-wins semantics, each client receives updated state via polling within 2 seconds + +- **Modbus Timeout**: What happens when a relay command times out? + - Backend retries once automatically, if retry fails, returns error to frontend with clear timeout message + +- **Partial Bulk Failure**: What happens when "All ON" command succeeds for 7 relays but relay #4 fails? + - Frontend displays partial success with list of failed relays, successful relays remain ON, user can retry failed relays individually + +- **Rapid Toggle Requests**: How does system handle user clicking toggle button repeatedly in quick succession? + - Frontend debounces clicks (500ms), backend queues commands serially, prevents command flooding + +- **Device Firmware Mismatch**: What happens if relay device firmware version is incompatible? + - Backend logs firmware version, health check displays warning if version is untested, system attempts normal operation with degraded status + +- **State Inconsistency**: What happens if Modbus read shows relay state different from expected state after write? + - Backend logs inconsistency, frontend displays actual state (read value), user sees visual indication of unexpected state + +- **Browser Compatibility**: How does frontend handle older browsers without modern JavaScript features? + - Vue.js build targets ES2015+, displays graceful error message on IE11 and older, works on all modern browsers (Chrome, Firefox, Safari, Edge) + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Backend - Modbus Integration + +- **FR-001**: System MUST establish Modbus RTU over TCP connection to relay device on configurable IP and port (default: device IP, port 502) +- **FR-002**: System MUST use Modbus function code 0x01 (Read Coils) to read all 8 relay states (addresses 0-7) +- **FR-003**: System MUST use Modbus function code 0x05 (Write Single Coil) to toggle individual relays +- **FR-004**: System MUST use Modbus function code 0x0F (Write Multiple Coils) for bulk operations (All ON/All OFF) +- **FR-005**: System MUST validate Modbus CRC16 checksums on all received messages +- **FR-006**: System MUST timeout Modbus operations after 3 seconds +- **FR-007**: System MUST retry failed Modbus commands exactly once before returning error +- **FR-008**: System MUST handle Modbus exception codes (0x01-0x04) and map to user-friendly error messages +- **FR-009**: System MUST use tokio-modbus library version 0.17.0 for Modbus protocol implementation +- **FR-010**: System MUST support configurable Modbus device address (default: 0x01) + +#### Backend - REST API + +- **FR-011**: System MUST expose `GET /api/relays` endpoint returning array of all relay states (id, state, label) +- **FR-012**: System MUST expose `POST /api/relays/{id}/toggle` endpoint to toggle relay {id} (id: 1-8) +- **FR-013**: System MUST expose `POST /api/relays/bulk` endpoint accepting `{"operation": "all_on" | "all_off"}` +- **FR-014**: System MUST expose `GET /api/health` endpoint returning device status (healthy/unhealthy, firmware version, last_contact timestamp) +- **FR-015**: System MUST expose `PUT /api/relays/{id}/label` endpoint to update relay label (max 50 characters) +- **FR-016**: System MUST return HTTP 200 for successful operations with JSON response body +- **FR-017**: System MUST return HTTP 500 for Modbus communication failures with error details +- **FR-018**: System MUST return HTTP 400 for invalid request parameters (e.g., relay id out of range) +- **FR-019**: System MUST return HTTP 504 for Modbus timeout errors +- **FR-020**: System MUST include OpenAPI 3.0 specification accessible at `/api/specs` +- **FR-021**: System MUST apply rate limiting middleware (100 requests/minute per IP) +- **FR-022**: System MUST apply CORS middleware allowing all origins (local network deployment) +- **FR-023**: System MUST start successfully even if Modbus device is unreachable at startup, marking device as unhealthy +- **FR-024**: System MUST persist relay labels to configuration file (YAML) for persistence across restarts + +#### Frontend - User Interface + +- **FR-025**: UI MUST display all 8 relays in a grid layout with clear ON/OFF state indication (color-coded) +- **FR-026**: UI MUST provide toggle button for each relay that triggers `POST /api/relays/{id}/toggle` +- **FR-027**: UI MUST provide "All ON" and "All OFF" buttons that trigger `POST /api/relays/bulk` +- **FR-028**: UI MUST poll `GET /api/relays` every 2 seconds to refresh relay states +- **FR-029**: UI MUST display loading indicator while relay operations are in progress +- **FR-030**: UI MUST display error messages when API calls fail, with specific error text from backend +- **FR-031**: UI MUST display health status section showing device connectivity and firmware version +- **FR-032**: UI MUST display "Unhealthy - Device Unreachable" message when backend reports device unreachable +- **FR-033**: UI MUST provide inline label editing for each relay (click to edit, save on blur/enter) +- **FR-034**: UI MUST be responsive and functional on desktop (>1024px), tablet (768-1024px), and mobile (320-767px) +- **FR-035**: UI MUST disable toggle buttons and show error when device is unhealthy +- **FR-036**: UI MUST show timestamp of last successful state update +- **FR-037**: UI MUST debounce toggle button clicks to 500ms to prevent rapid repeated requests + +### Non-Functional Requirements + +#### Performance + +- **NFR-001**: System MUST respond to `GET /api/relays` within 100ms (excluding Modbus communication time) +- **NFR-002**: System MUST complete relay toggle operations within 1 second (including Modbus communication) +- **NFR-003**: System MUST handle 10 concurrent users without performance degradation +- **NFR-004**: Frontend MUST render initial page load within 2 seconds on 10 Mbps connection + +#### Reliability + +- **NFR-005**: System MUST maintain 95% successful operation rate for Modbus commands +- **NFR-006**: System MUST recover automatically from temporary Modbus connection loss within 5 seconds +- **NFR-007**: System MUST log all Modbus errors with structured logging (timestamp, error code, relay id) +- **NFR-008**: Backend MUST continue serving health and API endpoints even when Modbus device is unreachable + +#### Security + +- **NFR-009**: Backend MUST run on local network with Modbus device (no direct public internet exposure) +- **NFR-010**: System MUST NOT implement application-level authentication (handled by Traefik middleware with Authelia) +- **NFR-011**: Frontend-to-backend communication MUST use HTTPS via Traefik reverse proxy (backend itself runs HTTP, Traefik handles TLS termination) +- **NFR-012**: System MUST validate all API inputs to prevent injection attacks +- **NFR-013-SEC**: Backend-to-Modbus communication uses unencrypted Modbus TCP (local network only) + +#### Maintainability + +- **NFR-014**: Code MUST achieve >90% test coverage for domain logic (relay control, Modbus abstraction) +- **NFR-015**: System MUST follow hexagonal architecture with trait-based Modbus abstraction for testability +- **NFR-016**: System MUST use Type-Driven Development (TyDD) with newtype pattern for RelayId, RelayState, ModbusCommand +- **NFR-017**: All public APIs MUST have OpenAPI documentation +- **NFR-018-MAINT**: Code MUST pass `cargo clippy` with zero warnings on all, pedantic, and nursery lints + +#### Observability + +- **NFR-019**: System MUST emit structured logs at all architectural boundaries (API, Modbus) +- **NFR-020**: System MUST log relay state changes with timestamp, relay id, old state, new state +- **NFR-021**: System MUST expose Prometheus metrics endpoint at `/metrics` (request count, error rate, Modbus latency) +- **NFR-022**: System MUST log startup configuration (Modbus host/port, relay count) at INFO level + +### Key Entities + +- **Relay**: Represents a single relay channel (1-8) with properties: id (1-8), state (ON/OFF), label (optional, max 50 chars) +- **RelayState**: Enum representing ON or OFF state +- **RelayId**: Newtype wrapping u8 with validation (1-8 range), implements TyDD pattern +- **ModbusCommand**: Enum representing Modbus operations (ReadCoils, WriteSingleCoil, WriteMultipleCoils) +- **DeviceHealth**: Struct representing Modbus device status (`healthy: bool`, `firmware_version: Option`, `last_contact: Option`) +- **RelayLabel**: Newtype wrapping String with validation (max 50 chars, alphanumeric + spaces) + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can view all 8 relay states within 2 seconds of loading the web interface +- **SC-002**: Users can toggle any relay with physical relay response within 1 second of button click +- **SC-003**: System achieves 95% successful operation rate for relay toggle commands over 24-hour period +- **SC-004**: Web interface is accessible and functional on Chrome, Firefox, Safari, and Edge browsers +- **SC-005**: Users can successfully use the interface on mobile devices (portrait and landscape) +- **SC-006**: Backend starts successfully and serves health endpoint even when Modbus device is disconnected +- **SC-007**: Frontend displays clear error message within 2 seconds when Modbus device is unhealthy +- **SC-008**: System supports 10 concurrent users performing toggle operations without performance degradation +- **SC-009**: All 8 relays turn ON within 2 seconds when "All ON" button is clicked +- **SC-010**: Domain logic achieves >90% test coverage as measured by `cargo tarpaulin` + +### User Experience Goals + +- **UX-001**: Non-technical users can control relays without referring to documentation +- **UX-002**: Error messages clearly explain problem and suggest remediation (e.g., "Device unreachable - check network connection") +- **UX-003**: Relay labels make it intuitive to identify relay purpose without memorizing numbers + +## Dependencies & Assumptions + +### Dependencies + +- **Hardware**: 8-channel Modbus POE ETH Relay device (documented in `docs/Modbus_POE_ETH_Relay.md`) +- **Network**: Local network connectivity between Raspberry Pi and relay device +- **Libraries**: tokio-modbus 0.17.0, Poem 3.1, poem-openapi 5.1, Tokio 1.48 +- **Frontend**: Vue.js 3.x, TypeScript, Vite build tool +- **Backend Deployment**: Raspberry Pi (or equivalent) running Linux with Docker +- **Frontend Deployment**: Cloudflare Pages (or equivalent static hosting) +- **Reverse Proxy**: Traefik with Authelia middleware for authentication + +### Assumptions + +- **ASM-001**: Relay device uses Modbus RTU over TCP protocol (per hardware documentation) +- **ASM-002**: Relay device supports standard Modbus function codes 0x01, 0x05, 0x0F +- **ASM-003**: Local network provides reliable connectivity (>95% uptime) +- **ASM-004**: Traefik reverse proxy with Authelia middleware provides adequate authentication +- **ASM-005**: Single user will control relays at a time in most scenarios (concurrent control is edge case) +- **ASM-006**: Relay device exposes 8 coils at Modbus addresses 0-7 +- **ASM-007**: Device firmware is compatible with tokio-modbus library +- **ASM-008**: Raspberry Pi has sufficient resources (CPU, memory) to run Rust backend +- **ASM-009**: Cloudflare Pages or equivalent CDN provides fast frontend delivery +- **ASM-010**: Frontend can reach backend via HTTPS through Traefik reverse proxy + +## Out of Scope + +The following capabilities are explicitly excluded from this specification: + +- **Application-Level Authentication**: No user login, role-based access control, or API keys (handled by Traefik/Authelia) +- **Historical Data**: No database, state logging, or historical relay state tracking +- **Scheduling**: No timer-based relay control or automation rules +- **Multiple Devices**: No support for controlling multiple relay devices simultaneously +- **Advanced Modbus Features**: No support for flash modes, timing operations, or device reconfiguration +- **Mobile Native Apps**: Web interface only, no iOS/Android native applications +- **Cloud Backend**: Backend runs on local network (Raspberry Pi), frontend served from Cloudflare Pages +- **Real-time Updates**: HTTP polling only (no WebSocket, Server-Sent Events) + +## Risks & Mitigations + +| Risk | Impact | Probability | Mitigation | +|--------------------------------------------|--------|-------------|------------------------------------------------------------------------| +| Modbus device firmware incompatibility | High | Low | Test with actual hardware early, document compatible firmware versions | +| Network latency exceeds timeout thresholds | Medium | Medium | Make timeouts configurable, implement adaptive retry logic | +| Concurrent control causes state conflicts | Low | Medium | Implement last-write-wins with clear state refresh in UI | +| Frontend polling overwhelms backend | Low | Low | Rate limit API endpoints, make poll interval configurable | +| Raspberry Pi resource exhaustion | Medium | Low | Benchmark with 10 concurrent users, optimize Modbus connection pooling | + +## Revision History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-12-28 | Business Analyst Agent | Initial specification based on user input | +| 1.1 | 2025-12-28 | User Clarification | FR-023 clarified: Backend starts successfully even when device unhealthy, frontend displays error (part of Health story) | +| 1.2 | 2025-12-29 | User Clarification | Architecture updated: Frontend on Cloudflare Pages, backend on RPi behind Traefik with Authelia. Updated NFR-009 to NFR-013-SEC to reflect HTTPS via reverse proxy, authentication via Traefik middleware | diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md new file mode 100644 index 0000000..2509b5d --- /dev/null +++ b/specs/001-modbus-relay-control/tasks.md @@ -0,0 +1,1153 @@ +# Implementation Tasks: Modbus Relay Control System + +**Feature**: 001-modbus-relay-control +**Total Tasks**: 94 tasks across 8 phases +**MVP Delivery**: Phase 4 complete (Task 49) +**Parallelizable Tasks**: 35 tasks marked with `[P]` +**Approach**: Type-Driven Development (TyDD) + Test-Driven Development (TDD), Backend API first + +--- + +## Phase 1: Setup & Foundation (0.5 days) + +**Purpose**: Initialize project dependencies and directory structure + +- [ ] **T001** [Setup] [TDD] Add Rust dependencies to Cargo.toml + - Add: tokio-modbus = "0.17.0", sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }, mockall = "0.13", async-trait = "0.1" + - **Test**: cargo check passes + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T002** [P] [Setup] [TDD] Create module structure in src/ + - Create: src/domain/, src/application/, src/infrastructure/, src/presentation/ + - **Test**: Module declarations compile without errors + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T003** [P] [Setup] [TDD] Update settings.rs with Modbus configuration + - Add ModbusSettings struct with host, port, slave_id, timeout_secs fields + - Add RelaySettings struct with label_max_length field + - Update Settings struct to include modbus and relay fields + - **Test**: Settings loads from settings/base.yaml with test Modbus config + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T004** [P] [Setup] [TDD] Create settings/base.yaml with Modbus defaults + - Add modbus section: host: "192.168.1.100", port: 502, slave_id: 1, timeout_secs: 3 + - Add relay section: label_max_length: 50 + - **Test**: Settings::new() loads config without errors + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T005** [P] [Setup] [TDD] Add SQLite schema file + - Create infrastructure/persistence/schema.sql with relay_labels table + - Table: relay_labels (relay_id INTEGER PRIMARY KEY CHECK(relay_id BETWEEN 1 AND 8), label TEXT NOT NULL CHECK(length(label) <= 50)) + - **Test**: Schema file syntax is valid SQL + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T006** [P] [Setup] [TDD] Initialize SQLite database module + - Create infrastructure/persistence/mod.rs + - Create infrastructure/persistence/sqlite_repository.rs with SqliteRelayLabelRepository struct + - Implement SqliteRelayLabelRepository::new(path) using SqlitePool + - **Test**: SqliteRelayLabelRepository::in_memory() creates in-memory DB with schema + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T007** [P] [Setup] [TDD] Add frontend project scaffolding + - Create frontend/ directory with Vite + Vue 3 + TypeScript + - Run: npm create vite@latest frontend -- --template vue-ts + - Install: axios, @types/node + - **Test**: npm run dev starts frontend dev server + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T008** [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI + - Add poem-openapi spec generation in startup.rs + - Generate frontend/src/api/client.ts from OpenAPI spec + - **Test**: TypeScript client compiles without errors + - **Complexity**: Medium | **Uncertainty**: Medium + - **Note**: May need manual adjustments to generated code + +--- + +## Phase 2: Domain Layer - Type-Driven Development (1 day) + +**Purpose**: Build domain types with 100% test coverage, bottom-to-top + +**⚠️ TDD CRITICAL**: Write failing tests FIRST for every type, then implement + +- [ ] **T009** [US1] [TDD] Write tests for RelayId newtype + - Test: RelayId::new(1) → Ok(RelayId(1)) + - Test: RelayId::new(8) → Ok(RelayId(8)) + - Test: RelayId::new(0) → Err(InvalidRelayId) + - Test: RelayId::new(9) → Err(InvalidRelayId) + - Test: RelayId::as_u8() returns inner value + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T010** [US1] [TDD] Implement RelayId newtype with validation + - #[repr(transparent)] newtype wrapping u8 + - Constructor validates 1..=8 range + - Implement Display, Debug, Clone, Copy, PartialEq, Eq + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T011** [P] [US1] [TDD] Write tests for RelayState enum + - Test: RelayState::On → serializes to "on" + - Test: RelayState::Off → serializes to "off" + - Test: Parse "on"/"off" from strings + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T012** [P] [US1] [TDD] Implement RelayState enum + - Enum: On, Off + - Implement Display, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize/Deserialize + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T013** [US1] [TDD] Write tests for Relay aggregate + - Test: Relay::new(RelayId(1), RelayState::Off, None) creates relay + - Test: relay.toggle() flips state + - Test: relay.turn_on() sets state to On + - Test: relay.turn_off() sets state to Off + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T014** [US1] [TDD] Implement Relay aggregate + - Struct: Relay { id: RelayId, state: RelayState, label: Option } + - Methods: new(), toggle(), turn_on(), turn_off(), state(), label() + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T015** [P] [US4] [TDD] Write tests for RelayLabel newtype + - Test: RelayLabel::new("Pump") → Ok + - Test: RelayLabel::new("A".repeat(50)) → Ok + - Test: RelayLabel::new("") → Err(EmptyLabel) + - Test: RelayLabel::new("A".repeat(51)) → Err(LabelTooLong) + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T016** [P] [US4] [TDD] Implement RelayLabel newtype + - #[repr(transparent)] newtype wrapping String + - Constructor validates 1..=50 length + - Implement Display, Debug, Clone, PartialEq, Eq + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T017** [US1] [TDD] Write tests for ModbusAddress type + - Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0) + - Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7) + - **File**: src/domain/modbus.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T018** [US1] [TDD] Implement ModbusAddress type with From + - #[repr(transparent)] newtype wrapping u16 + - Implement From with offset: user 1-8 → Modbus 0-7 + - **File**: src/domain/modbus.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T019** [US3] [TDD] Write tests and implement HealthStatus enum + - Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String } + - Test transitions between states + - **File**: src/domain/health.rs + - **Complexity**: Medium | **Uncertainty**: Low + +**Checkpoint**: Domain types complete with 100% test coverage + +--- + +## Phase 3: Infrastructure Layer (2 days) + +**Purpose**: Implement Modbus client, mocks, and persistence + +- [ ] **T020** [P] [US1] [TDD] Write tests for MockRelayController + - Test: read_state() returns mocked state + - Test: write_state() updates mocked state + - Test: read_all() returns 8 relays in known state + - **File**: src/infrastructure/modbus/mock_controller.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T021** [P] [US1] [TDD] Implement MockRelayController + - Struct with Arc>> + - Implement RelayController trait with in-memory state + - **File**: src/infrastructure/modbus/mock_controller.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T022** [US1] [TDD] Define RelayController trait + - async fn read_state(&self, id: RelayId) → Result + - async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError> + - async fn read_all(&self) → Result, ControllerError> + - async fn write_all(&self, state: RelayState) → Result<(), ControllerError> + - **File**: src/infrastructure/modbus/controller.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T023** [P] [US1] [TDD] Define ControllerError enum + - Variants: ConnectionError(String), Timeout(u64), ModbusException(String), InvalidRelayId(u8) + - Implement std::error::Error, Display, Debug + - Use thiserror derive macros + - **File**: src/infrastructure/modbus/error.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T024** [US1] [TDD] Write tests for ModbusRelayController + - **REQUIRES HARDWARE/MOCK**: Integration test with tokio_modbus::test utilities + - Test: Connection succeeds with valid config + - Test: read_state() returns correct coil value + - Test: write_state() sends correct Modbus command + - **File**: src/infrastructure/modbus/modbus_controller.rs + - **Complexity**: High → DECOMPOSED below + - **Uncertainty**: High + +--- + +### T025: ModbusRelayController Implementation (DECOMPOSED) + +**Complexity**: High → Broken into 6 sub-tasks +**Uncertainty**: High +**Rationale**: Nested Result handling, Arc synchronization, timeout wrapping + +- [ ] **T025a** [US1] [TDD] Implement ModbusRelayController connection setup + - Struct: ModbusRelayController { ctx: Arc>, timeout_duration: Duration } + - Constructor: new(host, port, slave_id, timeout_secs) → Result + - Use tokio_modbus::client::tcp::connect_slave() + - **File**: src/infrastructure/modbus/modbus_controller.rs + - **Complexity**: Medium | **Uncertainty**: Medium + + **Pseudocode**: + ```rust + pub struct ModbusRelayController { + ctx: Arc>, + timeout_duration: Duration, + } + + impl ModbusRelayController { + pub async fn new(host: &str, port: u16, slave_id: u8, timeout_secs: u64) + -> Result + { + use tokio_modbus::prelude::*; + + let socket_addr = format!("{}:{}", host, port) + .parse() + .map_err(|e| ControllerError::ConnectionError(format!("Invalid address: {}", e)))?; + + let ctx = tcp::connect_slave(socket_addr, Slave(slave_id)) + .await + .map_err(|e| ControllerError::ConnectionError(e.to_string()))?; + + Ok(Self { + ctx: Arc::new(Mutex::new(ctx)), + timeout_duration: Duration::from_secs(timeout_secs), + }) + } + } + ``` + + **TDD Checklist** (write these tests FIRST): + - [ ] Test: new() with valid config connects successfully + - [ ] Test: new() with invalid host returns ConnectionError + - [ ] Test: new() stores correct timeout_duration + +- [ ] **T025b** [US1] [TDD] Implement timeout-wrapped read_coils helper + - Private method: read_coils_with_timeout(addr: u16, count: u16) → Result, ControllerError> + - Wrap ctx.read_coils() with tokio::time::timeout() + - Handle nested Result: timeout → io::Error → Modbus Exception + - **File**: src/infrastructure/modbus/modbus_controller.rs + - **Complexity**: Medium | **Uncertainty**: Medium + + **Pseudocode** (CRITICAL PATTERN): + ```rust + async fn read_coils_with_timeout(&self, addr: u16, count: u16) + -> Result, ControllerError> + { + use tokio::time::timeout; + + let ctx = self.ctx.lock().await; + + // tokio-modbus returns nested Results: Result, io::Error> + // We must unwrap 3 layers: timeout → io::Error → Modbus Exception + + let result = timeout(self.timeout_duration, ctx.read_coils(addr, count)) + .await // Result, Exception>, io::Error>, Elapsed> + .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? // Handle timeout + .map_err(|e| ControllerError::ConnectionError(e.to_string()))? // Handle io::Error + .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; // Handle Exception + + Ok(result) + } + ``` + + **TDD Checklist**: + - [ ] Test: read_coils_with_timeout() returns coil values on success + - [ ] Test: read_coils_with_timeout() returns Timeout error when operation exceeds timeout + - [ ] Test: read_coils_with_timeout() returns ConnectionError on io::Error + - [ ] Test: read_coils_with_timeout() returns ModbusException on protocol error + +- [ ] **T025c** [US1] [TDD] Implement timeout-wrapped write_single_coil helper + - Private method: write_single_coil_with_timeout(addr: u16, value: bool) → Result<(), ControllerError> + - Similar nested Result handling as T025b + - **File**: src/infrastructure/modbus/modbus_controller.rs + - **Complexity**: Low | **Uncertainty**: Low + + **Pseudocode**: + ```rust + async fn write_single_coil_with_timeout(&self, addr: u16, value: bool) + -> Result<(), ControllerError> + { + use tokio::time::timeout; + + let ctx = self.ctx.lock().await; + + timeout(self.timeout_duration, ctx.write_single_coil(addr, value)) + .await + .map_err(|_| ControllerError::Timeout(self.timeout_duration.as_secs()))? + .map_err(|e| ControllerError::ConnectionError(e.to_string()))? + .map_err(|e| ControllerError::ModbusException(format!("{:?}", e)))?; + + Ok(()) + } + ``` + + **TDD Checklist**: + - [ ] Test: write_single_coil_with_timeout() succeeds for valid write + - [ ] Test: write_single_coil_with_timeout() returns Timeout on slow device + - [ ] Test: write_single_coil_with_timeout() returns appropriate error on failure + +- [ ] **T025d** [US1] [TDD] Implement RelayController::read_state() using helpers + - Convert RelayId → ModbusAddress (0-based) + - Call read_coils_with_timeout(addr, 1) + - Convert bool → RelayState + - **File**: src/infrastructure/modbus/modbus_controller.rs + - **Complexity**: Low | **Uncertainty**: Low + + **Pseudocode**: + ```rust + #[async_trait] + impl RelayController for ModbusRelayController { + async fn read_state(&self, id: RelayId) -> Result { + let addr = ModbusAddress::from(id).as_u16(); + let coils = self.read_coils_with_timeout(addr, 1).await?; + + Ok(if coils[0] { RelayState::On } else { RelayState::Off }) + } + } + ``` + + **TDD Checklist**: + - [ ] Test: read_state(RelayId(1)) returns On when coil is true + - [ ] Test: read_state(RelayId(1)) returns Off when coil is false + - [ ] Test: read_state() propagates ControllerError from helper + +- [ ] **T025e** [US1] [TDD] Implement RelayController::write_state() using helpers + - Convert RelayId → ModbusAddress + - Convert RelayState → bool (On=true, Off=false) + - Call write_single_coil_with_timeout() + - **File**: src/infrastructure/modbus/modbus_controller.rs + - **Complexity**: Low | **Uncertainty**: Low + + **Pseudocode**: + ```rust + async fn write_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError> { + let addr = ModbusAddress::from(id).as_u16(); + let value = matches!(state, RelayState::On); + self.write_single_coil_with_timeout(addr, value).await + } + ``` + + **TDD Checklist**: + - [ ] Test: write_state(RelayId(1), RelayState::On) writes true to coil + - [ ] Test: write_state(RelayId(1), RelayState::Off) writes false to coil + +- [ ] **T025f** [US1] [TDD] Implement RelayController::read_all() and write_all() + - read_all(): Call read_coils_with_timeout(0, 8), map to Vec<(RelayId, RelayState)> + - write_all(): Loop over RelayId 1-8, call write_state() for each + - Add firmware_version() method (read holding register 0x9999, optional) + - **File**: src/infrastructure/modbus/modbus_controller.rs + - **Complexity**: Medium | **Uncertainty**: Low + + **Pseudocode**: + ```rust + async fn read_all(&self) -> Result, ControllerError> { + let coils = self.read_coils_with_timeout(0, 8).await?; + + let mut relays = Vec::new(); + for (idx, &coil_value) in coils.iter().enumerate() { + let relay_id = RelayId::new((idx + 1) as u8)?; + let state = if coil_value { RelayState::On } else { RelayState::Off }; + relays.push((relay_id, state)); + } + Ok(relays) + } + + async fn write_all(&self, state: RelayState) -> Result<(), ControllerError> { + for i in 1..=8 { + let relay_id = RelayId::new(i)?; + self.write_state(relay_id, state).await?; + } + Ok(()) + } + ``` + + **TDD Checklist**: + - [ ] Test: read_all() returns 8 relay states + - [ ] Test: write_all(RelayState::On) turns all relays on + - [ ] Test: write_all(RelayState::Off) turns all relays off + +--- + +- [ ] **T026** [US1] [TDD] Integration test with real hardware (optional) + - **REQUIRES PHYSICAL DEVICE**: Test against actual Modbus relay at configured IP + - Skip if device unavailable, rely on MockRelayController for CI + - **File**: tests/integration/modbus_hardware_test.rs + - **Complexity**: Medium | **Uncertainty**: High + - **Note**: Use #[ignore] attribute, run with cargo test -- --ignored + +- [ ] **T027** [P] [US4] [TDD] Write tests for RelayLabelRepository trait + - Test: get_label(RelayId(1)) → Option + - Test: set_label(RelayId(1), label) → Result<(), RepositoryError> + - Test: delete_label(RelayId(1)) → Result<(), RepositoryError> + - **File**: src/infrastructure/persistence/label_repository.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T028** [P] [US4] [TDD] Implement SQLite RelayLabelRepository + - Implement get_label(), set_label(), delete_label() using SQLx + - Use sqlx::query! macros for compile-time SQL verification + - **File**: src/infrastructure/persistence/sqlite_label_repository.rs + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T029** [US4] [TDD] Write tests for in-memory mock LabelRepository + - For testing without SQLite dependency + - **File**: src/infrastructure/persistence/mock_label_repository.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T030** [US4] [TDD] Implement in-memory mock LabelRepository + - HashMap-based implementation + - **File**: src/infrastructure/persistence/mock_label_repository.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T031** [US3] [TDD] Write tests for HealthMonitor service + - Test: track_success() transitions Degraded → Healthy + - Test: track_failure() transitions Healthy → Degraded → Unhealthy + - **File**: src/application/health_monitor.rs + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T032** [US3] [TDD] Implement HealthMonitor service + - Track consecutive errors, transition states per FR-020, FR-021 + - **File**: src/application/health_monitor.rs + - **Complexity**: Medium | **Uncertainty**: Low + +**Checkpoint**: Infrastructure layer complete with trait abstractions + +--- + +## Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) + +**Goal**: View current state of all 8 relays + toggle individual relay on/off + +**Independent Test**: GET /api/relays returns 8 relays, POST /api/relays/{id}/toggle changes state + +### Application Layer + +- [ ] **T033** [US1] [TDD] Write tests for ToggleRelayUseCase + - Test: execute(RelayId(1)) toggles relay state via controller + - Test: execute() returns error if controller fails + - **File**: src/application/use_cases/toggle_relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T034** [US1] [TDD] Implement ToggleRelayUseCase + - Orchestrate: read current state → toggle → write new state + - **File**: src/application/use_cases/toggle_relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T035** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase + - Test: execute() returns all 8 relays with states + - **File**: src/application/use_cases/get_all_relays.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T036** [P] [US1] [TDD] Implement GetAllRelaysUseCase + - Call controller.read_all(), map to domain Relay objects + - **File**: src/application/use_cases/get_all_relays.rs + - **Complexity**: Low | **Uncertainty**: Low + +### Presentation Layer (Backend API) + +- [ ] **T037** [US1] [TDD] Define RelayDto in presentation layer + - Fields: id (u8), state ("on"/"off"), label (Option) + - Implement From for RelayDto + - **File**: src/presentation/dto/relay_dto.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T038** [US1] [TDD] Define API error responses + - ApiError enum with status codes and messages + - Implement poem::error::ResponseError + - **File**: src/presentation/error.rs + - **Complexity**: Low | **Uncertainty**: Low + +--- + +### T039: Dependency Injection Setup (DECOMPOSED) + +**Complexity**: High → Broken into 4 sub-tasks +**Uncertainty**: Medium +**Rationale**: Graceful degradation (FR-023), conditional mock/real controller + +- [ ] **T039a** [US1] [TDD] Create ModbusRelayController factory with retry and fallback + - Factory function: create_relay_controller(settings, use_mock) → Arc + - Retry 3 times with 2s backoff on connection failure + - Graceful degradation: fallback to MockRelayController if all retries fail (FR-023) + - **File**: src/infrastructure/modbus/factory.rs + - **Complexity**: Medium | **Uncertainty**: Medium + + **Pseudocode**: + ```rust + pub async fn create_relay_controller( + settings: &ModbusSettings, + use_mock: bool, + ) -> Arc { + if use_mock { + tracing::info!("Using MockRelayController (test mode)"); + return Arc::new(MockRelayController::new()); + } + + // Retry 3 times with 2s backoff + for attempt in 1..=3 { + match ModbusRelayController::new( + &settings.host, + settings.port, + settings.slave_id, + settings.timeout_secs, + ).await { + Ok(controller) => { + tracing::info!("Connected to Modbus device on attempt {}", attempt); + return Arc::new(controller); + } + Err(e) => { + tracing::warn!( + attempt, + error = %e, + "Failed to connect to Modbus device, retrying..." + ); + if attempt < 3 { + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } + } + + // Graceful degradation: fallback to MockRelayController + tracing::error!( + "Could not connect to Modbus device after 3 attempts, \ + using MockRelayController as fallback" + ); + Arc::new(MockRelayController::new()) + } + ``` + + **TDD Checklist**: + - [ ] Test: use_mock=true returns MockRelayController immediately + - [ ] Test: Successful connection returns ModbusRelayController + - [ ] Test: Connection failure after 3 retries returns MockRelayController + - [ ] Test: Retry delays are 2 seconds between attempts + - [ ] Test: Logs appropriate messages for each connection attempt + +- [ ] **T039b** [US4] [TDD] Create RelayLabelRepository factory + - Factory function: create_label_repository(db_path, use_mock) → Arc + - If use_mock: return MockLabelRepository + - Else: return SQLiteLabelRepository connected to db_path + - **File**: src/infrastructure/persistence/factory.rs + - **Complexity**: Low | **Uncertainty**: Low + + **Pseudocode**: + ```rust + pub fn create_label_repository( + db_path: &str, + use_mock: bool, + ) -> Result, RepositoryError> { + if use_mock { + tracing::info!("Using MockLabelRepository (test mode)"); + return Ok(Arc::new(MockLabelRepository::new())); + } + + let db = Database::new(db_path)?; + Ok(Arc::new(SQLiteLabelRepository::new(db))) + } + ``` + + **TDD Checklist**: + - [ ] Test: use_mock=true returns MockLabelRepository + - [ ] Test: use_mock=false returns SQLiteLabelRepository + - [ ] Test: Invalid db_path returns RepositoryError + +- [ ] **T039c** [US1] [TDD] Wire dependencies in Application::build() + - Determine test mode: cfg!(test) || env::var("CI").is_ok() + - Call create_relay_controller() and create_label_repository() + - Pass dependencies to RelayApi::new() + - **File**: src/startup.rs + - **Complexity**: Medium | **Uncertainty**: Low + + **Pseudocode**: + ```rust + impl Application { + pub async fn build(settings: Settings) -> Result { + let use_mock = cfg!(test) || std::env::var("CI").is_ok(); + + // Create dependencies + let relay_controller = create_relay_controller(&settings.modbus, use_mock).await; + let label_repository = create_label_repository(&settings.database.path, use_mock)?; + + // Create API with dependencies + let relay_api = RelayApi::new(relay_controller, label_repository); + + // Build OpenAPI service + let api_service = OpenApiService::new(relay_api, "STA API", "1.0.0") + .server("http://localhost:8080"); + + let ui = api_service.swagger_ui(); + let spec = api_service.spec(); + + let app = Route::new() + .nest("/api", api_service) + .nest("/", ui) + .at("/openapi.json", poem::endpoint::make_sync(move |_| spec.clone())); + + Ok(Self { app, settings }) + } + } + ``` + + **TDD Checklist**: + - [ ] Test: Application::build() succeeds in test mode + - [ ] Test: Application::build() creates correct mock dependencies when CI=true + - [ ] Test: Application::build() creates real dependencies when not in test mode + +- [ ] **T039d** [US1] [TDD] Register RelayApi in route aggregator + - Add RelayApi to OpenAPI service + - Tag: "Relays" + - **File**: src/startup.rs + - **Complexity**: Low | **Uncertainty**: Low + + **TDD Checklist**: + - [ ] Test: OpenAPI spec includes /api/relays endpoints + - [ ] Test: Swagger UI renders Relays tag + +--- + +- [ ] **T040** [US1] [TDD] Write contract tests for GET /api/relays + - Test: Returns 200 with array of 8 RelayDto + - Test: Each relay has id 1-8, state, and optional label + - **File**: tests/contract/test_relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T041** [US1] [TDD] Implement GET /api/relays endpoint + - #[oai(path = "/relays", method = "get")] + - Call GetAllRelaysUseCase, map to RelayDto + - **File**: src/presentation/api/relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T042** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle + - Test: Returns 200 with updated RelayDto + - Test: Returns 404 for id < 1 or id > 8 + - Test: State actually changes in controller + - **File**: tests/contract/test_relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T043** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint + - #[oai(path = "/relays/:id/toggle", method = "post")] + - Parse id, call ToggleRelayUseCase, return updated state + - **File**: src/presentation/api/relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +### Frontend Implementation + +- [ ] **T044** [P] [US1] [TDD] Create RelayDto TypeScript interface + - Generate from OpenAPI spec or manually define + - **File**: frontend/src/types/relay.ts + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T045** [P] [US1] [TDD] Create API client service + - getAllRelays(): Promise + - toggleRelay(id: number): Promise + - **File**: frontend/src/api/relayApi.ts + - **Complexity**: Low | **Uncertainty**: Low + +--- + +### T046: HTTP Polling Composable (DECOMPOSED) + +**Complexity**: High → Broken into 4 sub-tasks +**Uncertainty**: Medium +**Rationale**: Vue 3 lifecycle hooks, polling management, memory leak prevention + +- [ ] **T046a** [US1] [TDD] Create useRelayPolling composable structure + - Setup reactive refs: relays, isLoading, error, lastFetchTime + - Define interval variable and fetch function signature + - **File**: frontend/src/composables/useRelayPolling.ts + - **Complexity**: Low | **Uncertainty**: Low + + **Pseudocode**: + ```typescript + import { ref, Ref } from 'vue'; + import type { RelayDto } from '@/types/relay'; + + export function useRelayPolling(intervalMs: number = 2000) { + const relays: Ref = ref([]); + const isLoading = ref(true); + const error: Ref = ref(null); + const lastFetchTime: Ref = ref(null); + const isConnected = ref(false); + + let pollingInterval: number | null = null; + + // TODO: Implement fetchData, startPolling, stopPolling + + return { + relays, + isLoading, + error, + isConnected, + lastFetchTime, + refresh: fetchData, + startPolling, + stopPolling, + }; + } + ``` + + **TDD Checklist**: + - [ ] Test: Composable returns correct reactive refs + - [ ] Test: Initial state is loading=true, relays=[], error=null + +- [ ] **T046b** [US1] [TDD] Implement fetchData with parallel requests + - Fetch relays and health status in parallel using Promise.all + - Update reactive state on success + - Handle errors gracefully, set isConnected based on success + - **File**: frontend/src/composables/useRelayPolling.ts + - **Complexity**: Medium | **Uncertainty**: Low + + **Pseudocode**: + ```typescript + const fetchData = async () => { + try { + const [relayData, healthData] = await Promise.all([ + apiClient.getAllRelays(), + apiClient.getHealth(), + ]); + + relays.value = relayData.relays; + isConnected.value = healthData.status === 'healthy'; + lastFetchTime.value = new Date(); + error.value = null; + } catch (err: any) { + error.value = err.message || 'Failed to fetch relay data'; + isConnected.value = false; + console.error('Polling error:', err); + } finally { + isLoading.value = false; + } + }; + ``` + + **TDD Checklist**: + - [ ] Test: fetchData() updates relays on success + - [ ] Test: fetchData() sets error on API failure + - [ ] Test: fetchData() sets isLoading=false after completion + - [ ] Test: fetchData() updates lastFetchTime + +- [ ] **T046c** [US1] [TDD] Implement polling lifecycle with cleanup + - startPolling(): Fetch immediately, then setInterval + - stopPolling(): clearInterval and cleanup + - Use onMounted/onUnmounted for automatic lifecycle management + - **File**: frontend/src/composables/useRelayPolling.ts + - **Complexity**: Medium | **Uncertainty**: Low + + **Pseudocode**: + ```typescript + import { onMounted, onUnmounted } from 'vue'; + + const startPolling = () => { + if (pollingInterval !== null) return; // Already polling + + fetchData(); // Immediate first fetch + pollingInterval = window.setInterval(fetchData, intervalMs); + }; + + const stopPolling = () => { + if (pollingInterval !== null) { + clearInterval(pollingInterval); + pollingInterval = null; + } + }; + + // CRITICAL: Lifecycle cleanup to prevent memory leaks + onMounted(() => { + startPolling(); + }); + + onUnmounted(() => { + stopPolling(); + }); + ``` + + **TDD Checklist**: + - [ ] Test: startPolling() triggers immediate fetch + - [ ] Test: startPolling() sets interval for subsequent fetches + - [ ] Test: stopPolling() clears interval + - [ ] Test: onUnmounted hook calls stopPolling() + +- [ ] **T046d** [US1] [TDD] Add connection status tracking + - Track isConnected based on fetch success/failure + - Display connection status in UI + - **File**: frontend/src/composables/useRelayPolling.ts + - **Complexity**: Low | **Uncertainty**: Low + + **Pseudocode**: + ```typescript + // Already implemented in T046b, just ensure it's exposed + return { + relays, + isLoading, + error, + isConnected, // ← Connection status indicator + lastFetchTime, + refresh: fetchData, + startPolling, + stopPolling, + }; + ``` + + **TDD Checklist**: + - [ ] Test: isConnected is true after successful fetch + - [ ] Test: isConnected is false after failed fetch + +--- + +- [ ] **T047** [US1] [TDD] Create RelayCard component + - Props: relay (RelayDto) + - Display relay ID, state, label + - Emit toggle event on button click + - **File**: frontend/src/components/RelayCard.vue + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T048** [US1] [TDD] Create RelayGrid component + - Use useRelayPolling composable + - Render 8 RelayCard components + - Handle toggle events by calling API + - Display loading/error states + - **File**: frontend/src/components/RelayGrid.vue + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T049** [US1] [TDD] Integration test for US1 + - End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change + - Use Playwright or Cypress + - **File**: frontend/tests/e2e/relay-control.spec.ts + - **Complexity**: Medium | **Uncertainty**: Medium + +**Checkpoint**: US1 MVP complete - users can view and toggle individual relays + +--- + +## Phase 5: US2 - Bulk Relay Controls (0.5 days) + +**Goal**: Turn all relays on/off with single action + +**Independent Test**: POST /api/relays/all/on turns all 8 relays on + +- [ ] **T050** [US2] [TDD] Write tests for BulkControlUseCase + - Test: execute(BulkOperation::AllOn) turns all relays on + - Test: execute(BulkOperation::AllOff) turns all relays off + - **File**: src/application/use_cases/bulk_control.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T051** [US2] [TDD] Implement BulkControlUseCase + - Call controller.write_all(state) + - **File**: src/application/use_cases/bulk_control.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T052** [US2] [TDD] Define BulkOperation enum + - Variants: AllOn, AllOff + - **File**: src/domain/relay.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T053** [US2] [TDD] Write contract tests for POST /api/relays/all/on + - Test: Returns 200, all relays turn on + - **File**: tests/contract/test_relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T054** [US2] [TDD] Implement POST /api/relays/all/on endpoint + - Call BulkControlUseCase with AllOn + - **File**: src/presentation/api/relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T055** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off + - Test: Returns 200, all relays turn off + - **File**: tests/contract/test_relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T056** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint + - Call BulkControlUseCase with AllOff + - **File**: src/presentation/api/relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T057** [US2] [TDD] Add bulk control buttons to frontend + - Add "All On" and "All Off" buttons to RelayGrid component + - Call API endpoints and refresh relay states + - **File**: frontend/src/components/RelayGrid.vue + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T058** [US2] [TDD] Integration test for US2 + - Click "All On" → verify all 8 relays turn on + - Click "All Off" → verify all 8 relays turn off + - **File**: frontend/tests/e2e/bulk-control.spec.ts + - **Complexity**: Low | **Uncertainty**: Low + +**Checkpoint**: US2 complete - bulk controls functional + +--- + +## Phase 6: US3 - Health Monitoring (1 day) + +**Goal**: Display connection status and device health + +**Independent Test**: GET /api/health returns health status + +- [ ] **T059** [US3] [TDD] Write tests for GetHealthUseCase + - Test: Returns Healthy when controller is responsive + - Test: Returns Degraded after 3 consecutive errors + - Test: Returns Unhealthy after 10 consecutive errors + - **File**: src/application/use_cases/get_health.rs + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T060** [US3] [TDD] Implement GetHealthUseCase + - Use HealthMonitor to track controller status + - Return current HealthStatus + - **File**: src/application/use_cases/get_health.rs + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T061** [US3] [TDD] Define HealthDto + - Fields: status ("healthy"/"degraded"/"unhealthy"), consecutive_errors (optional), reason (optional) + - **File**: src/presentation/dto/health_dto.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T062** [US3] [TDD] Write contract tests for GET /api/health + - Test: Returns 200 with HealthDto + - **File**: tests/contract/test_health_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T063** [US3] [TDD] Implement GET /api/health endpoint + - Call GetHealthUseCase, map to HealthDto + - **File**: src/presentation/api/health_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T064** [P] [US3] [TDD] Add firmware version display (optional) + - If controller supports firmware_version(), display in UI + - **File**: frontend/src/components/DeviceInfo.vue + - **Complexity**: Low | **Uncertainty**: Medium + - **Note**: Device may not support this feature + +- [ ] **T065** [US3] [TDD] Create HealthIndicator component + - Display connection status with color-coded indicator + - Show firmware version if available + - **File**: frontend/src/components/HealthIndicator.vue + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T066** [US3] [TDD] Integrate HealthIndicator in RelayGrid + - Fetch health status in useRelayPolling composable + - Pass to HealthIndicator component + - **File**: frontend/src/components/RelayGrid.vue + - **Complexity**: Low | **Uncertainty**: Low + +**Checkpoint**: US3 complete - health monitoring visible + +--- + +## Phase 7: US4 - Relay Labeling (0.5 days) + +**Goal**: Set custom labels for each relay + +**Independent Test**: PUT /api/relays/{id}/label sets label, GET /api/relays returns label + +- [ ] **T067** [US4] [TDD] Write tests for SetLabelUseCase + - Test: execute(RelayId(1), "Pump") sets label + - Test: execute with empty label returns error + - Test: execute with 51-char label returns error + - **File**: src/application/use_cases/set_label.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T068** [US4] [TDD] Implement SetLabelUseCase + - Validate label with RelayLabel::new() + - Call label_repository.set_label() + - **File**: src/application/use_cases/set_label.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T069** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label + - Test: Returns 200, label is persisted + - Test: Returns 400 for invalid label + - **File**: tests/contract/test_relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T070** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint + - Parse id and label, call SetLabelUseCase + - **File**: src/presentation/api/relay_api.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T071** [US4] [TDD] Add label editing to RelayCard component + - Click label → show input field + - Submit → call PUT /api/relays/{id}/label + - **File**: frontend/src/components/RelayCard.vue + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T072** [US4] [TDD] Integration test for US4 + - Set label for relay 1 → refresh → verify label persists + - **File**: frontend/tests/e2e/relay-labeling.spec.ts + - **Complexity**: Low | **Uncertainty**: Low + +**Checkpoint**: US4 complete - relay labeling functional + +--- + +## Phase 8: Polish & Deployment (1 day) + +**Purpose**: Testing, documentation, and production readiness + +- [ ] **T073** [P] Add comprehensive logging at all architectural boundaries + - Log all API requests/responses + - Log all Modbus operations + - Log health status transitions + - **Files**: All API and infrastructure modules + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T074** [P] Add OpenAPI documentation for all endpoints + - Document request/response schemas + - Add example values + - Tag endpoints appropriately + - **File**: src/presentation/api/*.rs + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T075** [P] Run cargo clippy and fix all warnings + - Ensure compliance with strict linting + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T076** [P] Run cargo fmt and format all code + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T077** Generate test coverage report + - Run: just coverage + - Ensure > 80% coverage for domain and application layers + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T078** [P] Run cargo audit for dependency vulnerabilities + - Fix any high/critical vulnerabilities + - **Complexity**: Low | **Uncertainty**: Medium + +- [ ] **T079** [P] Update README.md with deployment instructions + - Document environment variables + - Document Modbus device configuration + - Add quickstart guide + - **File**: README.md + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T080** [P] Create Docker image for backend + - Multi-stage build with Rust + - Include SQLite database setup + - **File**: Dockerfile + - **Complexity**: Medium | **Uncertainty**: Low + +- [ ] **T081** [P] Create production settings/production.yaml + - Configure for actual device IP + - Set appropriate timeouts and retry settings + - **File**: settings/production.yaml + - **Complexity**: Low | **Uncertainty**: Low + +- [ ] **T082** Deploy to production environment + - Test with actual Modbus relay device + - Verify all user stories work end-to-end + - **Complexity**: Medium | **Uncertainty**: High + +**Checkpoint**: Production ready, all user stories validated + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +1. **Phase 1 (Setup)**: No dependencies - start immediately +2. **Phase 2 (Domain TyDD)**: Depends on Phase 1 module structure +3. **Phase 3 (Infrastructure)**: Depends on Phase 2 domain types +4. **Phase 4 (US1 MVP)**: Depends on Phase 3 infrastructure +5. **Phase 5 (US2)**: Depends on Phase 4 backend API complete +6. **Phase 6 (US3)**: Depends on Phase 4 backend API complete (can parallelize with US2) +7. **Phase 7 (US4)**: Depends on Phase 4 backend API complete (can parallelize with US2/US3) +8. **Phase 8 (Polish)**: Depends on all desired user stories complete + +### User Story Independence + +- **US1**: No dependencies on other stories +- **US2**: Reuses US1 backend infrastructure, but independently testable +- **US3**: Reuses US1 backend infrastructure, but independently testable +- **US4**: Reuses US1 backend infrastructure, adds new persistence layer + +### Critical Path + +**MVP (US1 only)**: Phase 1 → Phase 2 → Phase 3 → Phase 4 (5 days) +**Full Feature**: MVP + Phase 5 + Phase 6 + Phase 7 + Phase 8 (7 days) + +### Parallel Opportunities + +- **Phase 1**: T002, T003, T004, T005, T006, T007, T008 can run in parallel +- **Phase 2**: T011, T015 can run in parallel +- **Phase 3**: T020, T027, T028, T029, T030 can run in parallel after T022 complete +- **Phase 4**: T035, T044, T045 can run in parallel +- **After Phase 4**: US2, US3, US4 can be developed in parallel by different developers +- **Phase 8**: T073, T074, T075, T076, T078, T079, T080, T081 can run in parallel + +**Total Parallelizable Tasks**: 35 tasks marked `[P]` + +--- + +## Test-Driven Development Workflow + +**CRITICAL**: For every task marked `[TDD]`, follow this exact sequence: + +1. **Write failing test FIRST** (red) +2. **Verify test fails** for the right reason +3. **Implement minimum code** to pass test (green) +4. **Refactor** while keeping tests green +5. **Commit** after each task or logical group + +### Example TDD Workflow (T010): + +```bash +# 1. Write failing test for RelayId::new() validation +# In src/domain/relay.rs: +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relay_id_valid_range() { + assert!(RelayId::new(1).is_ok()); + assert!(RelayId::new(8).is_ok()); + } + + #[test] + fn test_relay_id_invalid_range() { + assert!(RelayId::new(0).is_err()); + assert!(RelayId::new(9).is_err()); + } +} + +# 2. Run test → VERIFY IT FAILS +cargo test test_relay_id + +# 3. Implement RelayId to make test pass +# 4. Run test again → VERIFY IT PASSES +# 5. Refactor if needed, keep tests green +# 6. Commit +jj describe -m "feat: implement RelayId with validation (T010)" +``` + +--- + +## Notes + +- **[P]** = Parallelizable (different files, no dependencies) +- **[US1/US2/US3/US4]** = User story mapping for traceability +- **[TDD]** = Test-Driven Development required +- **Complexity**: Low (< 1 hour) | Medium (1-3 hours) | High (> 3 hours or decomposed) +- **Uncertainty**: Low (clear path) | Medium (some unknowns) | High (requires research/spike) +- Commit after each task or logical group using `jj describe` or `jj commit` +- MVP delivery at task T049 (end of Phase 4) +- Stop at any checkpoint to independently validate user story diff --git a/specs/001-modbus-relay-control/types-design.md b/specs/001-modbus-relay-control/types-design.md new file mode 100644 index 0000000..81c91f1 --- /dev/null +++ b/specs/001-modbus-relay-control/types-design.md @@ -0,0 +1,1035 @@ +# Type Design: Modbus Relay Control System + +**Created**: 2025-12-28 +**Feature**: [spec.md](./spec.md) +**Language**: Rust +**Status**: Design + +## Overview + +This document defines the type hierarchy for a Modbus relay control system that manages 8 relay channels via Modbus RTU over TCP. The design enforces compile-time guarantees for relay identifiers, states, and labels while preventing common errors like invalid relay IDs or malformed labels. + +## Design Principles + +1. **Make illegal states unrepresentable**: Invalid relay IDs (0, 9+) cannot be constructed +2. **Validate at boundaries, trust internally**: Parse once at API/Modbus boundaries, trust types everywhere else +3. **Zero-cost abstractions**: Use `#[repr(transparent)]` for single-field newtypes +4. **Clear error messages**: Validation errors provide actionable context +5. **Type safety over convenience**: Prevent mixing RelayId with raw integers + +## Language + +**Target**: Rust (edition 2021) + +**Key features used**: +- Newtype pattern with `#[repr(transparent)]` +- Derive macros for common traits +- `thiserror` for error types +- `serde` for serialization (API boundaries) + +--- + +## Domain Primitives + +### RelayId + +**Purpose**: Type-safe relay identifier preventing out-of-range errors and accidental mixing with other integer types. + +**Wraps**: `u8` + +**Invariants**: +- Value MUST be in range 1-8 (inclusive) +- Represents user-facing relay number (not Modbus address) +- MUST NOT be constructed with value 0 or > 8 + +**Constructor Signature**: +```rust +impl RelayId { + pub fn new(value: u8) -> Result; +} +``` + +**Error Cases**: +- `RelayIdError::OutOfRange { value, min: 1, max: 8 }`: Value outside valid range + +**Methods**: +```rust +impl RelayId { + // Access raw value (for display, logging) + pub fn as_u8(&self) -> u8; + + // Convert to Modbus address (0-7) + pub fn to_modbus_address(&self) -> u16; + + // Convert from Modbus address (0-7) to RelayId (1-8) + pub fn from_modbus_address(address: u16) -> Result; +} +``` + +**Traits to Implement**: +- `Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord` (value semantics) +- `Display` - Format as "Relay {id}" +- `Serialize, Deserialize` - For API JSON (serialize as u8) + +**Rust-Specific**: +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] +pub struct RelayId(u8); +``` + +--- + +### RelayLabel + +**Purpose**: Validated custom label for relays with length and character constraints. + +**Wraps**: `String` + +**Invariants**: +- Length MUST be 1-50 characters (inclusive) +- MUST contain only alphanumeric characters, spaces, hyphens, underscores +- MUST NOT be empty +- MUST NOT consist only of whitespace +- Leading/trailing whitespace is trimmed on construction + +**Constructor Signature**: +```rust +impl RelayLabel { + pub fn new(value: String) -> Result; +} +``` + +**Error Cases**: +- `RelayLabelError::Empty`: String is empty or only whitespace +- `RelayLabelError::TooLong { max: 50, actual }`: Exceeds 50 characters +- `RelayLabelError::InvalidCharacters { position, char }`: Contains disallowed character + +**Methods**: +```rust +impl RelayLabel { + // Access inner string + pub fn as_str(&self) -> &str; + + // Default label for unlabeled relays + pub fn default_for_relay(id: RelayId) -> Self; +} +``` + +**Traits to Implement**: +- `Debug, Clone, PartialEq, Eq, Hash` (no Copy - owns String) +- `Display` - Return inner string as-is +- `Serialize, Deserialize` - Serialize as string +- `AsRef` - Allow ergonomic string access + +**Rust-Specific**: +```rust +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct RelayLabel(String); +``` + +--- + +### ModbusAddress + +**Purpose**: Type-safe Modbus coil address preventing confusion with RelayId. + +**Wraps**: `u16` + +**Invariants**: +- Value MUST be in range 0-7 (for 8-channel relay) +- Represents Modbus protocol address (0-indexed) +- MUST NOT be confused with RelayId (which is 1-indexed) + +**Constructor Signature**: +```rust +impl ModbusAddress { + pub fn new(value: u16) -> Result; +} +``` + +**Error Cases**: +- `ModbusAddressError::OutOfRange { value, max: 7 }`: Address exceeds device capacity + +**Methods**: +```rust +impl ModbusAddress { + pub fn as_u16(&self) -> u16; + + // Convert to RelayId (add 1) + pub fn to_relay_id(&self) -> RelayId; + + // Convert from RelayId (subtract 1) + pub fn from_relay_id(id: RelayId) -> Self; +} +``` + +**Traits to Implement**: +- `Debug, Clone, Copy, PartialEq, Eq, Hash` (value type) +- `Display` - Format as "Address {value}" + +**Rust-Specific**: +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct ModbusAddress(u16); +``` + +**Design Note**: This type prevents the common error of using RelayId (1-8) directly as Modbus address (0-7). + +--- + +### FirmwareVersion + +**Purpose**: Validated firmware version string with format constraints. + +**Wraps**: `String` + +**Invariants**: +- MUST NOT be empty +- MUST be max 20 characters +- Format: semantic versioning (e.g., "1.2.3") or vendor-specific +- Leading/trailing whitespace trimmed + +**Constructor Signature**: +```rust +impl FirmwareVersion { + pub fn new(value: String) -> Result; +} +``` + +**Error Cases**: +- `FirmwareVersionError::Empty`: Version string is empty +- `FirmwareVersionError::TooLong { max: 20, actual }`: Exceeds length limit + +**Methods**: +```rust +impl FirmwareVersion { + pub fn as_str(&self) -> &str; +} +``` + +**Traits to Implement**: +- `Debug, Clone, PartialEq, Eq` (no Hash - version comparison) +- `Display` - Return version string +- `Serialize, Deserialize` - As string + +**Rust-Specific**: +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +#[repr(transparent)] +pub struct FirmwareVersion(String); +``` + +--- + +## Sum Types / Enums + +### RelayState + +**Purpose**: Explicit representation of relay physical state. + +**Variants**: +```rust +pub enum RelayState { + On, + Off, +} +``` + +**Pattern Matching**: Required for exhaustiveness +**Compiler Enforcement**: Yes + +**Methods**: +```rust +impl RelayState { + // Toggle state + pub fn toggle(&self) -> Self; + + // Convert to boolean (On = true, Off = false) + pub fn as_bool(&self) -> bool; + + // Convert from boolean + pub fn from_bool(value: bool) -> Self; + + // Convert to Modbus coil value (On = 0xFF00, Off = 0x0000) + pub fn to_modbus_coil(&self) -> u16; + + // Convert from Modbus coil value + pub fn from_modbus_coil(value: u16) -> Self; +} +``` + +**Traits to Implement**: +- `Debug, Clone, Copy, PartialEq, Eq, Hash` +- `Display` - "ON" or "OFF" +- `Serialize, Deserialize` - As lowercase string "on"/"off" for API + +**Design Note**: Explicit enum prevents boolean blindness and makes code self-documenting. + +--- + +### BulkOperation + +**Purpose**: Explicit representation of bulk relay operations. + +**Variants**: +```rust +pub enum BulkOperation { + AllOn, + AllOff, +} +``` + +**Pattern Matching**: Required +**Compiler Enforcement**: Yes + +**Methods**: +```rust +impl BulkOperation { + // Convert to target RelayState + pub fn target_state(&self) -> RelayState; +} +``` + +**Traits to Implement**: +- `Debug, Clone, Copy, PartialEq, Eq` +- `Display` - "All ON" or "All OFF" +- `Serialize, Deserialize` - As "all_on" or "all_off" for API + +--- + +### HealthStatus + +**Purpose**: Explicit device health state with degradation levels. + +**Variants**: +```rust +pub enum HealthStatus { + Healthy, + Degraded { error_count: u32 }, + Unhealthy { reason: UnhealthyReason }, +} +``` + +**Sub-type**: +```rust +pub enum UnhealthyReason { + DeviceUnreachable, + ConnectionLost, + TimeoutExceeded, + ProtocolError { details: String }, +} +``` + +**Pattern Matching**: Required for handling different health states +**Compiler Enforcement**: Yes + +**Methods**: +```rust +impl HealthStatus { + pub fn is_healthy(&self) -> bool; + pub fn can_perform_operations(&self) -> bool; +} +``` + +**Traits to Implement**: +- `Debug, Clone, PartialEq, Eq` +- `Display` - Human-readable status +- `Serialize, Deserialize` - Tagged union for API + +--- + +### ModbusCommand + +**Purpose**: Type-safe representation of Modbus function codes and operations. + +**Variants**: +```rust +pub enum ModbusCommand { + ReadCoils { + starting_address: ModbusAddress, + quantity: u16, + }, + WriteSingleCoil { + address: ModbusAddress, + state: RelayState, + }, + WriteMultipleCoils { + starting_address: ModbusAddress, + states: Vec, + }, +} +``` + +**Pattern Matching**: Required +**Compiler Enforcement**: Yes + +**Methods**: +```rust +impl ModbusCommand { + // Get Modbus function code + pub fn function_code(&self) -> u8; + + // Create command to read all relays + pub fn read_all_relays() -> Self; + + // Create command to toggle single relay + pub fn toggle_relay(id: RelayId, state: RelayState) -> Self; + + // Create command for bulk operation + pub fn bulk_operation(op: BulkOperation) -> Self; +} +``` + +**Traits to Implement**: +- `Debug, Clone, PartialEq, Eq` +- No Serialize/Deserialize - internal representation only + +**Design Note**: Encapsulates Modbus protocol details, preventing direct manipulation of function codes. + +--- + +## Composite Types + +### Relay + +**Purpose**: Complete representation of a relay with all its properties. + +**Fields**: +```rust +pub struct Relay { + id: RelayId, + state: RelayState, + label: Option, +} +``` + +**Invariants**: +- `id` is always valid (enforced by RelayId type) +- `state` is always valid (enforced by RelayState enum) +- `label` is either None or a valid RelayLabel + +**Construction**: +```rust +impl Relay { + pub fn new(id: RelayId, state: RelayState) -> Self; + pub fn with_label(id: RelayId, state: RelayState, label: RelayLabel) -> Self; +} +``` + +**Methods**: +```rust +impl Relay { + pub fn id(&self) -> RelayId; + pub fn state(&self) -> RelayState; + pub fn label(&self) -> Option<&RelayLabel>; + + pub fn set_state(&mut self, state: RelayState); + pub fn set_label(&mut self, label: Option); + + pub fn toggle(&mut self); + + // Get display name (label or default) + pub fn display_name(&self) -> String; +} +``` + +**Traits to Implement**: +- `Debug, Clone, PartialEq, Eq` +- `Serialize, Deserialize` - For API responses + +--- + +### DeviceHealth + +**Purpose**: Complete health information about Modbus device. + +**Fields**: +```rust +pub struct DeviceHealth { + status: HealthStatus, + firmware_version: Option, + last_contact: Option>, + consecutive_errors: u32, +} +``` + +**Invariants**: +- If `status` is `Healthy`, `last_contact` should be `Some` +- If `status` is `Unhealthy`, `last_contact` may be `None` (never connected) or stale +- `consecutive_errors` resets to 0 on successful operation + +**Construction**: +```rust +impl DeviceHealth { + pub fn new() -> Self; // Starts as Unhealthy (not yet connected) + pub fn healthy(firmware: FirmwareVersion) -> Self; +} +``` + +**Methods**: +```rust +impl DeviceHealth { + pub fn status(&self) -> &HealthStatus; + pub fn firmware_version(&self) -> Option<&FirmwareVersion>; + pub fn last_contact(&self) -> Option>; + + pub fn mark_successful_contact(&mut self, firmware: Option); + pub fn mark_error(&mut self, reason: UnhealthyReason); + pub fn mark_degraded(&mut self); + + pub fn is_operational(&self) -> bool; +} +``` + +**Traits to Implement**: +- `Debug, Clone` +- `Serialize, Deserialize` - For API health endpoint + +--- + +### RelayCollection + +**Purpose**: Type-safe collection of exactly 8 relays with indexed access. + +**Fields**: +```rust +pub struct RelayCollection { + relays: [Relay; 8], +} +``` + +**Invariants**: +- MUST contain exactly 8 relays +- Relays MUST have IDs 1-8 in order +- Array index corresponds to RelayId - 1 + +**Construction**: +```rust +impl RelayCollection { + // Create with all relays OFF and no labels + pub fn new() -> Self; + + // Create from array (validates IDs are 1-8) + pub fn from_array(relays: [Relay; 8]) -> Result; +} +``` + +**Methods**: +```rust +impl RelayCollection { + // Get relay by ID (infallible - ID is validated) + pub fn get(&self, id: RelayId) -> &Relay; + pub fn get_mut(&mut self, id: RelayId) -> &mut Relay; + + // Iterate over all relays + pub fn iter(&self) -> impl Iterator; + pub fn iter_mut(&mut self) -> impl Iterator; + + // Bulk operations + pub fn set_all_states(&mut self, state: RelayState); + pub fn apply_bulk_operation(&mut self, op: BulkOperation); +} +``` + +**Traits to Implement**: +- `Debug, Clone` +- `Index` - Ergonomic access: `collection[relay_id]` +- `Serialize, Deserialize` - For API responses + +**Design Note**: Fixed-size array prevents runtime length checks and makes the 8-relay constraint compile-time enforced. + +--- + +## Validation Boundaries + +### API Layer (Parse, Don't Validate) + +**Entry Points**: +- HTTP request path parameters: `/api/relays/{id}` → parse to `RelayId` +- JSON request bodies: deserialize with validated newtypes +- Query parameters: validate and convert to domain types + +**Validation Strategy**: +```rust +// ✅ API handler (parsing boundary) +async fn toggle_relay( + Path(id): Path, // Raw input +) -> Result, ApiError> { + let relay_id = RelayId::new(id) + .map_err(|e| ApiError::InvalidRelayId(e))?; // Validate once + + relay_service.toggle(relay_id).await // Pass validated type +} +``` + +**Error Mapping**: +```rust +impl From for ApiError { + fn from(err: RelayIdError) -> Self { + match err { + RelayIdError::OutOfRange { value, min, max } => + ApiError::BadRequest(format!( + "Relay ID {} out of range (valid: {}-{})", + value, min, max + )) + } + } +} +``` + +--- + +### Domain Layer (Trust Types) + +**Strategy**: Accept only validated types, perform no redundant validation. + +```rust +// ✅ Domain service (trusts types) +impl RelayService { + pub async fn toggle(&self, id: RelayId) -> Result { + // No validation needed - RelayId is already valid + let current_state = self.get_state(id).await?; + let new_state = current_state.toggle(); + self.set_state(id, new_state).await?; + Ok(self.get_relay(id).await?) + } +} +``` + +**No Validation Inside Domain**: +- RelayId is guaranteed valid by type system +- RelayState is guaranteed valid by enum +- RelayLabel is guaranteed valid by constructor + +--- + +### Infrastructure Layer (Modbus Boundary) + +**Modbus → Domain (Parse)**: +```rust +// ✅ Parse Modbus response to domain types +impl ModbusClient { + async fn read_coils(&self) -> Result, ModbusError> { + let raw_coils = self.raw_read_coils(0, 8).await?; + + // Convert raw Modbus values to validated domain types + let states = raw_coils.iter() + .map(|&coil| RelayState::from_modbus_coil(coil)) + .collect(); + + Ok(states) // Return validated types + } +} +``` + +**Domain → Modbus (Convert)**: +```rust +// ✅ Convert validated types to Modbus protocol +impl ModbusClient { + async fn write_single_coil( + &self, + id: RelayId, // Validated type + state: RelayState, + ) -> Result<(), ModbusError> { + let address = id.to_modbus_address(); // Infallible conversion + let coil_value = state.to_modbus_coil(); // Infallible conversion + + self.raw_write_coil(address, coil_value).await + } +} +``` + +--- + +### Persistence Layer (Configuration YAML) + +**File → Domain (Parse)**: +```rust +// ✅ Load relay labels from YAML +impl ConfigLoader { + fn load_labels(&self) -> Result, ConfigError> { + let raw: HashMap = self.read_yaml()?; + + raw.into_iter() + .map(|(id, label)| { + let relay_id = RelayId::new(id)?; + let relay_label = RelayLabel::new(label)?; + Ok((relay_id, relay_label)) + }) + .collect() + } +} +``` + +--- + +## Error Types + +### RelayIdError + +**Purpose**: Validation errors for RelayId construction. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RelayIdError { + #[error("Relay ID {value} out of range (valid: {min}-{max})")] + OutOfRange { + value: u8, + min: u8, + max: u8, + }, +} +``` + +**Display Messages**: +- `OutOfRange`: "Relay ID 9 out of range (valid: 1-8)" + +**Usage**: +```rust +RelayId::new(9) // Err(RelayIdError::OutOfRange { value: 9, min: 1, max: 8 }) +``` + +--- + +### RelayLabelError + +**Purpose**: Validation errors for RelayLabel construction. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RelayLabelError { + #[error("Relay label cannot be empty")] + Empty, + + #[error("Relay label too long (max: {max}, got: {actual})")] + TooLong { + max: usize, + actual: usize, + }, + + #[error("Invalid character '{char}' at position {position} (only alphanumeric, spaces, hyphens, underscores allowed)")] + InvalidCharacters { + position: usize, + char: char, + }, +} +``` + +**Display Messages**: +- `Empty`: "Relay label cannot be empty" +- `TooLong`: "Relay label too long (max: 50, got: 73)" +- `InvalidCharacters`: "Invalid character '!' at position 5 (only alphanumeric, spaces, hyphens, underscores allowed)" + +--- + +### ModbusAddressError + +**Purpose**: Validation errors for ModbusAddress. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ModbusAddressError { + #[error("Modbus address {value} exceeds device capacity (max: {max})")] + OutOfRange { + value: u16, + max: u16, + }, +} +``` + +--- + +### FirmwareVersionError + +**Purpose**: Validation errors for FirmwareVersion. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum FirmwareVersionError { + #[error("Firmware version cannot be empty")] + Empty, + + #[error("Firmware version too long (max: {max}, got: {actual})")] + TooLong { + max: usize, + actual: usize, + }, +} +``` + +--- + +### RelayCollectionError + +**Purpose**: Errors when constructing RelayCollection. + +```rust +#[derive(Debug, thiserror::Error)] +pub enum RelayCollectionError { + #[error("Relay IDs must be 1-8 in order, found ID {found} at index {index}")] + InvalidIdOrdering { + index: usize, + found: u8, + }, +} +``` + +--- + +## Implementation Notes + +### Rust-Specific Patterns + +**Zero-Cost Abstractions**: +```rust +#[repr(transparent)] // Guarantee no runtime overhead +pub struct RelayId(u8); +``` + +**Infallible Conversions** (when source is already validated): +```rust +impl RelayId { + // Infallible because RelayId is guaranteed valid (1-8) + pub fn to_modbus_address(&self) -> u16 { + (self.0 - 1) as u16 // Always produces 0-7 + } +} + +impl ModbusAddress { + // Infallible because ModbusAddress is guaranteed valid (0-7) + pub fn to_relay_id(&self) -> RelayId { + RelayId(self.0 as u8 + 1) // Always produces 1-8 + } +} +``` + +**Serde Integration**: +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +#[serde(try_from = "u8", into = "u8")] // Parse from JSON number +pub struct RelayId(u8); + +impl TryFrom for RelayId { + type Error = RelayIdError; + fn try_from(value: u8) -> Result { + RelayId::new(value) + } +} + +impl From for u8 { + fn from(id: RelayId) -> u8 { + id.0 + } +} +``` + +**Array Indexing**: +```rust +impl std::ops::Index for RelayCollection { + type Output = Relay; + + fn index(&self, id: RelayId) -> &Self::Output { + // Safe: RelayId is 1-8, array index is 0-7 + &self.relays[(id.0 - 1) as usize] + } +} +``` + +--- + +### Performance Considerations + +**Copy Types** (cheap to copy, no allocation): +- `RelayId` (u8) +- `RelayState` (enum) +- `BulkOperation` (enum) +- `ModbusAddress` (u16) + +**Clone Types** (heap allocation, clone when needed): +- `RelayLabel` (String) +- `FirmwareVersion` (String) +- `Relay` (contains Option) +- `DeviceHealth` (contains String in enum) + +**Fixed-Size Types** (stack allocation, no heap): +- `RelayCollection` ([Relay; 8] - stack allocated) + +--- + +### Testing Strategy + +**Type Construction Tests**: +```rust +#[cfg(test)] +mod tests { + #[test] + fn relay_id_valid_range() { + assert!(RelayId::new(1).is_ok()); + assert!(RelayId::new(8).is_ok()); + } + + #[test] + fn relay_id_rejects_zero() { + assert!(matches!( + RelayId::new(0), + Err(RelayIdError::OutOfRange { value: 0, .. }) + )); + } + + #[test] + fn relay_id_rejects_out_of_range() { + assert!(matches!( + RelayId::new(9), + Err(RelayIdError::OutOfRange { value: 9, .. }) + )); + } +} +``` + +**Conversion Tests**: +```rust +#[test] +fn relay_id_modbus_address_roundtrip() { + let id = RelayId::new(5).unwrap(); + let address = id.to_modbus_address(); + assert_eq!(address, 4); // 5 - 1 = 4 + + let modbus_addr = ModbusAddress::new(address).unwrap(); + let back_to_id = modbus_addr.to_relay_id(); + assert_eq!(id, back_to_id); +} +``` + +--- + +## Integration with Existing Code + +### Settings (Configuration) + +**Configuration Structure**: +```rust +#[derive(Deserialize)] +pub struct ModbusSettings { + pub host: String, + pub port: u16, + pub device_address: u8, // Parsed to ModbusAddress at startup + pub timeout_secs: u64, +} + +#[derive(Deserialize)] +pub struct RelaySettings { + pub labels: HashMap, // Parsed to HashMap +} +``` + +**Startup Validation**: +```rust +impl Settings { + pub fn validated_modbus_address(&self) -> Result { + ModbusAddress::new(self.modbus.device_address as u16) + .map_err(ConfigError::InvalidModbusAddress) + } + + pub fn validated_relay_labels(&self) -> Result, ConfigError> { + self.relays.labels.iter() + .map(|(id, label)| { + let relay_id = RelayId::new(*id)?; + let relay_label = RelayLabel::new(label.clone())?; + Ok((relay_id, relay_label)) + }) + .collect() + } +} +``` + +--- + +### API Routes (Poem Integration) + +**Path Parameter Parsing**: +```rust +use poem::web::Path; +use poem_openapi::{param::Path as OpenApiPath, payload::Json, OpenApi}; + +#[derive(Debug, Deserialize)] +struct RelayIdParam(u8); + +#[OpenApi] +impl RelayApi { + #[oai(path = "/relays/:id/toggle", method = "post")] + async fn toggle_relay( + &self, + #[oai(name = "id")] id: OpenApiPath, + ) -> Result, ApiError> { + let relay_id = RelayId::new(id.0) + .map_err(ApiError::from)?; + + let relay = self.service.toggle(relay_id).await?; + Ok(Json(relay)) + } +} +``` + +**Request/Response DTOs**: +```rust +#[derive(Serialize, Deserialize, Object)] +pub struct RelayResponse { + id: u8, // Serialized from RelayId + state: String, // "on" or "off" from RelayState + label: Option, // From RelayLabel +} + +impl From for RelayResponse { + fn from(relay: Relay) -> Self { + Self { + id: relay.id().as_u8(), + state: relay.state().to_string().to_lowercase(), + label: relay.label().map(|l| l.as_str().to_string()), + } + } +} +``` + +--- + +## Next Steps + +1. **Review this design**: Validate type hierarchy meets all requirements +2. **Run `/tydd:implement-types`**: Generate Rust implementations +3. **Run `/tdd:write-tests`**: Create comprehensive test suite +4. **Integration**: Update architecture plan with concrete type definitions + +--- + +## Type Summary + +**Domain Primitives** (7): +- `RelayId` - Validated relay identifier (1-8) +- `RelayLabel` - Validated custom label (1-50 chars, alphanumeric) +- `ModbusAddress` - Validated Modbus address (0-7) +- `FirmwareVersion` - Validated version string + +**Sum Types** (4): +- `RelayState` - On | Off +- `BulkOperation` - AllOn | AllOff +- `HealthStatus` - Healthy | Degraded | Unhealthy +- `ModbusCommand` - ReadCoils | WriteSingleCoil | WriteMultipleCoils + +**Composite Types** (3): +- `Relay` - Complete relay representation +- `DeviceHealth` - Device health information +- `RelayCollection` - Type-safe collection of 8 relays + +**Error Types** (5): +- `RelayIdError` +- `RelayLabelError` +- `ModbusAddressError` +- `FirmwareVersionError` +- `RelayCollectionError` + +**Total**: 19 carefully designed types ensuring compile-time correctness. diff --git a/specs/constitution.md b/specs/constitution.md new file mode 100644 index 0000000..f1126c4 --- /dev/null +++ b/specs/constitution.md @@ -0,0 +1,243 @@ + + +# STA (Smart Temperature & Appliance Control) Constitution + +## Core Principles + +### I. Hexagonal Architecture (Clean Architecture) + +The system MUST follow hexagonal architecture principles with clear separation of concerns: + +- **Domain Layer**: Pure business logic with no external dependencies +- **Application Layer**: Use cases and orchestration logic +- **Infrastructure Layer**: External concerns (HTTP, Modbus, persistence) +- **Presentation Layer**: API contracts and DTOs + +All dependencies MUST point inward. Infrastructure and presentation layers depend on domain/application, never the reverse. This ensures testability, maintainability, and framework independence. + +**SOLID Alignment**: This principle directly enforces Dependency Inversion Principle (DIP) through inward-pointing dependencies and Interface Segregation Principle (ISP) through layer boundaries. + +**Rationale**: Hexagonal architecture enables independent testing of business logic, technology substitution without domain changes, and clear ownership boundaries between layers. + +### II. Domain-Driven Design + +The domain model MUST be rich and expressive: + +- Domain entities encapsulate business rules and invariants +- Value objects are immutable and self-validating +- Repositories abstract persistence concerns +- Services contain domain logic that doesn't belong to entities +- Clear ubiquitous language shared between code and specifications + +Domain types MUST NOT leak across architectural boundaries. DTOs and domain entities are distinct. + +**Rationale**: DDD ensures the codebase reflects real-world domain concepts, making it easier to reason about, maintain, and evolve as business requirements change. + +### III. Test-First Development (NON-NEGOTIABLE) + +TDD is mandatory for all feature development: + +1. Write failing tests first +2. User reviews and approves test scenarios +3. Implement minimal code to pass tests +4. Refactor while keeping tests green + +Test coverage MUST include: +- Unit tests for domain logic (isolated, fast) +- Integration tests for infrastructure adapters (Modbus, HTTP) +- Contract tests for API endpoints +- Mock-based tests to avoid hardware dependencies during CI + +**Rationale**: Test-first ensures specifications are validated before implementation, prevents regression, and serves as executable documentation. The red-green-refactor cycle enforces disciplined development. + +### IV. API-First Design + +All functionality MUST be exposed through well-defined API contracts: + +- RESTful HTTP API for web interface (Poem + OpenAPI) +- Modbus TCP protocol for relay hardware communication +- Clear separation between public API contracts and internal implementation +- OpenAPI specifications generated and maintained automatically +- API versioning strategy for backward compatibility + +Backend (Rust) and frontend (TypeScript/Vue) communicate exclusively through documented API contracts. + +**Rationale**: API-first design enables parallel frontend/backend development, clear integration points, and prevents tight coupling between presentation and business logic. + +### V. Observability & Monitoring + +Production systems MUST be observable: + +- Structured logging at all architectural boundaries (tracing crate) +- Request/response logging for HTTP and Modbus communication +- Health check endpoints for system status +- Error context preserved across layer boundaries (thiserror) +- JSON log format for production environments +- Human-readable format for development + +Debugging MUST be possible without modifying code. + +**Rationale**: Observability enables rapid diagnosis of production issues, performance analysis, and understanding system behavior under real-world conditions. + +### VI. SOLID Principles + +All code MUST adhere to SOLID design principles: + +**Single Responsibility Principle (SRP)**: +- Each module, class, or function has ONE reason to change +- Domain entities focus on business rules only +- Infrastructure adapters focus on external integration only +- Controllers/handlers focus on request orchestration only + +**Open/Closed Principle (OCP)**: +- Entities open for extension via traits/interfaces +- Closed for modification through trait implementations +- New behavior added through new implementations, not modifications + +**Liskov Substitution Principle (LSP)**: +- Trait implementations MUST be substitutable without breaking behavior +- Mock implementations MUST honor trait contracts +- Repository implementations MUST preserve domain semantics + +**Interface Segregation Principle (ISP)**: +- Traits MUST be focused and minimal +- Clients depend only on methods they use +- Large interfaces split into role-specific traits + +**Dependency Inversion Principle (DIP)**: +- High-level domain logic depends on abstractions (traits) +- Low-level infrastructure implements abstractions +- Dependencies injected through constructors/builders +- No direct instantiation of concrete infrastructure types in domain + +**Rationale**: SOLID principles ensure code remains maintainable, testable, and extensible as the system evolves. They provide concrete design rules that support the broader clean architecture goals. + +## Technology Stack + +### Backend (Rust) + +- **Web Framework**: Poem with OpenAPI support (poem-openapi) +- **Async Runtime**: Tokio +- **Modbus Protocol**: tokio-modbus 0.17.0 +- **Configuration**: config crate with YAML support +- **Logging**: tracing + tracing-subscriber +- **Error Handling**: thiserror +- **Testing**: Built-in Rust test framework + mockall + +### Frontend (TypeScript/Vue) + +- **Framework**: Vue 3 with TypeScript +- **HTTP Client**: Type-safe API client generated from OpenAPI specs +- **Build Tool**: Vite +- **State Management**: Pinia (if needed for complex state) + +### Architecture Patterns + +- Clean/Hexagonal architecture with explicit layer boundaries +- Repository pattern for persistence abstraction +- Trait-based dependency injection +- Mock-based testing to avoid hardware dependencies +- SOLID principles applied to all design decisions + +## Development Workflow + +### Feature Development Process + +1. **Specification Phase**: + - Write feature specification in `specs//spec.md` + - Define data model in `specs//data-model.md` + - Create implementation plan in `specs//plan.md` + - Document API contracts in `specs//contracts/` + +2. **Test-First Implementation**: + - Write failing tests for domain logic + - Write failing tests for infrastructure adapters + - Write failing contract tests for API endpoints + - Get user approval on test scenarios + - Implement code to pass tests + - Refactor while maintaining green tests + +3. **Integration & Validation**: + - Integration tests with mocked hardware + - Real hardware testing (when available) + - OpenAPI documentation validation + - Code review focusing on architectural compliance + +### Code Review Requirements + +All code changes MUST be reviewed for: +- Compliance with hexagonal architecture principles +- SOLID principles adherence +- Domain model clarity and expressiveness +- Test coverage and quality +- API contract adherence +- Observability (logging, error context) +- No domain logic leaking into infrastructure layer + +### Quality Gates + +Code MUST NOT be merged unless: +- All tests pass (unit + integration) +- Test coverage meets minimum thresholds +- Architecture review confirms layer separation +- SOLID principles validated (no SRP/DIP violations) +- OpenAPI specs are up-to-date +- Logging captures key operational events + +## Governance + +This constitution supersedes all other development practices and guidelines. All architectural decisions MUST align with the principles defined herein. + +### Amendment Process + +Amendments to this constitution require: +1. Documented rationale for the change +2. Impact analysis on existing codebase +3. Migration plan if breaking existing patterns +4. Approval before implementation + +### Compliance Verification + +- All pull requests MUST verify constitutional compliance +- Architecture decisions MUST be justified against these principles +- Complexity introduced MUST be necessary to uphold principles +- Violations MUST be addressed before merge + +### Version Control + +This constitution uses semantic versioning: +- **MAJOR**: Breaking changes to core principles +- **MINOR**: New principles or significant clarifications +- **PATCH**: Typo fixes, wording improvements + +**Version**: 1.1.0 | **Ratified**: 2025-12-27 | **Last Amended**: 2025-12-27 diff --git a/specs/templates/plan-template.md b/specs/templates/plan-template.md new file mode 100644 index 0000000..6a8bfc6 --- /dev/null +++ b/specs/templates/plan-template.md @@ -0,0 +1,104 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] +**Project Type**: [single/web/mobile - determines source structure] +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/templates/spec-checklist.md b/specs/templates/spec-checklist.md new file mode 100644 index 0000000..0a7fe79 --- /dev/null +++ b/specs/templates/spec-checklist.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: [FEATURE NAME] + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: [DATE] +**Feature**: [Link to spec.md] + +## Content Quality + +- [ ] No implementation details (languages, frameworks, APIs) +- [ ] Focused on user value and business needs +- [ ] Written for non-technical stakeholders +- [ ] All mandatory sections completed + +## Requirement Completeness + +- [ ] No [NEEDS CLARIFICATION] markers remain +- [ ] Requirements are testable and unambiguous +- [ ] Success criteria are measurable +- [ ] Success criteria are technology-agnostic (no implementation details) +- [ ] All acceptance scenarios are defined +- [ ] Edge cases are identified +- [ ] Scope is clearly bounded +- [ ] Dependencies and assumptions identified + +## Feature Readiness + +- [ ] All functional requirements have clear acceptance criteria +- [ ] User scenarios cover primary flows +- [ ] Feature meets measurable outcomes defined in Success Criteria +- [ ] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/sdd:01-specify` or `/sdd:01-plan` \ No newline at end of file diff --git a/specs/templates/spec-template.md b/specs/templates/spec-template.md new file mode 100644 index 0000000..c67d914 --- /dev/null +++ b/specs/templates/spec-template.md @@ -0,0 +1,115 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` +**Created**: [DATE] +**Status**: Draft +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] diff --git a/specs/templates/tasks-template.md b/specs/templates/tasks-template.md new file mode 100644 index 0000000..60f9be4 --- /dev/null +++ b/specs/templates/tasks-template.md @@ -0,0 +1,251 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 2 + +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence