diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cecc49d2b..beefbc9534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cypress/e2e/console/devices/onboarding/qr-scan.spec.js b/cypress/e2e/console/devices/onboarding/qr-scan.spec.js index 9caf84f108..2627e6cb93 100644 --- a/cypress/e2e/console/devices/onboarding/qr-scan.spec.js +++ b/cypress/e2e/console/devices/onboarding/qr-scan.spec.js @@ -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') diff --git a/cypress/e2e/console/devices/onboarding/utils.js b/cypress/e2e/console/devices/onboarding/utils.js index 76cdc0953e..fa4c26c4c2 100644 --- a/cypress/e2e/console/devices/onboarding/utils.js +++ b/cypress/e2e/console/devices/onboarding/utils.js @@ -44,7 +44,13 @@ 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: { @@ -52,6 +58,7 @@ export const composeQRGeneratorParseResponse = ({ joinEui, devEui, cac, vendorId claim_authentication_code: { value: cac }, lora_alliance_profile_ids: { vendor_id: vendorId, + ...(vendorProfileId ? { vendor_profile_id: vendorProfileId } : {}), }, }, field_mask: { paths: ['ids', 'claim_authentication_code'] }, diff --git a/pkg/webui/console/containers/device-onboarding-form/qr-scan-form-section/index.js b/pkg/webui/console/containers/device-onboarding-form/qr-scan-form-section/index.js index 0ecc04ff87..0c02fb7adf 100644 --- a/pkg/webui/console/containers/device-onboarding-form/qr-scan-form-section/index.js +++ b/pkg/webui/console/containers/device-onboarding-form/qr-scan-form-section/index.js @@ -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' @@ -38,6 +39,7 @@ const qrDataInitialState = { approved: false, data: undefined, device: undefined, + versionIds: undefined, } const DeviceQRScanFormSection = () => { @@ -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, @@ -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 }) @@ -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, @@ -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)) + : []), ], }, ] @@ -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 ( diff --git a/pkg/webui/console/containers/device-onboarding-form/type-form-section/repository-form-section/index.js b/pkg/webui/console/containers/device-onboarding-form/type-form-section/repository-form-section/index.js index 3b0069cb6d..234b67b065 100644 --- a/pkg/webui/console/containers/device-onboarding-form/type-form-section/repository-form-section/index.js +++ b/pkg/webui/console/containers/device-onboarding-form/type-form-section/repository-form-section/index.js @@ -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' @@ -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 diff --git a/pkg/webui/console/containers/device-profile-section/device-selection/band-select/index.js b/pkg/webui/console/containers/device-profile-section/device-selection/band-select/index.js index 3855e42d24..5dcb93ce04 100644 --- a/pkg/webui/console/containers/device-profile-section/device-selection/band-select/index.js +++ b/pkg/webui/console/containers/device-profile-section/device-selection/band-select/index.js @@ -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) { diff --git a/pkg/webui/console/store/actions/device-repository.js b/pkg/webui/console/store/actions/device-repository.js index 9b27de5f30..30b4588e01 100644 --- a/pkg/webui/console/store/actions/device-repository.js +++ b/pkg/webui/console/store/actions/device-repository.js @@ -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 }, diff --git a/pkg/webui/console/store/middleware/logics/device-repository.js b/pkg/webui/console/store/middleware/logics/device-repository.js index f5fa0222cd..0e6475112f 100644 --- a/pkg/webui/console/store/middleware/logics/device-repository.js +++ b/pkg/webui/console/store/middleware/logics/device-repository.js @@ -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 }) => { @@ -130,5 +141,6 @@ export default [ listDeviceModelsLogic, getDeviceModelLogic, getTemplateLogic, + getTemplateByProfileIdsLogic, getRepositoryPayloadFormattersLogic, ] diff --git a/sdk/js/src/service/devices/repository.js b/sdk/js/src/service/devices/repository.js index 6687a6ecf2..7cdac33885 100644 --- a/sdk/js/src/service/devices/repository.js +++ b/sdk/js/src/service/devices/repository.js @@ -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) {