Skip to content

Commit

Permalink
Merge pull request #7294 from TheThingsNetwork/feature/claim-gateway-…
Browse files Browse the repository at this point in the history
…via-qr-fe

Claim gateway via QR code (FE)
  • Loading branch information
KrishnaIyer authored Sep 12, 2024
2 parents a653459 + 8de861e commit 5bdc597
Show file tree
Hide file tree
Showing 22 changed files with 331 additions and 98 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For details about compatibility between different releases, see the **Commitment
- Option to pause application webhooks.
- Endpoint for claiming gateways using a qr code
- Update the GetTemplate endpoint in device repository to check for profile identifiers in the vendor index.
- Support for claiming a gateway via QR code in the Console.

### Changed

Expand Down
2 changes: 1 addition & 1 deletion pkg/qrcodegenerator/qrcode/gateways/gateways.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,5 @@ func (s *Server) Parse(formatID string, data []byte) (ret Data, err error) {
return f, nil
}

return nil, errUnknownFormat
return nil, errUnknownFormat.New()
}
10 changes: 5 additions & 5 deletions pkg/qrcodegenerator/qrcode/gateways/ttigpro1.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ const (
)

// ttigpro1Regex is the regular expression to match the TTIGPRO1 format.
// The format is as follows: https://ttig.pro/c/{16 lowercase base16 chars}/{12 base62 chars}.
var ttigpro1Regex = regexp.MustCompile(`^https://ttig\.pro/c/([a-f0-9]{16})/([a-z0-9]{12})$`)
// The format is as follows: https://ttig.pro/c/{16 lowercase base16 chars}/{8+ base32 chars}.
var ttigpro1Regex = regexp.MustCompile(`^https://ttig\.pro/c/([a-f0-9]{16})/([a-z0-9]{8,})$`)

// TTIGPRO1 is a format for gateway identification QR codes.
type ttigpro1 struct {
Expand All @@ -40,7 +40,7 @@ func (m *ttigpro1) UnmarshalText(text []byte) error {
// Match the URL against the pattern
matches := ttigpro1Regex.FindStringSubmatch(string(text))
if matches == nil || len(matches) != 3 {
return errInvalidFormat
return errInvalidFormat.New()
}

if err := m.gatewayEUI.UnmarshalText([]byte(matches[1])); err != nil {
Expand All @@ -49,8 +49,8 @@ func (m *ttigpro1) UnmarshalText(text []byte) error {

m.ownerToken = matches[2]

if len(m.ownerToken) != 12 /* owner token length */ {
return errInvalidLength
if len(m.ownerToken) < 8 {
return errInvalidLength.New()
}

return nil
Expand Down
6 changes: 3 additions & 3 deletions pkg/qrcodegenerator/qrcode/gateways/ttigpro1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ func TestTTIGPRO1(t *testing.T) {
}{
{
Name: "CorrectQRCode",
Data: []byte("https://ttig.pro/c/ec656efffe000128/abcdef123456"),
Data: []byte("https://ttig.pro/c/ec656efffe000128/abcdef12"),
Expected: ttigpro1{
gatewayEUI: types.EUI64{0xec, 0x65, 0x6e, 0xff, 0xfe, 0x00, 0x01, 0x28},
ownerToken: "abcdef123456",
ownerToken: "abcdef12",
},
},
{
Expand Down Expand Up @@ -77,7 +77,7 @@ func TestTTIGPRO1(t *testing.T) {
},
{
Name: "Invalid/OwnerTokenLength",
Data: []byte("https://ttig.pro/c/ec656efffe000128/abcdef123"),
Data: []byte("https://ttig.pro/c/ec656efffe000128/abcdef"),
ErrorAssertion: func(t *testing.T, err error) bool {
t.Helper()
return assertions.New(t).So(errors.IsInvalidArgument(err), should.BeTrue)
Expand Down
2 changes: 1 addition & 1 deletion pkg/webui/components/modal/modal.styl
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
max-height: 80vh

+media-query-min($bp.sm)
min-width: 500px
min-width: 385px
max-width: 780px

+media-query($bp.sm)
Expand Down
14 changes: 7 additions & 7 deletions pkg/webui/components/qr-modal-button/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,12 @@ const QrScanDoc = (
)

const m = defineMessages({
scanEndDeviceContinue: 'Please scan the QR code to continue. {qrScanDoc}',
invalidData:
'Invalid QR code data. Please note that only TR005 LoRaWAN® Device Identification QR Code can be scanned. Some devices have unrelated QR codes printed on them that cannot be used.',
scanContinue: 'Please scan the QR code to continue. {qrScanDoc}',
apply: 'Apply',
})

const QRModalButton = props => {
const { message, onApprove, onCancel, onRead, qrData } = props
const { message, onApprove, onCancel, onRead, qrData, invalidMessage } = props

const handleRead = useCallback(
val => {
Expand All @@ -57,15 +55,16 @@ const QRModalButton = props => {
qrData.valid ? (
<DataSheet data={qrData.data} />
) : (
<ErrorMessage content={m.invalidData} />
<ErrorMessage content={invalidMessage} />
)
) : (
<>
<QR onChange={handleRead} />
<Message
content={m.scanEndDeviceContinue}
content={m.scanContinue}
values={{ qrScanDoc: QrScanDoc }}
component="span"
className="c-text-neutral-light"
/>
</>
)}
Expand All @@ -80,7 +79,7 @@ const QRModalButton = props => {
onApprove={onApprove}
message={message}
modalData={{
title: sharedMessages.scanEndDevice,
title: message,
children: modalData,
buttonMessage: m.apply,
approveButtonProps: {
Expand All @@ -95,6 +94,7 @@ const QRModalButton = props => {
}

QRModalButton.propTypes = {
invalidMessage: PropTypes.message.isRequired,
message: PropTypes.message.isRequired,
onApprove: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
Expand Down
2 changes: 1 addition & 1 deletion pkg/webui/components/qr/input/video/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ const Video = props => {
data-test-id="webcam-feed"
/>
) : (
<Spinner center>
<Spinner center inline className="mb-cs-xl">
<Message className={style.msg} content={m.fetchingCamera} />
</Spinner>
)}
Expand Down
4 changes: 2 additions & 2 deletions pkg/webui/components/qr/qr.styl
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@
width: 100%
max-height: 75vh

.msg
color: var(--c-bg-neutral-min)
+media-query($bp.xl)
max-height: 47vh
10 changes: 8 additions & 2 deletions pkg/webui/components/qr/require-permission.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { defineMessages } from 'react-intl'
import ErrorMessage from '@ttn-lw/lib/components/error-message'

import PropTypes from '@ttn-lw/lib/prop-types'
import sharedMessages from '@ttn-lw/lib/shared-messages'

import Button from '../button'

Expand Down Expand Up @@ -111,9 +112,14 @@ const RequirePermission = props => {
if (!allow || videoError) {
return (
<div className={style.captureWrapper}>
<ErrorMessage style={{ color: '#fff' }} content={m.permissionDeniedError} />
<ErrorMessage content={m.permissionDeniedError} />
<br />
<Button className="mt-cs-m" onClick={handleUseCapture} message={m.uploadImage} secondary />
<Button
className="mt-cs-m"
onClick={handleUseCapture}
message={sharedMessages.uploadAnImage}
secondary
/>
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,6 @@ export default defineMessages({
hasEndDeviceQR:
'Does your end device have a LoRaWAN® Device Identification QR Code? Scan it to speed up onboarding.',
deviceGuide: 'Device registration help',
deviceInfo: 'Found QR code data',
resetQRCodeData: 'Reset QR code data',
resetConfirm:
'Are you sure you want to discard QR code data? The scanned device will not be registered and the form will be reset.',
scanSuccess: 'QR code scanned successfully',
invalidData:
'Invalid QR code data. Please note that only TR005 LoRaWAN® Device Identification QR Code can be scanned. Some devices have unrelated QR codes printed on them that cannot be used.',
})
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import Message from '@ttn-lw/lib/components/message'
import attachPromise from '@ttn-lw/lib/store/actions/attach-promise'
import sharedMessages from '@ttn-lw/lib/shared-messages'

import { parseQRCode } from '@console/store/actions/qr-code-generator'
import { parseEndDeviceQRCode } from '@console/store/actions/qr-code-generator'

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

Expand Down Expand Up @@ -101,15 +101,15 @@ const DeviceQRScanFormSection = () => {
async qrCode => {
try {
// Get end device template from QR code
const device = await dispatch(attachPromise(parseQRCode(qrCode)))
const device = await dispatch(attachPromise(parseEndDeviceQRCode(qrCode)))

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

const brand = getBrand(lora_alliance_profile_ids.vendor_id)
const sheetData = [
{
header: m.deviceInfo,
header: sharedMessages.qrCodeData,
items: [
{
key: sharedMessages.claimAuthCode,
Expand Down Expand Up @@ -151,7 +151,7 @@ const DeviceQRScanFormSection = () => {
{qrData.approved ? (
<div className="mb-cs-xs">
<Icon icon={IconCheck} textPaddedRight className="c-bg-success-normal" />
<Message content={m.scanSuccess} />
<Message content={sharedMessages.scanSuccess} />
</div>
) : (
<div className="mb-cs-xs">
Expand All @@ -164,12 +164,12 @@ const DeviceQRScanFormSection = () => {
type="button"
icon={IconX}
onApprove={handleReset}
message={m.resetQRCodeData}
message={sharedMessages.qrCodeDataReset}
modalData={{
title: m.resetQRCodeData,
title: sharedMessages.qrCodeDataReset,
noTitleLine: true,
buttonMessage: m.resetQRCodeData,
children: <Message content={m.resetConfirm} component="span" />,
buttonMessage: sharedMessages.qrCodeDataReset,
children: <Message content={sharedMessages.resetConfirm} component="span" />,
approveButtonProps: {
icon: IconX,
},
Expand All @@ -178,6 +178,7 @@ const DeviceQRScanFormSection = () => {
) : (
<QRModalButton
message={sharedMessages.scanEndDevice}
invalidMessage={m.invalidData}
onApprove={handleQRCodeApprove}
onCancel={handleQRCodeCancel}
onRead={handleQRCodeRead}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const initialValues = {
const GatewayClaimFormSection = () => {
const { values, addToFieldRegistry, removeFromFieldRegistry } = useFormikContext()
const isManaged = values._inputMethod === 'managed'
const withQRdata = values._withQRdata

// Register hidden fields so they don't get cleaned.
useEffect(() => {
Expand Down Expand Up @@ -83,6 +84,7 @@ const GatewayClaimFormSection = () => {
component={Input}
encode={btoa}
decode={atob}
disabled={withQRdata}
autoFocus
/>
<Form.Field
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const GatewayProvisioningFormSection = () => {
values: {
_ownerId: ownerId,
_inputMethod: inputMethod,
_withQRdata: withQRdata,
ids: { eui = '' },
},
initialValues,
Expand Down Expand Up @@ -120,6 +121,13 @@ const GatewayProvisioningFormSection = () => {
}
}, [dispatch, eui, hasEmptyEui, setFieldValue])

useEffect(() => {
// Auto-confirm the join EUI when using QR code data.
if (withQRdata) {
handleGatewayEUI()
}
}, [withQRdata, handleGatewayEUI])

const handleEuiReset = useCallback(async () => {
setEuiError(undefined)
resetForm({ values: { ...initialValues, _ownerId: ownerId } })
Expand Down Expand Up @@ -162,7 +170,7 @@ const GatewayProvisioningFormSection = () => {
component={Input}
tooltipId={tooltipIds.GATEWAY_EUI}
required={inputMethod !== 'register'}
disabled={hasInputMethod}
disabled={hasInputMethod || withQRdata}
onKeyDown={handleGatewayEUIKeydown}
encode={gatewayEuiEncoder}
decode={gatewayEuiDecoder}
Expand All @@ -184,6 +192,7 @@ const GatewayProvisioningFormSection = () => {
message={sharedMessages.reset}
onClick={handleEuiReset}
secondary
disabled={withQRdata}
/>
) : (
<Button
Expand Down
2 changes: 2 additions & 0 deletions pkg/webui/console/containers/gateway-onboarding-form/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import GatewayProvisioningFormSection from './gateway-provisioning-form'
import validationSchema from './gateway-provisioning-form/validation-schema'
import { initialValues as registerInitialValues } from './gateway-provisioning-form/gateway-registration-form-section'
import { initialValues as claimingInitialValues } from './gateway-provisioning-form/gateway-claim-form-section'
import GatewayQRScanSection from './qr-scan-section'

const GatewayOnboardingForm = props => {
const { onSuccess } = props
Expand Down Expand Up @@ -190,6 +191,7 @@ const GatewayOnboardingForm = props => {
validationSchema={validationSchema}
validateAgainstCleanedValues
>
<GatewayQRScanSection />
<GatewayProvisioningFormSection userId={userId} />
</Form>
</>
Expand Down
Loading

0 comments on commit 5bdc597

Please sign in to comment.