Skip to content

Fix: Preserve CSV-imported device data during sync#3295

Merged
kbeaugrand merged 6 commits intomainfrom
copilot/fix-device-import-data-issue
Jan 31, 2026
Merged

Fix: Preserve CSV-imported device data during sync#3295
kbeaugrand merged 6 commits intomainfrom
copilot/fix-device-import-data-issue

Conversation

Copy link
Contributor

Copilot AI commented Jan 30, 2026

Description

CSV-imported LoRaWAN device properties were overwritten during periodic IoT Hub synchronization. The sync job used AutoMapper's blind mapper.Map(), which overwrote all entity properties—including those never sent to IoT Hub (e.g., SensorDecoder, ClassType, Deduplication, RX1DROffset, KeepAliveTimeout).

Changes:

  • SyncDevicesJob: Replace unconditional mapping with selective property updates
    • Core properties (Name, Version, connectivity): always update from Twin
    • LoRaWAN config properties: update only if present in Twin.Properties.Desired
    • Reported properties: update only if present in Twin.Properties.Reported
    • Derived properties (UseOTAA, AlreadyLoggedInOnce): update based on source property existence

Example:

// Before: overwrites all properties unconditionally
_ = this.mapper.Map(lorawanDevice, lorawanDeviceEntity);

// After: preserves database values for missing Twin properties
if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.SensorDecoder)))
    lorawanDeviceEntity.SensorDecoder = lorawanDevice.SensorDecoder;

Test coverage: Added Execute_ExistingLorawanDeviceWithMissingTwinProperties_DatabaseValuesPreserved validating preservation of database values when properties absent from Twin.

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • Tests
  • Other
Original prompt

This section details on the original issue you should resolve

<issue_title>Bug: Import device - data overwritten</issue_title>
<issue_description><!--
Hi there! Thank you for discovering and submitting an issue!

-->

When a device is imported, only some data is added to Azure(). The device in the database is then resynchronized using its model data. Data not imported into the iothub is overwritten in the database at the time of synchronization

Expected Behavior

The portal should import only data directly uploaded to IoTHub. Data herited from device model and unused data on lorawan device should not be imported.

Current Behavior

Some data is used and overwritten because it is defined in several places. Others are overwritten but unused (because they were never uploaded to Azure).

Steps to Reproduce

  1. Import a device using csv file with all data filled.
  2. After synchronization, only some values are keeped in LoraWan: Id, Name, ModelId, assetId, locationCode, supportLoRaFeatures, AppKey, AppEUI, AppSKey, NwkSKey, DevAddr and GatewayID.

Context (Environment)

Portal version:
LoRaWAN Stack version:

Logs

Additional Information

<agent_instructions>Fix this bug</agent_instructions>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 3 commits January 30, 2026 16:40
… sync

Co-authored-by: kbeaugrand <9513635+kbeaugrand@users.noreply.github.com>
Co-authored-by: kbeaugrand <9513635+kbeaugrand@users.noreply.github.com>
Co-authored-by: kbeaugrand <9513635+kbeaugrand@users.noreply.github.com>
@kbeaugrand
Copy link
Member

@copilot review this PR

Copilot AI changed the title [WIP] Fix bug with device import data being overwritten Fix: Preserve CSV-imported device data during IoT Hub synchronization Jan 30, 2026
Copilot AI requested a review from kbeaugrand January 30, 2026 16:52
Copy link
Contributor Author

Copilot AI commented Jan 30, 2026

@copilot review this PR

Code Review Summary

This PR successfully addresses the bug where CSV-imported device data was being overwritten during synchronization. The solution is sound and well-tested.

✅ Strengths

  1. Correct Fix: Replaces blind mapper.Map() with selective property updates based on Twin property existence
  2. Comprehensive Coverage: Handles all LoRaWAN properties (23 properties total)
  3. Derived Properties: Properly handles UseOTAA (derived from AppEUI) and AlreadyLoggedInOnce (derived from DevAddr)
  4. Good Test Coverage: New test validates preservation behavior with multiple scenarios
  5. Backward Compatible: Core properties (Name, Version, connectivity) always updated from Twin

📝 Code Quality Observations

Repetitive Code Pattern: Lines 149-238 use repetitive conditional checks (~90 lines). While functional, this could be refactored into a helper method:

private void UpdatePropertyIfExists<T>(Twin twin, string propertyName, 
    Func<LorawanDevice, T> getter, Action<LorawanDevice, T> setter, bool isReported = false)
{
    var properties = isReported ? twin.Properties.Reported : twin.Properties.Desired;
    if (properties.Contains(propertyName))
        setter(lorawanDeviceEntity, getter(lorawanDevice));
}

However, this refactoring is optional - the current explicit approach is clear and maintainable.

✅ Testing

Test Execute_ExistingLorawanDeviceWithMissingTwinProperties_DatabaseValuesPreserved properly validates:

  • Core properties update from Twin
  • Properties in Twin are updated
  • Properties NOT in Twin are preserved
  • Derived properties handled correctly

Recommendation

Approve - The fix correctly solves the stated problem. The repetitive code is acceptable for clarity and the explicit nature makes debugging easier. No changes required.

Co-authored-by: kbeaugrand <9513635+kbeaugrand@users.noreply.github.com>
Copilot AI changed the title Fix: Preserve CSV-imported device data during IoT Hub synchronization Fix: Preserve CSV-imported device data during sync Jan 30, 2026
@kbeaugrand kbeaugrand marked this pull request as ready for review January 31, 2026 07:43
@kbeaugrand kbeaugrand requested a review from a team as a code owner January 31, 2026 07:43
Copilot AI review requested due to automatic review settings January 31, 2026 07:43
@kbeaugrand kbeaugrand merged commit 9c2ef5e into main Jan 31, 2026
0 of 2 checks passed
@kbeaugrand kbeaugrand deleted the copilot/fix-device-import-data-issue branch January 31, 2026 07:43
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request fixes a bug where CSV-imported LoRaWAN device properties were being overwritten during periodic IoT Hub synchronization. The previous implementation used AutoMapper's blind mapper.Map() which overwrote all entity properties, including those never sent to IoT Hub.

Changes:

  • Modified SyncDevicesJob to use selective property updates instead of unconditional mapping
  • Added test coverage for the preservation of database values when properties are absent from Twin
  • Added an unrelated bash script for spec-driven development workflow

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/IoTHub.Portal.Infrastructure/Jobs/SyncDevicesJob.cs Replaced AutoMapper blind mapping with selective property updates based on Twin property presence
src/IoTHub.Portal.Tests.Unit/Infrastructure/Jobs/SyncDevicesJobTests.cs Added test to verify database values are preserved when properties are absent from Twin
.specify/scripts/bash/check-prerequisites.sh Added workflow script for spec-driven development (unrelated to the bug fix)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +137 to +238
// Update only properties that are present in the Twin
// Base device properties (always updated from Twin)
lorawanDeviceEntity.Name = lorawanDevice.Name;
lorawanDeviceEntity.DeviceModelId = lorawanDevice.DeviceModelId;
lorawanDeviceEntity.Version = lorawanDevice.Version;
lorawanDeviceEntity.IsConnected = lorawanDevice.IsConnected;
lorawanDeviceEntity.IsEnabled = lorawanDevice.IsEnabled;
lorawanDeviceEntity.StatusUpdatedTime = lorawanDevice.StatusUpdatedTime;
lorawanDeviceEntity.LastActivityTime = lorawanDevice.LastActivityTime;
lorawanDeviceEntity.LayerId = lorawanDevice.LayerId;
lorawanDeviceEntity.Tags = lorawanDevice.Tags;

// Update LoRaWAN properties only if they exist in the Twin's desired properties
// OTAA/ABP authentication settings
if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.AppEUI)))
lorawanDeviceEntity.AppEUI = lorawanDevice.AppEUI;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.AppKey)))
lorawanDeviceEntity.AppKey = lorawanDevice.AppKey;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.AppSKey)))
lorawanDeviceEntity.AppSKey = lorawanDevice.AppSKey;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.NwkSKey)))
lorawanDeviceEntity.NwkSKey = lorawanDevice.NwkSKey;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.DevAddr)))
lorawanDeviceEntity.DevAddr = lorawanDevice.DevAddr;

// Update UseOTAA based on AppEUI presence in Twin
// Only update if AppEUI exists in Twin to avoid overwriting database value
if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.AppEUI)))
lorawanDeviceEntity.UseOTAA = lorawanDevice.UseOTAA;

// Other LoRaWAN configuration properties (only update if present in Twin)
if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.SensorDecoder)))
lorawanDeviceEntity.SensorDecoder = lorawanDevice.SensorDecoder;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.ClassType)))
lorawanDeviceEntity.ClassType = lorawanDevice.ClassType;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.PreferredWindow)))
lorawanDeviceEntity.PreferredWindow = lorawanDevice.PreferredWindow;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.Deduplication)))
lorawanDeviceEntity.Deduplication = lorawanDevice.Deduplication;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.RX1DROffset)))
lorawanDeviceEntity.RX1DROffset = lorawanDevice.RX1DROffset;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.RX2DataRate)))
lorawanDeviceEntity.RX2DataRate = lorawanDevice.RX2DataRate;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.RXDelay)))
lorawanDeviceEntity.RXDelay = lorawanDevice.RXDelay;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.ABPRelaxMode)))
lorawanDeviceEntity.ABPRelaxMode = lorawanDevice.ABPRelaxMode;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.FCntUpStart)))
lorawanDeviceEntity.FCntUpStart = lorawanDevice.FCntUpStart;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.FCntDownStart)))
lorawanDeviceEntity.FCntDownStart = lorawanDevice.FCntDownStart;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.FCntResetCounter)))
lorawanDeviceEntity.FCntResetCounter = lorawanDevice.FCntResetCounter;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.Supports32BitFCnt)))
lorawanDeviceEntity.Supports32BitFCnt = lorawanDevice.Supports32BitFCnt;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.KeepAliveTimeout)))
lorawanDeviceEntity.KeepAliveTimeout = lorawanDevice.KeepAliveTimeout;

if (twin.Properties.Desired.Contains(nameof(LoRaDeviceDetails.Downlink)))
lorawanDeviceEntity.Downlink = lorawanDevice.Downlink;

// Update reported properties only if they exist in Twin (as they come from the device)
// AlreadyLoggedInOnce is set based on DevAddr presence in reported properties
if (twin.Properties.Reported.Contains("DevAddr"))
lorawanDeviceEntity.AlreadyLoggedInOnce = lorawanDevice.AlreadyLoggedInOnce;

if (twin.Properties.Reported.Contains(nameof(LoRaDeviceDetails.GatewayID)))
lorawanDeviceEntity.GatewayID = lorawanDevice.GatewayID;

if (twin.Properties.Reported.Contains(nameof(LoRaDeviceDetails.DataRate)))
lorawanDeviceEntity.DataRate = lorawanDevice.DataRate;

if (twin.Properties.Reported.Contains(nameof(LoRaDeviceDetails.TxPower)))
lorawanDeviceEntity.TxPower = lorawanDevice.TxPower;

if (twin.Properties.Reported.Contains(nameof(LoRaDeviceDetails.NbRep)))
lorawanDeviceEntity.NbRep = lorawanDevice.NbRep;

if (twin.Properties.Reported.Contains(nameof(LoRaDeviceDetails.ReportedRX2DataRate)))
lorawanDeviceEntity.ReportedRX2DataRate = lorawanDevice.ReportedRX2DataRate;

if (twin.Properties.Reported.Contains(nameof(LoRaDeviceDetails.ReportedRX1DROffset)))
lorawanDeviceEntity.ReportedRX1DROffset = lorawanDevice.ReportedRX1DROffset;

if (twin.Properties.Reported.Contains(nameof(LoRaDeviceDetails.ReportedRXDelay)))
lorawanDeviceEntity.ReportedRXDelay = lorawanDevice.ReportedRXDelay;
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mapper's Twin->LorawanDevice mapping uses helper methods like GetDesiredPropertyAsIntegerValue and GetDesiredPropertyAsEnum that return nullable values. When these properties are not present in the Twin, the mapper would set them to null or default values. However, the new implementation only updates properties if they're present in the Twin. This means that if a property WAS in the Twin before but is now removed, it won't be cleared from the database - it will keep the old value. Consider whether properties should be explicitly set to null/default when they're removed from the Twin, or document this as expected behavior.

Copilot uses AI. Check for mistakes.
d.RX1DROffset == 5 &&
d.KeepAliveTimeout == 120 &&
d.GatewayID == "ExistingGatewayID" &&
d.AlreadyLoggedInOnce == true // Preserved because DevAddr not in reported properties
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expression 'A == true' can be simplified to 'A'.

Copilot uses AI. Check for mistakes.
// Verify Twin properties are updated
d.AppKey == "NewAppKey" &&
d.AppEUI == "NewAppEUI" &&
d.UseOTAA == true && // Updated because AppEUI is in Twin
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expression 'A == true' can be simplified to 'A'.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Import device - data overwritten

3 participants