From b18c3bcfff783d80b92e629edccac525fcc8137a Mon Sep 17 00:00:00 2001 From: Zach Radlicz Date: Thu, 7 Aug 2025 12:42:26 -0500 Subject: [PATCH 1/5] updated configuration to use EEPROM backup in case of sd card failures --- CONFIGURATION.md | 63 +++++++----- src/FlightControl.cpp | 78 +++++++++----- src/configuration/ConfigurationManager.cpp | 113 +++++++++++++++++++-- src/configuration/ConfigurationManager.h | 12 +++ 4 files changed, 208 insertions(+), 58 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 58c5cf6..c5be46b 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -4,18 +4,21 @@ Configuration is loaded at startup in the following priority order: -1. **SD Card**: `config.json` file on the SD card -2. **Default**: Built-in default configuration if SD file not found +1. **SD Card**: `config.json` file on the SD card (primary storage) +2. **EEPROM Backup**: Configuration restored from EEPROM if SD card fails +3. **Default**: Built-in default configuration if both SD and EEPROM unavailable + +The system automatically creates EEPROM backups when configurations are successfully loaded from SD card or applied via cloud functions. When EEPROM backup is used, the configuration is automatically restored to the SD card. #### Updating Configuration Configuration can be updated through: 1. **Cloud Function**: `updateConfig` Particle function - - copy the intended config.json and paste it as and argument into updateConfig - - If successful, the device will restart without returning any value. - - verify that the first metadata packet after reset matches your configuration - - See below for other types of responses. + - Configuration is always saved to EEPROM backup for reliability + - SD card is updated when possible, but function succeeds if EEPROM update works + - Device will restart automatically on successful configuration update + - Verify success by checking metadata packet includes new configuration UIDs 3. **SD Card**: Replace `config.json` file and restart system #### updateConfig Function Details @@ -50,15 +53,12 @@ The function returns specific error codes when configuration updates fail: | Error Code | Description | Troubleshooting | |------------|-------------|-----------------| -| `0` | Success - Configuration removed from SD card | | -| `1` | Success - Configuration updated, system restarting | | -| `-1` | Failed to remove configuration from SD card | | -| `-2` | Invalid configuration format - Missing 'config' element | | -| `-3` | Invalid configuration format - Missing 'system' element | | -| `-4` | Invalid configuration format - Missing 'sensors' element | | -| `-5` | Failed to write test file to SD card | | -| `-6` | Failed to remove current configuration from SD card | | -| `-7` | Failed to write new configuration to SD card | | +| `0` | Success - Configuration removed from SD card and EEPROM | | +| `1` | Success - Configuration updated and saved to EEPROM, system restarting | May show SD card warnings but still succeeds | +| `-2` | Invalid configuration format - Missing 'config' element | Check JSON structure | +| `-3` | Invalid configuration format - Missing 'system' element | Ensure 'system' section exists | +| `-4` | Invalid configuration format - Missing 'sensors' element | Ensure 'sensors' section exists | +| `-8` | Failed to parse configuration - Invalid JSON or values | Check JSON syntax and parameter ranges | ##### Configuration Validation Rules @@ -70,13 +70,14 @@ The system performs several validation checks: - Must contain "sensors" section within config (checked by string search) - All whitespace, newlines, carriage returns, and tabs are automatically stripped -2. **SD Card Validation** - - SD card must be accessible for writing - - System tests write capability before attempting configuration update - - Current configuration must be removable before writing new configuration +2. **Configuration Processing** + - Configuration is parsed and applied first (automatically saves to EEPROM backup) + - SD card is updated when possible, but failures don't prevent success + - System succeeds if configuration parsing works, regardless of SD card status + - Warning messages indicate SD card issues but don't cause operation failure 3. **Special Commands** - - Use "remove" as the configuration string to delete config.json and revert to defaults + - Use "remove" as the configuration string to delete config.json and clear EEPROM backup ##### Example Error Scenarios @@ -92,17 +93,25 @@ particle call device_name updateConfig '{"config":{"system":{"logPeriod":300}}}' # Returns: -4 ``` -###### SD Card Issues +###### SD Card Issues (Non-Critical) ```bash -# If SD card is not available or full -particle call device_name updateConfig '{"config":{"system":{"logPeriod":300},"sensors":{}}}' -# May return: -5, -6, or -7 depending on the specific SD card failure point +# Configuration update succeeds even if SD card fails +particle call device_name updateConfig '{"config":{"system":{"logPeriod":300},"sensors":{"numSoil":3}}}' +# Returns: 1 (success) - Configuration saved to EEPROM, may show SD warnings in logs +# Serial output: "Warning: Failed to write configuration to SD card, but EEPROM backup is available." ``` ###### Configuration Removal ```bash particle call device_name updateConfig 'remove' -# Returns: 0 (success) or -1 (failed to remove) +# Returns: 0 (success) - Removes SD config and clears EEPROM backup +``` + +###### Parsing Failures (Critical) +```bash +# Invalid JSON structure causes complete failure +particle call device_name updateConfig '{"config":{"system":{"logPeriod":"invalid"}}}' +# Returns: -8 (failed to parse configuration) ``` ##### Best Practices @@ -111,7 +120,9 @@ particle call device_name updateConfig 'remove' 2. **Check Parameter Ranges**: Verify all values are within acceptable ranges 3. **Plan Hardware Requirements**: Ensure sufficient Talons for sensor configuration 4. **Monitor Device Status**: Watch for restart after successful configuration -5. **Verify Configuration**: Check UIDs after restart to confirm changes applied +5. **Verify Configuration**: Check UIDs in metadata packets to confirm changes applied +6. **EEPROM Backup Reliability**: Configuration updates work even with SD card failures +7. **Field Recovery**: Insert fresh SD cards - system automatically restores config from EEPROM #### Configuration UIDs diff --git a/src/FlightControl.cpp b/src/FlightControl.cpp index 9f350e3..ee325ca 100644 --- a/src/FlightControl.cpp +++ b/src/FlightControl.cpp @@ -841,7 +841,9 @@ String getMetadataString() output = output + "\"Update\":" + String(logPeriod) + ","; output = output + "\"Backhaul\":" + String(backhaulCount) + ","; output = output + "\"LogMode\":" + String(loggingMode) + ","; - output = output + "\"Sleep\":" + String(powerSaveMode) + "}},"; + output = output + "\"Sleep\":" + String(powerSaveMode) + ","; + output = output + "\"SysConfigUID\":" + String(configManager.updateSystemConfigurationUid()) + ","; + output = output + "\"SensorConfigUID\":" + String(configManager.updateSensorConfigurationUid()) + "}},"; //FIX! Add support for device name uint8_t deviceCount = 0; //Used to keep track of how many devices have been appended @@ -1346,15 +1348,17 @@ int setNodeID(String nodeID) int updateConfiguration(String configJson) { if(configJson == "remove") { - // Remove the configuration file from the SD card - if (!fileSys.removeFileFromSD("config.json")) { - Serial.println("Error: Failed to remove configuration from SD card."); - return -1; // Failed to remove config - } - else { - Serial.println("Configuration removed from SD card."); + // Remove the configuration file from the SD card and clear EEPROM + bool sdRemoved = fileSys.removeFileFromSD("config.json"); + configManager.clearConfigEEPROM(); + + if (sdRemoved) { + Serial.println("Configuration removed from SD card and EEPROM."); + return 0; // Success + } else { + Serial.println("Warning: Failed to remove SD config, but EEPROM cleared."); + return 0; // Still success since EEPROM was cleared } - return 0; // Success } Serial.println("Updating configuration..."); @@ -1382,24 +1386,30 @@ int updateConfiguration(String configJson) { return -4; // Invalid format } - // test write to SD card - if (!fileSys.writeToSD("", "config.json")) { - Serial.println("Error: Failed to write to SD card."); - return -5; // Failed to write config + // First, try to parse and apply the configuration (this will also save to EEPROM) + bool configParsed = configManager.setConfiguration(configJson.c_str()); + if (!configParsed) { + Serial.println("Error: Failed to parse configuration."); + return -8; // Failed to parse config } + Serial.println("Configuration parsed successfully and saved to EEPROM."); - //clear current config.json - if(!fileSys.removeFileFromSD("config.json")) { - Serial.println("Error: Failed to remove current configuration from SD card."); - return -6; // Failed to remove current config - } + // Try to write to SD card (optional - don't fail if this doesn't work) + bool sdSuccess = false; + + // Remove old config first + fileSys.removeFileFromSD("config.json"); // Don't check return value // Write new configuration to SD card - if (!fileSys.writeToSD(configJson.c_str(), "config.json")) { - Serial.println("Error: Failed to write new configuration to SD card."); - return -7; // Failed to write new config + if (fileSys.writeToSD(configJson.c_str(), "config.json")) { + Serial.println("Configuration written to SD card."); + sdSuccess = true; + } else { + Serial.println("Warning: Failed to write configuration to SD card, but EEPROM backup is available."); } + // Success if config was parsed (EEPROM updated), regardless of SD card status + Serial.println("Configuration update completed. System will restart to apply changes."); System.reset(); //restart the system to apply new configuration return 1; //Success } @@ -1524,12 +1534,34 @@ bool loadConfiguration() { configLoaded = configManager.setConfiguration(configStr); } + // If SD card config failed, try EEPROM backup + if (!configLoaded) { + Serial.println("SD config failed, trying EEPROM backup..."); + configLoaded = configManager.loadConfigFromEEPROM(); + if (configLoaded) { + Serial.println("Configuration loaded from EEPROM backup"); + // Restore the config to SD card from EEPROM backup + std::string eepromConfig = configManager.getConfiguration(); + if (fileSys.writeToSD(eepromConfig.c_str(), "config.json")) { + Serial.println("EEPROM config restored to SD card"); + } else { + Serial.println("Warning: Could not restore config to SD card"); + } + } + } + + // If both SD and EEPROM failed, use defaults if (!configLoaded) { Serial.println("Loading default configuration..."); std::string defaultConfig = configManager.getDefaultConfigurationJson(); configLoaded = configManager.setConfiguration(defaultConfig); - fileSys.removeFileFromSD("config.json"); - fileSys.writeToSD(defaultConfig.c_str(), "config.json"); + // Only write default config to SD if no config file exists, not if parsing failed + if (configStr.empty()) { + Serial.println("No config file found, writing default config to SD card..."); + fileSys.writeToSD(defaultConfig.c_str(), "config.json"); + } else { + Serial.println("Config file exists but parsing failed, keeping existing file on SD card"); + } } // Set global variables from configuration diff --git a/src/configuration/ConfigurationManager.cpp b/src/configuration/ConfigurationManager.cpp index c6ed5ea..dab33b9 100644 --- a/src/configuration/ConfigurationManager.cpp +++ b/src/configuration/ConfigurationManager.cpp @@ -6,13 +6,33 @@ */ #include "ConfigurationManager.h" + #include + #include + + #ifndef TESTING + #include "Particle.h" + #else + #include "MockParticle.h" + #endif + + // Static member definitions + const int ConfigurationManager::EEPROM_CONFIG_START; + const int ConfigurationManager::EEPROM_SYSTEM_UID_ADDR; + const int ConfigurationManager::EEPROM_SENSOR_UID_ADDR; + const int ConfigurationManager::EEPROM_CONFIG_VALID_FLAG; + const uint8_t ConfigurationManager::EEPROM_VALID_MARKER; ConfigurationManager::ConfigurationManager() {}; bool ConfigurationManager::setConfiguration(std::string config) { // Parse and apply the configuration - return parseConfiguration(config); + bool success = parseConfiguration(config); + if (success) { + // Save to EEPROM as backup + saveConfigToEEPROM(); + } + return success; } std::string ConfigurationManager::getConfiguration() { @@ -23,8 +43,22 @@ config += "\"logPeriod\":" + std::to_string(m_logPeriod) + ","; config += "\"backhaulCount\":" + std::to_string(m_backhaulCount) + ","; config += "\"powerSaveMode\":" + std::to_string(m_powerSaveMode) + ","; - config += "\"loggingMode\":" + std::to_string(m_loggingMode); - config += "}}"; + config += "\"loggingMode\":" + std::to_string(m_loggingMode) + ","; + config += "\"numAuxTalons\":" + std::to_string(m_numAuxTalons) + ","; + config += "\"numI2CTalons\":" + std::to_string(m_numI2CTalons) + ","; + config += "\"numSDI12Talons\":" + std::to_string(m_numSDI12Talons); + config += "},"; + + // Sensor configuration + config += "\"sensors\":{"; + config += "\"numET\":" + std::to_string(m_numET) + ","; + config += "\"numHaar\":" + std::to_string(m_numHaar) + ","; + config += "\"numSoil\":" + std::to_string(m_numSoil) + ","; + config += "\"numApogeeSolar\":" + std::to_string(m_numApogeeSolar) + ","; + config += "\"numCO2\":" + std::to_string(m_numCO2) + ","; + config += "\"numO2\":" + std::to_string(m_numO2) + ","; + config += "\"numPressure\":" + std::to_string(m_numPressure); + config += "}}}"; return config; } @@ -55,12 +89,18 @@ bool ConfigurationManager::parseConfiguration(const std::string& configStr) { + // Remove all whitespace characters from the config string + std::string cleanedConfig = configStr; + cleanedConfig.erase(std::remove_if(cleanedConfig.begin(), cleanedConfig.end(), + [](char c) { return std::isspace(c); }), + cleanedConfig.end()); + // Find the system configuration section - size_t systemStart = configStr.find("\"system\":{"); + size_t systemStart = cleanedConfig.find("\"system\":{"); if (systemStart != std::string::npos) { - size_t systemEnd = findMatchingBracket(configStr, systemStart + 9); + size_t systemEnd = findMatchingBracket(cleanedConfig, systemStart + 9); if (systemEnd > 0) { - std::string systemJson = configStr.substr(systemStart + 9, systemEnd - (systemStart + 9)); + std::string systemJson = cleanedConfig.substr(systemStart + 9, systemEnd - (systemStart + 9)); // Parse system settings m_logPeriod = extractJsonIntField(systemJson, "logPeriod", 300); @@ -75,11 +115,11 @@ } } // Find the sensor configuration section - size_t sensorsStart = configStr.find("\"sensors\":{"); + size_t sensorsStart = cleanedConfig.find("\"sensors\":{"); if (sensorsStart != std::string::npos) { - size_t sensorsEnd = findMatchingBracket(configStr, sensorsStart + 10); + size_t sensorsEnd = findMatchingBracket(cleanedConfig, sensorsStart + 10); if (sensorsEnd > 0) { - std::string sensorsJson = configStr.substr(sensorsStart + 10, sensorsEnd - (sensorsStart + 10)); + std::string sensorsJson = cleanedConfig.substr(sensorsStart + 10, sensorsEnd - (sensorsStart + 10)); // Parse sensor settings m_numET = extractJsonIntField(sensorsJson, "numET", 0); @@ -201,4 +241,59 @@ std::unique_ptr ConfigurationManager::createETSensor(ITimeProvider& timeP std::unique_ptr ConfigurationManager::createPressureSensor(SDI12Talon& talon) { return std::make_unique(talon, 0, 0x00); // Default ports and version +} + +// EEPROM backup functionality +bool ConfigurationManager::saveConfigToEEPROM() { + // Write system and sensor UIDs (they encode all the config values) + EEPROM.put(EEPROM_SYSTEM_UID_ADDR, m_SystemConfigUid); + EEPROM.put(EEPROM_SENSOR_UID_ADDR, m_SensorConfigUid); + + // Write valid flag to indicate EEPROM contains valid config + EEPROM.put(EEPROM_CONFIG_VALID_FLAG, EEPROM_VALID_MARKER); + + return true; +} + +bool ConfigurationManager::loadConfigFromEEPROM() { + // Check if EEPROM contains valid configuration + uint8_t validFlag; + EEPROM.get(EEPROM_CONFIG_VALID_FLAG, validFlag); + if (validFlag != EEPROM_VALID_MARKER) { + return false; // No valid config in EEPROM + } + + // Read UIDs from EEPROM + int systemUid, sensorUid; + EEPROM.get(EEPROM_SYSTEM_UID_ADDR, systemUid); + EEPROM.get(EEPROM_SENSOR_UID_ADDR, sensorUid); + + // Decode system UID back to configuration values (reverse of updateSystemConfigurationUid) + m_logPeriod = (systemUid >> 16) & 0xFFFF; + m_backhaulCount = (systemUid >> 12) & 0xF; + m_powerSaveMode = (systemUid >> 10) & 0x3; + m_loggingMode = (systemUid >> 8) & 0x3; + m_numAuxTalons = (systemUid >> 6) & 0x3; + m_numI2CTalons = (systemUid >> 4) & 0x3; + m_numSDI12Talons = (systemUid >> 2) & 0x3; + + // Decode sensor UID back to configuration values (reverse of updateSensorConfigurationUid) + m_numET = (sensorUid >> 28) & 0xF; + m_numHaar = (sensorUid >> 24) & 0xF; + m_numSoil = (sensorUid >> 20) & 0xF; + m_numApogeeSolar = (sensorUid >> 16) & 0xF; + m_numCO2 = (sensorUid >> 12) & 0xF; + m_numO2 = (sensorUid >> 8) & 0xF; + m_numPressure = (sensorUid >> 4) & 0xF; + + // Store the UIDs + m_SystemConfigUid = systemUid; + m_SensorConfigUid = sensorUid; + + return true; +} + +void ConfigurationManager::clearConfigEEPROM() { + // Clear the valid flag to invalidate EEPROM config + EEPROM.put(EEPROM_CONFIG_VALID_FLAG, (uint8_t)0x00); } \ No newline at end of file diff --git a/src/configuration/ConfigurationManager.h b/src/configuration/ConfigurationManager.h index a94ea9b..28fcd5d 100644 --- a/src/configuration/ConfigurationManager.h +++ b/src/configuration/ConfigurationManager.h @@ -51,6 +51,11 @@ class ConfigurationManager : public IConfiguration { int updateSystemConfigurationUid() override; int updateSensorConfigurationUid() override; + // EEPROM backup methods + bool saveConfigToEEPROM(); + bool loadConfigFromEEPROM(); + void clearConfigEEPROM(); + // System configuration getters unsigned long getLogPeriod() const { return m_logPeriod; } int getBackhaulCount() const { return m_backhaulCount; } @@ -83,6 +88,13 @@ class ConfigurationManager : public IConfiguration { static std::unique_ptr createPressureSensor(SDI12Talon& talon); private: + // EEPROM addresses for configuration backup + static const int EEPROM_CONFIG_START = 16; // Start at address 16 (after accel offsets at 0-11) + static const int EEPROM_SYSTEM_UID_ADDR = EEPROM_CONFIG_START; + static const int EEPROM_SENSOR_UID_ADDR = EEPROM_CONFIG_START + 4; + static const int EEPROM_CONFIG_VALID_FLAG = EEPROM_CONFIG_START + 8; + static const uint8_t EEPROM_VALID_MARKER = 0xAB; // Magic number to validate EEPROM data + // System configuration unsigned long m_logPeriod; int m_backhaulCount; From 5a608d4d15030964388300a66de3a407247c2f90 Mon Sep 17 00:00:00 2001 From: Zach Radlicz Date: Thu, 7 Aug 2025 15:13:53 -0500 Subject: [PATCH 2/5] dumping working but wrtiting is work in progress --- src/FlightControl.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/FlightControl.cpp b/src/FlightControl.cpp index ee325ca..9361487 100644 --- a/src/FlightControl.cpp +++ b/src/FlightControl.cpp @@ -1076,6 +1076,40 @@ void systemConfig() Serial.println("\tDone"); } + if(ReadString.equalsIgnoreCase("Dump SD")) { + fileSys.dumpSDOverSerial(); + Serial.println("\tDump Complete"); + } + + if(ReadString.startsWith("Dump SD Recent ")) { + String countStr = ReadString.substring(15); // "Dump SD Recent " is 15 chars + uint32_t count = countStr.toInt(); + if (count > 0) { + fileSys.dumpSDOverSerial(count); + Serial.print("\tDump Complete ("); + Serial.print(count); + Serial.println(" recent files per type)"); + } else { + Serial.println("\tInvalid count parameter"); + } + } + + if(ReadString.startsWith("Write SD ")) { + String filename = ReadString.substring(9); // "Write SD " is 9 chars + filename.trim(); + if (filename.length() > 0) { + Serial.print("\tStarting file write: "); + Serial.println(filename); + if (fileSys.writeFileOverSerial(filename.c_str())) { + Serial.println("\tWrite Complete"); + } else { + Serial.println("\tWrite Failed"); + } + } else { + Serial.println("\tInvalid filename parameter"); + } + } + if(ReadString.equalsIgnoreCase("Exit")) { return; //Exit the setup function } From 7e1b0a693fa05b910332a3c42080809363437115 Mon Sep 17 00:00:00 2001 From: Zach Radlicz Date: Thu, 7 Aug 2025 15:20:16 -0500 Subject: [PATCH 3/5] updated submod --- lib/Driver_-_Kestrel-FileHandler | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Driver_-_Kestrel-FileHandler b/lib/Driver_-_Kestrel-FileHandler index 1b5db1a..5fb183d 160000 --- a/lib/Driver_-_Kestrel-FileHandler +++ b/lib/Driver_-_Kestrel-FileHandler @@ -1 +1 @@ -Subproject commit 1b5db1ad66baeced2e2c0356a2a5e72ee600478a +Subproject commit 5fb183d700ba903b5010e506e0f2a75c8b0fc8f7 From 59c364a083eb502e6fd490a1325be29a4c6017fa Mon Sep 17 00:00:00 2001 From: Zach Radlicz Date: Thu, 14 Aug 2025 11:13:46 -0500 Subject: [PATCH 4/5] pointed submods to master after their respective PRs --- lib/Driver_-_Kestrel | 2 +- lib/Driver_-_Kestrel-FileHandler | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Driver_-_Kestrel b/lib/Driver_-_Kestrel index 6ee4e49..68d1534 160000 --- a/lib/Driver_-_Kestrel +++ b/lib/Driver_-_Kestrel @@ -1 +1 @@ -Subproject commit 6ee4e491ed35d5edfee389d9641acc1ffe85bead +Subproject commit 68d153472bca9323b616245c0960c75d8c890c8f diff --git a/lib/Driver_-_Kestrel-FileHandler b/lib/Driver_-_Kestrel-FileHandler index 5fb183d..dfe304a 160000 --- a/lib/Driver_-_Kestrel-FileHandler +++ b/lib/Driver_-_Kestrel-FileHandler @@ -1 +1 @@ -Subproject commit 5fb183d700ba903b5010e506e0f2a75c8b0fc8f7 +Subproject commit dfe304a21672631ceb54c8968a70734b9a7efbc3 From c0ec67dfdddbe027ed2a518834b9fbb0170810f4 Mon Sep 17 00:00:00 2001 From: Zach Radlicz Date: Thu, 14 Aug 2025 12:00:17 -0500 Subject: [PATCH 5/5] updated workflow for new projects --- .github/workflows/release-workflow.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-workflow.yaml b/.github/workflows/release-workflow.yaml index cdd288a..8814313 100644 --- a/.github/workflows/release-workflow.yaml +++ b/.github/workflows/release-workflow.yaml @@ -181,14 +181,18 @@ jobs: product_id_secret: "PARTICLE_WINTERTURF_PRODUCT_ID" - name: "Plant Pathways" product_id_secret: "PARTICLE_PLANT_PATHWAYS_PRODUCT_ID" - - name: "LCCMR Irrigation" - product_id_secret: "PARTICLE_LCCMR_IRRIGATION_PRODUCT_ID" + - name: "Sharma V3 Irrigation" + product_id_secret: "PARTICLE_SHARMA_V3_IRRIGATION_PRODUCT_ID" - name: "Roadside Turf" product_id_secret: "PARTICLE_ROADSIDE_TURF_PRODUCT_ID" - name: "PepsiCo" product_id_secret: "PARTICLE_PEPSICO_PRODUCT_ID" - name: "Precision Ag Pilot" product_id_secret: "PARTICLE_PRECISION_AG_PILOT_PRODUCT_ID" + - name: "Turf Disease Pilot" + product_id_secret: "PARTICLE_TURF_DISEASE_PILOT_PRODUCT_ID" + - name: "Legacy Irrigation" + product_id_secret: "PARTICLE_LEGACY_IRRIGATION_PRODUCT_ID" steps: - name: Checkout code uses: actions/checkout@v4