Skip to content

iOS Integration

Learn how to integrate the Roboscope 2 API into your iOS ARKit application.

┌─────────────────────────────────────┐
│ iOS App (SwiftUI) │
├─────────────────────────────────────┤
│ Views │
│ ├─ ARSessionView (ARKit + RK) │
│ ├─ SpacesView │
│ ├─ SessionsView │
│ └─ MarkerListView │
├─────────────────────────────────────┤
│ Services │
│ ├─ NetworkManager │
│ ├─ SpaceService │
│ ├─ WorkSessionService │
│ ├─ MarkerService │
│ ├─ PresenceService │
│ └─ LockService │
├─────────────────────────────────────┤
│ Models │
│ ├─ Space │
│ ├─ WorkSession │
│ ├─ Marker │
│ └─ AnyCodable │
├─────────────────────────────────────┤
│ ARKit / RealityKit │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ REST API (Rust/Axum) │
└─────────────────────────────────────┘
roboscope2/
├── Models/
│ ├── Space.swift
│ ├── WorkSession.swift
│ ├── Marker.swift
│ └── AnyCodable.swift
├── Services/
│ ├── Network/
│ │ ├── APIConfiguration.swift
│ │ ├── NetworkManager.swift
│ │ └── APIError.swift
│ ├── SpaceService.swift
│ ├── WorkSessionService.swift
│ ├── MarkerService.swift
│ ├── PresenceService.swift
│ └── LockService.swift
├── Views/
│ ├── SpacesView.swift
│ ├── SessionsView.swift
│ ├── ARSessionView.swift
│ └── Components/
└── ViewModels/
└── ARSessionViewModel.swift

Using Swift Package Manager:

In Xcode:

  1. File → Add Package Dependencies
  2. Search for packages:
    • Alamofire: https://github.com/Alamofire/Alamofire.git (5.8.0+)

Or in Package.swift:

dependencies: [
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0")
]

Add required privacy descriptions:

<key>NSCameraUsageDescription</key>
<string>Camera access is required for AR scanning and marker placement</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Local network access is required to communicate with the API server</string>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
</array>

Create APIConfiguration.swift:

import Foundation
struct APIConfiguration {
static var shared = APIConfiguration()
enum Environment {
case development
case production
}
var environment: Environment = .development
var baseURL: String {
switch environment {
case .development:
return "http://192.168.1.100:8080/api/v1" // Replace with your IP
case .production:
return "https://api.roboscope.example.com/api/v1"
}
}
var timeout: TimeInterval = 30.0
}
import Foundation
struct Space: Codable, Identifiable {
let id: UUID
let key: String
let name: String
let description: String?
let modelGlbUrl: String?
let modelUsdcUrl: String?
let previewUrl: String?
let scanUrl: String?
let calibrationVector: [Double]
let meta: [String: AnyCodable]
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, key, name, description, meta
case modelGlbUrl = "model_glb_url"
case modelUsdcUrl = "model_usdc_url"
case previewUrl = "preview_url"
case scanUrl = "scan_url"
case calibrationVector = "calibration_vector"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct CreateSpace: Codable {
let key: String
let name: String
let description: String?
let modelGlbUrl: String?
let modelUsdcUrl: String?
let previewUrl: String?
let scanUrl: String?
let calibrationVector: [Double]
enum CodingKeys: String, CodingKey {
case key, name, description
case modelGlbUrl = "model_glb_url"
case modelUsdcUrl = "model_usdc_url"
case previewUrl = "preview_url"
case scanUrl = "scan_url"
case calibrationVector = "calibration_vector"
}
}
import Foundation
enum WorkSessionType: String, Codable {
case inspection
case repair
case other
}
enum WorkSessionStatus: String, Codable {
case draft
case active
case done
case archived
}
struct WorkSession: Codable, Identifiable {
let id: UUID
let spaceId: UUID
let sessionType: WorkSessionType
let status: WorkSessionStatus
let startedAt: Date?
let completedAt: Date?
let version: Int64
let meta: [String: AnyCodable]
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, status, version, meta
case spaceId = "space_id"
case sessionType = "session_type"
case startedAt = "started_at"
case completedAt = "completed_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct CreateWorkSession: Codable {
let spaceId: UUID
let sessionType: WorkSessionType
let status: WorkSessionStatus?
let startedAt: Date?
let completedAt: Date?
enum CodingKeys: String, CodingKey {
case status
case spaceId = "space_id"
case sessionType = "session_type"
case startedAt = "started_at"
case completedAt = "completed_at"
}
}
import Foundation
struct Marker: Codable, Identifiable {
let id: UUID
let workSessionId: UUID
let label: String?
let p1: [Double]
let p2: [Double]
let p3: [Double]
let p4: [Double]
let color: String?
let version: Int64
let meta: [String: AnyCodable]
let customProps: [String: AnyCodable]
let calibratedData: CalibratedData?
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, label, p1, p2, p3, p4, color, version, meta
case workSessionId = "work_session_id"
case customProps = "custom_props"
case calibratedData = "calibrated_data"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct CalibratedData: Codable {
let p1: [Double]
let p2: [Double]
let p3: [Double]
let p4: [Double]
let center: [Double]
}
struct CreateMarker: Codable {
let workSessionId: UUID
let label: String?
let p1: [Double]
let p2: [Double]
let p3: [Double]
let p4: [Double]
let color: String?
let customProps: [String: AnyCodable]?
let calibratedData: CalibratedData?
enum CodingKeys: String, CodingKey {
case label, p1, p2, p3, p4, color
case workSessionId = "work_session_id"
case customProps = "custom_props"
case calibratedData = "calibrated_data"
}
}
import Foundation
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let string = try? container.decode(String.self) {
value = string
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map { $0.value }
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues { $0.value }
} else {
value = NSNull()
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let bool as Bool:
try container.encode(bool)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
default:
try container.encodeNil()
}
}
}
import Foundation
enum APIError: Error, LocalizedError {
case invalidURL
case networkError(Error)
case invalidResponse
case decodingError(Error)
case serverError(Int, String)
case notFound
case conflict(String)
case validationError(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .invalidResponse:
return "Invalid server response"
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .serverError(let code, let message):
return "Server error (\(code)): \(message)"
case .notFound:
return "Resource not found"
case .conflict(let message):
return "Conflict: \(message)"
case .validationError(let message):
return "Validation error: \(message)"
}
}
}
import Foundation
import Alamofire
final class NetworkManager {
static let shared = NetworkManager()
private let session: Session
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = APIConfiguration.shared.timeout
self.session = Session(configuration: configuration)
// Configure JSON decoder for dates
self.decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .useDefaultKeys
// Configure JSON encoder for dates
self.encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .useDefaultKeys
}
// MARK: - GET Request
func get<T: Decodable>(
endpoint: String,
queryItems: [URLQueryItem]? = nil
) async throws -> T {
let url = try buildURL(endpoint: endpoint, queryItems: queryItems)
return try await withCheckedThrowingContinuation { continuation in
session.request(url, method: .get)
.validate()
.responseData { response in
self.handleResponse(response, continuation: continuation)
}
}
}
// MARK: - POST Request
func post<T: Decodable, U: Encodable>(
endpoint: String,
body: U
) async throws -> T {
let url = try buildURL(endpoint: endpoint)
let data = try encoder.encode(body)
return try await withCheckedThrowingContinuation { continuation in
session.request(
url,
method: .post,
parameters: [:],
encoder: JSONParameterEncoder.default,
headers: ["Content-Type": "application/json"]
)
.uploadData(data)
.validate()
.responseData { response in
self.handleResponse(response, continuation: continuation)
}
}
}
// MARK: - PATCH Request
func patch<T: Decodable, U: Encodable>(
endpoint: String,
body: U
) async throws -> T {
let url = try buildURL(endpoint: endpoint)
let data = try encoder.encode(body)
return try await withCheckedThrowingContinuation { continuation in
session.request(
url,
method: .patch,
parameters: [:],
encoder: JSONParameterEncoder.default,
headers: ["Content-Type": "application/json"]
)
.uploadData(data)
.validate()
.responseData { response in
self.handleResponse(response, continuation: continuation)
}
}
}
// MARK: - DELETE Request
func delete(endpoint: String) async throws {
let url = try buildURL(endpoint: endpoint)
return try await withCheckedThrowingContinuation { continuation in
session.request(url, method: .delete)
.validate()
.response { response in
if let error = response.error {
continuation.resume(throwing: APIError.networkError(error))
} else {
continuation.resume()
}
}
}
}
// MARK: - Helper Methods
private func buildURL(
endpoint: String,
queryItems: [URLQueryItem]? = nil
) throws -> URL {
let baseURL = APIConfiguration.shared.baseURL
var urlString = baseURL + endpoint
if let queryItems = queryItems, !queryItems.isEmpty {
var components = URLComponents(string: urlString)
components?.queryItems = queryItems
urlString = components?.url?.absoluteString ?? urlString
}
guard let url = URL(string: urlString) else {
throw APIError.invalidURL
}
return url
}
private func handleResponse<T: Decodable>(
_ response: AFDataResponse<Data>,
continuation: CheckedContinuation<T, Error>
) {
switch response.result {
case .success(let data):
do {
let decoded = try decoder.decode(T.self, from: data)
continuation.resume(returning: decoded)
} catch {
continuation.resume(throwing: APIError.decodingError(error))
}
case .failure(let error):
if let statusCode = response.response?.statusCode {
switch statusCode {
case 404:
continuation.resume(throwing: APIError.notFound)
case 409:
let message = String(data: response.data ?? Data(), encoding: .utf8) ?? ""
continuation.resume(throwing: APIError.conflict(message))
case 400:
let message = String(data: response.data ?? Data(), encoding: .utf8) ?? ""
continuation.resume(throwing: APIError.validationError(message))
default:
let message = String(data: response.data ?? Data(), encoding: .utf8) ?? error.localizedDescription
continuation.resume(throwing: APIError.serverError(statusCode, message))
}
} else {
continuation.resume(throwing: APIError.networkError(error))
}
}
}
}
import Foundation
final class SpaceService: ObservableObject {
static let shared = SpaceService()
private let networkManager = NetworkManager.shared
@Published var spaces: [Space] = []
@Published var isLoading = false
private init() {}
func listSpaces() async throws -> [Space] {
isLoading = true
defer { isLoading = false }
let spaces: [Space] = try await networkManager.get(endpoint: "/spaces")
await MainActor.run { self.spaces = spaces }
return spaces
}
func getSpace(id: UUID) async throws -> Space {
try await networkManager.get(endpoint: "/spaces/\(id.uuidString)")
}
func createSpace(_ space: CreateSpace) async throws -> Space {
let created: Space = try await networkManager.post(
endpoint: "/spaces",
body: space
)
await MainActor.run { spaces.append(created) }
return created
}
func deleteSpace(id: UUID) async throws {
try await networkManager.delete(endpoint: "/spaces/\(id.uuidString)")
await MainActor.run {
spaces.removeAll { $0.id == id }
}
}
}
import Foundation
final class MarkerService: ObservableObject {
static let shared = MarkerService()
private let networkManager = NetworkManager.shared
@Published var markers: [Marker] = []
@Published var isLoading = false
private init() {}
func listMarkers(workSessionId: UUID? = nil) async throws -> [Marker] {
isLoading = true
defer { isLoading = false }
var queryItems: [URLQueryItem] = []
if let workSessionId = workSessionId {
queryItems.append(URLQueryItem(
name: "work_session_id",
value: workSessionId.uuidString
))
}
let markers: [Marker] = try await networkManager.get(
endpoint: "/markers",
queryItems: queryItems.isEmpty ? nil : queryItems
)
await MainActor.run { self.markers = markers }
return markers
}
func createMarker(_ marker: CreateMarker) async throws -> Marker {
let created: Marker = try await networkManager.post(
endpoint: "/markers",
body: marker
)
await MainActor.run { markers.append(created) }
return created
}
func bulkCreateMarkers(_ markers: [CreateMarker]) async throws -> [Marker] {
struct BulkRequest: Codable {
let markers: [CreateMarker]
}
let created: [Marker] = try await networkManager.post(
endpoint: "/markers/bulk",
body: BulkRequest(markers: markers)
)
await MainActor.run { self.markers.append(contentsOf: created) }
return created
}
}

Converting ARKit Coordinates to Marker Points

Section titled “Converting ARKit Coordinates to Marker Points”
import ARKit
import RealityKit
extension SIMD3<Float> {
func toDoubleArray() -> [Double] {
[Double(x), Double(y), Double(z)]
}
}
func createMarkerFromARPoints(
workSessionId: UUID,
corners: [SIMD3<Float>],
label: String?,
color: String?
) -> CreateMarker {
guard corners.count == 4 else {
fatalError("Marker requires exactly 4 corner points")
}
return CreateMarker(
workSessionId: workSessionId,
label: label,
p1: corners[0].toDoubleArray(),
p2: corners[1].toDoubleArray(),
p3: corners[2].toDoubleArray(),
p4: corners[3].toDoubleArray(),
color: color,
customProps: nil,
calibratedData: nil
)
}
import SwiftUI
import ARKit
import RealityKit
struct ARMarkerPlacementView: View {
@StateObject private var arViewModel = ARViewModel()
let workSessionId: UUID
var body: some View {
ZStack {
ARViewContainer(arViewModel: arViewModel)
.edgesIgnoringSafeArea(.all)
VStack {
Spacer()
if arViewModel.selectedCorners.count < 4 {
Text("Tap to place corner \(arViewModel.selectedCorners.count + 1)/4")
.padding()
.background(Color.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(8)
} else {
Button("Create Marker") {
Task {
await createMarker()
}
}
.padding()
.background(Color.green)
.foregroundColor(.white)
.cornerRadius(8)
}
}
.padding()
}
}
private func createMarker() async {
let marker = createMarkerFromARPoints(
workSessionId: workSessionId,
corners: arViewModel.selectedCorners,
label: "New Marker",
color: "#FF0000"
)
do {
_ = try await MarkerService.shared.createMarker(marker)
arViewModel.selectedCorners.removeAll()
} catch {
print("Failed to create marker: \(error)")
}
}
}
func loadData() async {
do {
let spaces = try await SpaceService.shared.listSpaces()
// Use spaces
} catch let error as APIError {
switch error {
case .notFound:
showAlert("Space not found")
case .conflict(let message):
showAlert("Conflict: \(message)")
case .validationError(let message):
showAlert("Validation error: \(message)")
default:
showAlert(error.localizedDescription)
}
} catch {
showAlert("Unknown error: \(error.localizedDescription)")
}
}
func updateSession(_ session: WorkSession) async throws {
var updateRequest = UpdateWorkSession(
status: .done,
completedAt: Date(),
version: session.version // Include current version
)
do {
let updated = try await WorkSessionService.shared.updateWorkSession(
id: session.id,
update: updateRequest
)
// Success
} catch APIError.conflict {
// Version mismatch - refetch and retry
let fresh = try await WorkSessionService.shared.getWorkSession(id: session.id)
updateRequest.version = fresh.version
// Retry update
}
}
// Instead of creating markers one by one
for corner in markerCorners {
try await MarkerService.shared.createMarker(corner) // Slow!
}
// Use bulk create
try await MarkerService.shared.bulkCreateMarkers(markerCorners) // Fast!
class SessionViewModel: ObservableObject {
private var heartbeatTask: Task<Void, Never>?
func joinSession(id: UUID) async {
// Join presence
try? await PresenceService.shared.joinSession(id)
// Start heartbeat
heartbeatTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 10_000_000_000) // 10s
try? await PresenceService.shared.sendHeartbeat(sessionId: id)
}
}
}
func leaveSession(id: UUID) async {
// Cancel heartbeat
heartbeatTask?.cancel()
// Leave presence
try? await PresenceService.shared.leaveSession(id)
}
deinit {
heartbeatTask?.cancel()
}
}