|
| 1 | +# API Contract: Device Import Endpoint |
| 2 | + |
| 3 | +**Endpoint**: `POST /api/admin/devices/_import` |
| 4 | +**Purpose**: Import devices from CSV file |
| 5 | +**Status**: No changes - this documents existing behavior with bug fix applied |
| 6 | + |
| 7 | +## Request |
| 8 | + |
| 9 | +### HTTP Method |
| 10 | +`POST` |
| 11 | + |
| 12 | +### Headers |
| 13 | +``` |
| 14 | +Content-Type: multipart/form-data |
| 15 | +Authorization: Bearer {jwt_token} |
| 16 | +``` |
| 17 | + |
| 18 | +### Body |
| 19 | +Form data with single file upload: |
| 20 | +- **Field name**: `file` |
| 21 | +- **File type**: CSV (text/csv) |
| 22 | +- **Max size**: (configured by server) |
| 23 | + |
| 24 | +### CSV Format |
| 25 | + |
| 26 | +**Mandatory columns**: |
| 27 | +- `Id`: Device identifier (16 hex characters, uppercase) |
| 28 | +- `Name`: Device name (free text) |
| 29 | +- `ModelId`: Device model GUID |
| 30 | + |
| 31 | +**Optional tag columns** (format: `TAG:{tagName}`): |
| 32 | +- `TAG:supportLoRaFeatures`: "true" for LoRaWAN devices, empty/"false" for standard |
| 33 | +- `TAG:{anyCustomTag}`: Custom device tags from device tag configuration |
| 34 | + |
| 35 | +**Optional LoRaWAN property columns** (format: `PROPERTY:{propertyName}`): |
| 36 | + |
| 37 | +Authentication (OTAA): |
| 38 | +- `PROPERTY:AppKey`: Application Key (hex string) |
| 39 | +- `PROPERTY:AppEUI`: Application EUI (hex string) |
| 40 | + |
| 41 | +Authentication (ABP): |
| 42 | +- `PROPERTY:AppSKey`: Application Session Key (hex string) |
| 43 | +- `PROPERTY:NwkSKey`: Network Session Key (hex string) |
| 44 | +- `PROPERTY:DevAddr`: Device Address (hex string) |
| 45 | + |
| 46 | +Connection: |
| 47 | +- `PROPERTY:GatewayID`: Preferred gateway identifier |
| 48 | + |
| 49 | +Configuration: |
| 50 | +- `PROPERTY:ClassType`: LoRaWAN class ("A", "B", or "C") |
| 51 | +- `PROPERTY:PreferredWindow`: Preferred receive window (1 or 2) |
| 52 | +- `PROPERTY:Deduplication`: Deduplication mode ("None", "Drop", or "Mark") |
| 53 | +- `PROPERTY:Downlink`: Enable downlinks ("true" or "false") |
| 54 | + |
| 55 | +Advanced OTAA Settings: |
| 56 | +- `PROPERTY:RX1DROffset`: RX1 data rate offset (integer 0-7) |
| 57 | +- `PROPERTY:RX2DataRate`: RX2 data rate (integer 0-15) |
| 58 | +- `PROPERTY:RXDelay`: RX delay in seconds (integer 0-15) |
| 59 | + |
| 60 | +Advanced ABP Settings: |
| 61 | +- `PROPERTY:ABPRelaxMode`: Enable ABP relaxed mode ("true" or "false") |
| 62 | +- `PROPERTY:FCntUpStart`: Uplink frame counter start (integer) |
| 63 | +- `PROPERTY:FCntDownStart`: Downlink frame counter start (integer) |
| 64 | +- `PROPERTY:FCntResetCounter`: Frame counter reset value (integer) |
| 65 | +- `PROPERTY:Supports32BitFCnt`: Support 32-bit frame counters ("true" or "false") |
| 66 | + |
| 67 | +Other: |
| 68 | +- `PROPERTY:KeepAliveTimeout`: Keep-alive timeout in seconds (integer) |
| 69 | +- `PROPERTY:SensorDecoder`: Sensor decoder API URL (string) |
| 70 | + |
| 71 | +### Example CSV Content |
| 72 | +```csv |
| 73 | +Id,Name,ModelId,TAG:supportLoRaFeatures,TAG:location,PROPERTY:AppKey,PROPERTY:AppEUI,PROPERTY:ClassType,PROPERTY:PreferredWindow,PROPERTY:Downlink |
| 74 | +ABCD1234ABCD1234,Sensor-01,550e8400-e29b-41d4-a716-446655440000,true,Building-A,FEDCBA9876543210FEDCBA9876543210,0102030405060708,A,1,true |
| 75 | +EFGH5678EFGH5678,Sensor-02,550e8400-e29b-41d4-a716-446655440000,true,Building-B,0123456789ABCDEF0123456789ABCDEF,0807060504030201,C,2,false |
| 76 | +``` |
| 77 | + |
| 78 | +## Response |
| 79 | + |
| 80 | +### Success Response (200 OK) |
| 81 | +```json |
| 82 | +{ |
| 83 | + "message": "Import completed", |
| 84 | + "totalProcessed": 2, |
| 85 | + "errors": [] |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +Or with partial success: |
| 90 | +```json |
| 91 | +{ |
| 92 | + "message": "Import completed with errors", |
| 93 | + "totalProcessed": 5, |
| 94 | + "errors": [ |
| 95 | + { |
| 96 | + "lineNumber": 3, |
| 97 | + "deviceId": "BADDEVICE123456", |
| 98 | + "isErrorMessage": true, |
| 99 | + "message": "The parameter Id cannot be null or empty" |
| 100 | + }, |
| 101 | + { |
| 102 | + "lineNumber": 7, |
| 103 | + "deviceId": "1234567890ABCDEF", |
| 104 | + "isErrorMessage": true, |
| 105 | + "message": "The device identifier must contain 16 hexadecimal characters" |
| 106 | + } |
| 107 | + ] |
| 108 | +} |
| 109 | +``` |
| 110 | + |
| 111 | +**Response Model**: |
| 112 | +```csharp |
| 113 | +public class ImportResultLine |
| 114 | +{ |
| 115 | + public string DeviceId { get; set; } |
| 116 | + public int LineNumber { get; set; } |
| 117 | + public bool IsErrorMessage { get; set; } |
| 118 | + public string Message { get; set; } |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +### Error Responses |
| 123 | + |
| 124 | +**400 Bad Request** - Invalid file format |
| 125 | +```json |
| 126 | +{ |
| 127 | + "title": "Bad Request", |
| 128 | + "status": 400, |
| 129 | + "detail": "Invalid file format: The submitted file should be a comma-separated values (CSV) file. A template file showing the mandatory fields is available to download on the portal." |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +**401 Unauthorized** - Missing or invalid authentication |
| 134 | +```json |
| 135 | +{ |
| 136 | + "title": "Unauthorized", |
| 137 | + "status": 401 |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +**403 Forbidden** - User lacks required permission |
| 142 | +```json |
| 143 | +{ |
| 144 | + "title": "Forbidden", |
| 145 | + "status": 403, |
| 146 | + "detail": "User does not have permission to import devices" |
| 147 | +} |
| 148 | +``` |
| 149 | + |
| 150 | +## Validation Rules |
| 151 | + |
| 152 | +### Device ID Validation |
| 153 | +- **Required**: Yes |
| 154 | +- **Format**: Exactly 16 hexadecimal characters (0-9, A-F) |
| 155 | +- **Regex**: `^[A-Z0-9]{16}$` |
| 156 | +- **Error**: "The parameter Id cannot be null or empty" or "The device identifier must contain 16 hexadecimal characters" |
| 157 | + |
| 158 | +### Device Name Validation |
| 159 | +- **Required**: Yes |
| 160 | +- **Error**: "The parameter Name cannot be null or empty" |
| 161 | + |
| 162 | +### Model ID Validation |
| 163 | +- **Required**: Yes |
| 164 | +- **Must exist**: Must reference an existing device model in the database |
| 165 | +- **Error**: "The parameter ModelId cannot be null or empty" or "Device model not found" |
| 166 | + |
| 167 | +### LoRaWAN Authentication Validation |
| 168 | +For LoRaWAN devices (`TAG:supportLoRaFeatures = true`): |
| 169 | +- **OTAA mode**: Requires `PROPERTY:AppKey` AND `PROPERTY:AppEUI` |
| 170 | +- **ABP mode**: Requires `PROPERTY:AppSKey` AND `PROPERTY:NwkSKey` AND `PROPERTY:DevAddr` |
| 171 | +- **Detection**: If AppKey and AppEUI are provided → OTAA; if empty → ABP mode expected |
| 172 | + |
| 173 | +### Property Type Validation |
| 174 | +- **ClassType**: Must be "A", "B", or "C" (case-sensitive) |
| 175 | +- **Deduplication**: Must be "None", "Drop", or "Mark" |
| 176 | +- **Boolean fields**: "true"/"false" (case-insensitive) |
| 177 | +- **Integer fields**: Valid integer within range (e.g., RX1DROffset: 0-7) |
| 178 | + |
| 179 | +## Behavior Changes (Bug Fix) |
| 180 | + |
| 181 | +### Before Fix |
| 182 | +- Only authentication properties were persisted to Azure IoT Hub |
| 183 | +- Configuration properties (ClassType, PreferredWindow, Deduplication, etc.) were stored in local database only |
| 184 | +- Synchronization job would overwrite local database with incomplete data from IoT Hub |
| 185 | +- **Result**: Imported configuration properties were lost after sync |
| 186 | + |
| 187 | +### After Fix |
| 188 | +- **All LoRaWAN properties** are persisted to Azure IoT Hub as desired properties |
| 189 | +- Local database remains in sync with IoT Hub |
| 190 | +- Synchronization job refreshes local database from IoT Hub without data loss |
| 191 | +- **Result**: Imported configuration properties are preserved |
| 192 | + |
| 193 | +## Security Considerations |
| 194 | + |
| 195 | +### Authorization |
| 196 | +- Requires authenticated user with `AdminRole` permission |
| 197 | +- Policy: `Policies.AdminAccess` (defined in `IoTHub.Portal.Shared.Security.Policies`) |
| 198 | + |
| 199 | +### Data Validation |
| 200 | +- All CSV input is validated before processing |
| 201 | +- Invalid rows are logged but do not stop processing of valid rows |
| 202 | +- Error messages do not expose sensitive system information |
| 203 | + |
| 204 | +### Rate Limiting |
| 205 | +- (Not currently implemented - consider for production) |
| 206 | + |
| 207 | +## Related Endpoints |
| 208 | + |
| 209 | +### Export Devices |
| 210 | +`GET /api/admin/devices/_export` - Export all devices to CSV |
| 211 | + |
| 212 | +### Download Template |
| 213 | +`GET /api/admin/devices/_template` - Download CSV template with headers |
| 214 | + |
| 215 | +## Testing Contract |
| 216 | + |
| 217 | +### Happy Path Test Cases |
| 218 | +1. **New LoRaWAN device (OTAA)**: All required fields + all optional LoRaWAN fields |
| 219 | +2. **New LoRaWAN device (ABP)**: ABP auth fields + configuration fields |
| 220 | +3. **Update existing device**: Same device ID with new configuration |
| 221 | +4. **Mixed standard and LoRaWAN**: CSV with both device types |
| 222 | + |
| 223 | +### Error Test Cases |
| 224 | +1. **Missing required field**: Id, Name, or ModelId empty |
| 225 | +2. **Invalid device ID format**: Less than 16 chars, invalid characters |
| 226 | +3. **Invalid enum value**: ClassType = "D", Deduplication = "Invalid" |
| 227 | +4. **Incomplete authentication**: AppKey without AppEUI |
| 228 | +5. **Invalid file format**: Not a CSV, missing headers |
| 229 | + |
| 230 | +### Edge Cases |
| 231 | +1. **Empty CSV**: Only headers, no data rows |
| 232 | +2. **CSV with only old columns**: No new LoRaWAN configuration columns (should use defaults) |
| 233 | +3. **CSV with extra unknown columns**: Should be ignored |
| 234 | +4. **Duplicate device IDs in CSV**: Last entry wins (update behavior) |
| 235 | + |
| 236 | +## Notes |
| 237 | + |
| 238 | +- The import is idempotent: importing the same device twice updates it |
| 239 | +- Existing devices are updated, not replaced (version tracking maintained) |
| 240 | +- Tags and labels are completely replaced on update (delete + recreate) |
| 241 | +- Device Twin version in IoT Hub is incremented on each update |
| 242 | +- Import is transactional per-device (one device failure doesn't rollback others) |
| 243 | + |
| 244 | +## Change Log |
| 245 | + |
| 246 | +**2026-01-30**: Bug fix - Added persistence of all LoRaWAN configuration properties to IoT Hub during import |
0 commit comments