Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Claim gateway via QR code (FE) #7294

Merged
merged 10 commits into from
Sep 12, 2024
Merged
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 @@ -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
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
Loading