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
177 changes: 164 additions & 13 deletions lib/features/scanner/data/services/device_registration_service.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import 'dart:async';
import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:rgnets_fdk/core/services/logger_service.dart';
import 'package:rgnets_fdk/core/services/secure_http_client.dart';
import 'package:rgnets_fdk/core/services/websocket_service.dart';
import 'package:rgnets_fdk/core/utils/log_redaction.dart' as log_redaction;
import 'package:rgnets_fdk/features/scanner/domain/entities/scan_session.dart';

/// Outcome of a device-registration round-trip with the backend.
Expand All @@ -27,14 +31,28 @@ class RegistrationServiceOutcome {
final Map<String, dynamic>? data;
}

/// Service for registering devices via WebSocket.
/// Service for registering devices.
///
/// Access Points register over REST (`POST /api/access_points/
/// register_ap_device`); ONTs and switches register over WebSocket. The AP
/// path was moved off WebSocket because the rXg's controller-side work can
/// outlive AnyCable's gRPC deadline, leaving the client without an
/// authoritative reply.
class DeviceRegistrationService {
DeviceRegistrationService({required WebSocketService webSocketService})
: _wsService = webSocketService;
DeviceRegistrationService({
required WebSocketService webSocketService,
http.Client? httpClient,
}) : _wsService = webSocketService,
_httpClient = httpClient;

static const String _tag = 'DeviceRegistration';

final WebSocketService _wsService;
final http.Client? _httpClient;

/// Lazily falls back to the shared cert-validating client so self-signed
/// rXg certificates are accepted in debug builds.
http.Client get _client => _httpClient ?? SecureHttpClient.getClient();

/// Check if WebSocket is connected.
bool get isConnected => _wsService.isConnected;
Expand All @@ -52,8 +70,23 @@ class DeviceRegistrationService {
String? partNumber,
String? model,
int? existingDeviceId,
String? siteUrl,
String? apiKey,
Duration timeout = const Duration(seconds: 30),
}) async {
// Access Points register over REST, not WebSocket.
if (deviceType == DeviceType.accessPoint) {
return _registerApViaRest(
mac: mac,
serialNumber: serialNumber,
pmsRoomId: pmsRoomId,
existingDeviceId: existingDeviceId,
siteUrl: siteUrl,
apiKey: apiKey,
timeout: timeout,
);
}

if (!_wsService.isConnected) {
LoggerService.error(
'Cannot register device: WebSocket not connected',
Expand Down Expand Up @@ -111,6 +144,131 @@ class DeviceRegistrationService {
}
}

/// Registers an Access Point via the rXg's `register_ap_device` REST
/// collection action. Returns the rXg's authoritative verdict.
///
/// The endpoint mirrors the WebSocket `register_ap_device` crud action:
/// it associates the scanned MAC/serial with a PMS room, creating a new AP
/// or updating the existing one identified by [existingDeviceId].
Future<RegistrationServiceOutcome> _registerApViaRest({
required String mac,
required String serialNumber,
required int pmsRoomId,
required Duration timeout,
int? existingDeviceId,
String? siteUrl,
String? apiKey,
}) async {
if (siteUrl == null || siteUrl.isEmpty || apiKey == null || apiKey.isEmpty) {
LoggerService.error(
'Cannot register AP via REST: missing site URL or API key',
tag: _tag,
);
return const RegistrationServiceOutcome.failure(
errorMessage: 'Not signed in',
status: 0,
);
}

final uri = Uri.parse(
'https://${_normalizeSiteUrl(siteUrl)}'
'/api/access_points/register_ap_device.json?api_key=$apiKey',
);
final body = <String, dynamic>{
'serial_number': serialNumber,
'mac': mac,
'pms_room_id': pmsRoomId,
if (existingDeviceId != null) 'ap_id': existingDeviceId,
};

LoggerService.info(
'Registering AccessPoint via REST POST '
'${log_redaction.scrubUrlForLog(uri)}',
tag: _tag,
);

try {
final response = await _client
.post(
uri,
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode(body),
)
.timeout(timeout);
return _parseRestResponse(response);
} on TimeoutException {
LoggerService.warning(
'AP REST registration timed out waiting for backend response',
tag: _tag,
);
return const RegistrationServiceOutcome.failure(
errorMessage: 'Timed out waiting for backend',
status: 0,
);
} on Object catch (e) {
// ClientException / TimeoutException can embed the full URL (with
// api_key); scrub before logging and never pass the raw object.
final scrubbed = log_redaction.scrubErrorForLog(e);
LoggerService.error(
'AP REST registration failed: $scrubbed',
tag: _tag,
);
return RegistrationServiceOutcome.failure(
errorMessage: scrubbed,
status: 0,
);
}
}

/// Parses the rXg's `register_ap_device` REST response. The controller
/// renders the `api:` hash directly as the JSON body (e.g.
/// `{"message": "...", "access_point": {...}}`) with the HTTP status
/// carrying the verdict — 200/201 on success, 4xx on failure.
RegistrationServiceOutcome _parseRestResponse(http.Response response) {
final status = response.statusCode;
Map<String, dynamic>? body;
if (response.body.isNotEmpty) {
try {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
body = decoded;
}
} on FormatException {
body = null;
}
}

if (status >= 200 && status < 300) {
return RegistrationServiceOutcome.success(data: body, status: status);
}

final message = body?['message'];
return RegistrationServiceOutcome.failure(
errorMessage: message is String && message.isNotEmpty
? message
: 'Registration failed (status $status)',
status: status,
);
}

/// Strips scheme and trailing slash so a stored `siteUrl` (which may be a
/// bare host or a full `https://host/`) yields a clean authority.
static String _normalizeSiteUrl(String url) {
var normalized = url;
if (normalized.startsWith('https://')) {
normalized = normalized.substring(8);
} else if (normalized.startsWith('http://')) {
normalized = normalized.substring(7);
}
if (normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}

/// Parses a [SocketMessage] returned by `requestActionCable` for a
/// registration into a [RegistrationServiceOutcome].
///
Expand Down Expand Up @@ -176,16 +334,9 @@ class DeviceRegistrationService {
}) {
switch (deviceType) {
case DeviceType.accessPoint:
return (
resourceType: 'access_points',
additionalData: {
'crud_action': 'register_ap_device',
'mac': mac,
'serial_number': serialNumber,
'pms_room_id': pmsRoomId,
if (existingDeviceId != null) 'ap_id': existingDeviceId,
},
);
// Access Points register over REST via _registerApViaRest and never
// reach this WebSocket payload builder.
throw ArgumentError('Access Points register over REST, not WebSocket');

case DeviceType.ont:
// The dedicated register_ont_device extra collection action
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rgnets_fdk/core/providers/core_providers.dart';
import 'package:rgnets_fdk/core/providers/websocket_providers.dart';
import 'package:rgnets_fdk/core/providers/websocket_sync_providers.dart';
import 'package:rgnets_fdk/core/services/logger_service.dart';
Expand Down Expand Up @@ -673,6 +674,16 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier {
// correlated by request_id and we learn the real outcome instead of
// assuming success on send.
final registrationService = ref.read(deviceRegistrationServiceProvider);

// Access Points register over REST, which needs the live site URL and
// API token; ONTs/switches still go over the authenticated WebSocket.
String? siteUrl;
String? apiKey;
if (deviceType == DeviceType.accessPoint) {
siteUrl = ref.read(storageServiceProvider).siteUrl;
apiKey = await ref.read(secureStorageServiceProvider).getToken();
}

final outcome = await registrationService.registerDevice(
deviceType: deviceType,
mac: mac,
Expand All @@ -681,6 +692,8 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier {
partNumber: partNumber,
model: model,
existingDeviceId: existingDeviceId,
siteUrl: siteUrl,
apiKey: apiKey,
);

if (!outcome.success) {
Expand Down
Loading