Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ For details about compatibility between different releases, see the **Commitment

- Parsing of the `rctx` field in the `upinfo` object of upstream messages received via the LoRa Basics Station LNS protocol. The field name was misspelled as `rtcx`, so the radio context reported by gateways was ignored. The antenna index in the uplink metadata now reflects the reported radio context and is echoed back in class A downlinks, instead of always being 0.
- The antenna gain sent to LoRa Basics Station gateways in the router configuration. Each board's radio configuration now uses the gain of its own antenna instead of applying the first antenna's gain to all boards, and fractional gains are no longer truncated.
- Prefilling the end device model, hardware version, firmware version and band in the Console when scanning an end device QR code that contains a LoRa Alliance vendor ID and vendor profile ID that are known in the Device Repository. Previously, only the brand was prefilled.

## [3.36.2] - 2026-08-17

Expand Down
62 changes: 62 additions & 0 deletions cypress/e2e/console/devices/onboarding/qr-scan.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,68 @@ describe('Device onboarding with QR scan', () => {
)
})

it('succeeds prefilling the end device type from the vendor profile ID', () => {
const vendorProfileId = 2
cy.intercept(
'POST',
'/api/v3/qr-codes/end-devices/parse',
composeQRGeneratorParseResponse({ ...device, vendorId: 428, vendorProfileId }),
).as('qr-code-parse-request')
cy.intercept('GET', `/api/v3/dr/vendors/428/profiles/${vendorProfileId}/template`, {
body: {
end_device: {
version_ids: {
brand_id: 'test-brand-otaa',
model_id: 'test-model3',
hardware_version: '2.0',
firmware_version: '1.0.1',
band_id: 'EU_863_870',
},
},
field_mask: { paths: ['version_ids'] },
},
}).as('profile-template-request')
cy.intercept('GET', `/api/v3/dr/applications/${appId}/brands/test-brand-otaa/models*`, {
fixture: 'console/devices/repository/test-brand-otaa.models.json',
delay: 2000,
})

cy.findByTestId('full-error-view').should('not.exist')
cy.findByTestId('error-notification').should('not.exist')

// Open qr modal and scan.
cy.findByRole('button', { name: /Scan end device QR code/g }).click()
cy.findByText('Found QR code data').should('be.visible')
cy.wait('@qr-code-parse-request')
cy.wait('@profile-template-request')
cy.findByText('test-model3').should('be.visible')
cy.findByText('Apply').should('not.be.disabled').click()

// Display scanned data in form, including the full end device type.
cy.findByText('QR code scanned successfully').should('be.visible')
cy.findByLabelText('End device brand')
.parents('.select__control')
.next('input')
.should('have.value', 'test-brand-otaa')
cy.findByLabelText('Model')
.parents('.select__control')
.next('input')
.should('have.value', 'test-model3')
cy.findByLabelText('Hardware Ver.')
.parents('.select__control')
.next('input')
.should('have.value', '2.0')
cy.findByLabelText('Firmware Ver.')
.parents('.select__control')
.next('input')
.should('have.value', '1.0.1')
cy.findByLabelText('Profile (Region)')
.parents('.select__control')
.next('input')
.should('have.value', 'EU_863_870')
cy.findByLabelText('Frequency plan').should('be.visible')
})

it('succeeds scanning again', () => {
cy.findByTestId('full-error-view').should('not.exist')
cy.findByTestId('error-notification').should('not.exist')
Expand Down
9 changes: 8 additions & 1 deletion cypress/e2e/console/devices/onboarding/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,21 @@ export const interceptDeviceRepo = appId => {
})
}

export const composeQRGeneratorParseResponse = ({ joinEui, devEui, cac, vendorId }) => ({
export const composeQRGeneratorParseResponse = ({
joinEui,
devEui,
cac,
vendorId,
vendorProfileId,
}) => ({
format_id: 'tr005',
end_device_template: {
end_device: {
ids: { dev_eui: devEui, join_eui: joinEui },
claim_authentication_code: { value: cac },
lora_alliance_profile_ids: {
vendor_id: vendorId,
...(vendorProfileId ? { vendor_profile_id: vendorProfileId } : {}),
},
},
field_mask: { paths: ['ids', 'claim_authentication_code'] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import attachPromise from '@ttn-lw/lib/store/actions/attach-promise'
import sharedMessages from '@ttn-lw/lib/shared-messages'

import { parseEndDeviceQRCode } from '@console/store/actions/qr-code-generator'
import { getTemplateByProfileIds } from '@console/store/actions/device-repository'

import { selectDeviceBrands } from '@console/store/selectors/device-repository'

Expand All @@ -38,6 +39,7 @@ const qrDataInitialState = {
approved: false,
data: undefined,
device: undefined,
versionIds: undefined,
}

const DeviceQRScanFormSection = () => {
Expand All @@ -52,20 +54,39 @@ const DeviceQRScanFormSection = () => {
}, [resetForm])

const getBrand = useCallback(
vendorId => {
const brand = brands.find(brand => brand?.lora_alliance_vendor_id === vendorId)
(vendorId, brandId) =>
brands.find(brand =>
brandId ? brand?.brand_id === brandId : brand?.lora_alliance_vendor_id === vendorId,
),
[brands],
)

return brand
// Resolve the full end device version identifiers (brand, model, hardware and firmware version
// and band) from the LoRa Alliance vendor ID and vendor profile ID, if present in the QR code.
// Not all vendors map vendor profile IDs to models, so failing to resolve is not an error.
const getVersionIds = useCallback(
async ({ vendor_id, vendor_profile_id } = {}) => {
if (!vendor_id || !vendor_profile_id) {
return undefined
}
try {
const template = await dispatch(
attachPromise(getTemplateByProfileIds(vendor_id, vendor_profile_id)),
)
return template?.end_device?.version_ids
} catch {
return undefined
}
},
[brands],
[dispatch],
)

const handleQRCodeApprove = useCallback(() => {
const { device } = qrData
const { device, versionIds } = qrData
const { end_device } = device.end_device_template
const { lora_alliance_profile_ids } = end_device
const { lora_alliance_profile_ids = {} } = end_device

const brand = getBrand(lora_alliance_profile_ids.vendor_id)
const brand = getBrand(lora_alliance_profile_ids.vendor_id, versionIds?.brand_id)

setValues(values => ({
...values,
Expand All @@ -84,10 +105,19 @@ const DeviceQRScanFormSection = () => {
: '',
join_eui: end_device.ids.join_eui,
},
version_ids: {
...values.version_ids,
brand_id: brand ? brand.brand_id : values.version_ids.brand_id,
},
version_ids:
brand && versionIds?.model_id
? {
brand_id: brand.brand_id,
model_id: versionIds.model_id,
hardware_version: versionIds.hardware_version || '',
firmware_version: versionIds.firmware_version || '',
band_id: versionIds.band_id || '',
}
: {
...values.version_ids,
brand_id: brand ? brand.brand_id : values.version_ids.brand_id,
},
}))

setQrData({ ...qrData, approved: true })
Expand All @@ -104,9 +134,10 @@ const DeviceQRScanFormSection = () => {
const device = await dispatch(attachPromise(parseEndDeviceQRCode(qrCode)))

const { end_device } = device.end_device_template
const { lora_alliance_profile_ids } = end_device
const { lora_alliance_profile_ids = {} } = end_device

const brand = getBrand(lora_alliance_profile_ids.vendor_id)
const versionIds = await getVersionIds(lora_alliance_profile_ids)
const brand = getBrand(lora_alliance_profile_ids.vendor_id, versionIds?.brand_id)
const sheetData = [
{
header: sharedMessages.qrCodeData,
Expand All @@ -130,6 +161,13 @@ const DeviceQRScanFormSection = () => {
sensitive: false,
},
{ key: sharedMessages.brand, value: brand?.name },
...(brand && versionIds?.model_id
? [
{ key: sharedMessages.model, value: versionIds.model_id },
{ key: sharedMessages.hardwareVersion, value: versionIds.hardware_version },
{ key: sharedMessages.firmwareVersion, value: versionIds.firmware_version },
].filter(({ value }) => Boolean(value))
: []),
],
},
]
Expand All @@ -138,12 +176,13 @@ const DeviceQRScanFormSection = () => {
valid: true,
data: sheetData,
device,
versionIds,
})
} catch (error) {
setQrData({ ...qrData, data: [], valid: false })
}
},
[dispatch, getBrand, qrData],
[dispatch, getBrand, getVersionIds, qrData],
)

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import { hasSelectedDeviceRepositoryOther } from '@console/lib/device-utils'

import { getTemplate } from '@console/store/actions/device-repository'

import { selectDeviceTemplate } from '@console/store/selectors/device-repository'
import {
selectDeviceModelById,
selectDeviceTemplate,
} from '@console/store/selectors/device-repository'
import { selectSelectedApplicationId } from '@console/store/selectors/applications'

import { initialValues as provisioningInitialValues } from '../../provisioning-form-section'
Expand All @@ -57,13 +60,14 @@ const DeviceTypeRepositoryFormSection = () => {
const firmwareVersion = version_ids?.firmware_version
const band = version_ids?.band_id
const template = useSelector(selectDeviceTemplate)
const hasModel = useSelector(state => Boolean(selectDeviceModelById(state, brand, model)))
const supportLink = useSelector(selectSupportLinkConfig)

const hasSelectedOther = hasSelectedDeviceRepositoryOther(version)
const hasCompleted = hasCompletedDeviceRepositorySelection(version)
const hasValidType = hasValidDeviceRepositoryType(version, template)
const showProgressHint = !hasSelectedOther && !hasCompleted
const showDeviceCard = hasValidType
const showDeviceCard = hasValidType && hasModel
const showFrequencyPlanSelector = hasValidType
const showOtherHint = hasSelectedOther

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ const BandSelect = props => {
const { name, onChange, brandId, modelId, fwVersion, ...rest } = props
const { setFieldValue } = useFormContext()
const versions = useSelector(state => selectDeviceModelFirmwareVersions(state, brandId, modelId))
const version = versions.find(v => v.version === fwVersion) || { profiles: [] }
const profiles = Object.keys(version.profiles)
const version = versions.find(v => v.version === fwVersion)
const profiles = Object.keys(version?.profiles || {})

const options = React.useMemo(() => formatOptions(profiles), [profiles])
const onlyOption = options.length > 0 && options.length <= 2 ? options[0].value : undefined
const onlyOption = version && options.length <= 2 ? options[0].value : undefined

React.useEffect(() => {
if (onlyOption) {
Expand Down
17 changes: 17 additions & 0 deletions pkg/webui/console/store/actions/device-repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ export const [
{ request: getTemplate, success: getTemplateSuccess, failure: getTemplateFailure },
] = createRequestActions(GET_TEMPLATE_BASE, (appId, version) => ({ appId, version }))

export const GET_TEMPLATE_BY_PROFILE_IDS_BASE = 'GET_DEVICE_TEMPLATE_BY_PROFILE_IDS'
export const [
{
request: GET_TEMPLATE_BY_PROFILE_IDS,
success: GET_TEMPLATE_BY_PROFILE_IDS_SUCCESS,
failure: GET_TEMPLATE_BY_PROFILE_IDS_FAILURE,
},
{
request: getTemplateByProfileIds,
success: getTemplateByProfileIdsSuccess,
failure: getTemplateByProfileIdsFailure,
},
] = createRequestActions(GET_TEMPLATE_BY_PROFILE_IDS_BASE, (vendorId, vendorProfileId) => ({
vendorId,
vendorProfileId,
}))

export const GET_REPO_PF_BASE = 'GET_REPOSITORY_PAYLOAD_FORMATTERS'
export const [
{ request: GET_REPO_PF, success: GET_REPO_PF_SUCCESS, failure: GET_REPO_PF_FAILURE },
Expand Down
12 changes: 12 additions & 0 deletions pkg/webui/console/store/middleware/logics/device-repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ const getTemplateLogic = createRequestLogic({
},
})

const getTemplateByProfileIdsLogic = createRequestLogic({
type: repository.GET_TEMPLATE_BY_PROFILE_IDS,
process: ({ action }) => {
const {
payload: { vendorId, vendorProfileId },
} = action

return tts.Applications.Devices.Repository.getTemplateByProfileIds(vendorId, vendorProfileId)
},
})

const getRepositoryPayloadFormattersLogic = createRequestLogic({
type: repository.GET_REPO_PF,
process: async ({ action }) => {
Expand Down Expand Up @@ -130,5 +141,6 @@ export default [
listDeviceModelsLogic,
getDeviceModelLogic,
getTemplateLogic,
getTemplateByProfileIdsLogic,
getRepositoryPayloadFormattersLogic,
]
11 changes: 11 additions & 0 deletions sdk/js/src/service/devices/repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ class DeviceRepository {
return Marshaler.payloadSingleResponse(result)
}

async getTemplateByProfileIds(vendorId, vendorProfileId) {
const result = await this._api.GetTemplate({
routeParams: {
'end_device_profile_ids.vendor_id': vendorId,
'end_device_profile_ids.vendor_profile_id': vendorProfileId,
},
})

return Marshaler.payloadSingleResponse(result)
}

// Formatters retrieval.

async getUplinkDecoder(appId, version) {
Expand Down
Loading