Meshtastic-Apple/Meshtastic/Views/Settings/Config/DeviceConfig.swift

347 lines
13 KiB
Swift

//
// DeviceConfig.swift
// Meshtastic Apple
//
// Copyright (c) Garth Vander Houwen 6/13/22.
//
import MeshtasticProtobufs
import OSLog
import SwiftUI
struct DeviceConfig: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.dismiss) private var goBack
var node: NodeInfoEntity?
@State private var isPresentingNodeDBResetConfirm = false
@State private var isPresentingFactoryResetConfirm = false
@State var hasChanges = false
@State var deviceRole = 0
@State var buzzerGPIO = 0
@State var buttonGPIO = 0
@State var rebroadcastMode = 0
@State private var nodeInfoBroadcastSecs: UpdateInterval = UpdateInterval(from: 10800)
@State var doubleTapAsButtonPress = false
@State var ledHeartbeatEnabled = true
@State var tripleClickAsAdHocPing = true
@State var tzdef = ""
@State private var showSpecialRoleWarning = false
@State private var showSpecialRoleWarningForRole: Int = 0
var body: some View {
Form {
ConfigHeader(title: "Device", config: \.deviceConfig, node: node, onAppear: setDeviceValues)
Section(header: Text("Options")) {
VStack(alignment: .leading) {
Picker("Device Role", selection: $deviceRole ) {
ForEach(DeviceRoles.allCases) { dr in
Text(dr.name).tag(dr.rawValue as Int)
}
}
.onChange(of: deviceRole) { _, newRole in
if hasChanges && [DeviceRoles.router.rawValue, DeviceRoles.routerLate.rawValue, DeviceRoles.clientBase.rawValue].contains(newRole) {
showSpecialRoleWarningForRole = newRole
showSpecialRoleWarning = true
}
}
.confirmationDialog(
"Are you sure?",
isPresented: $showSpecialRoleWarning,
titleVisibility: .visible
) {
Button("Confirm") {
hasChanges = true
}
Button("Cancel", role: .cancel) {
setDeviceValues()
}
} message: {
Text(specialRoleWarningMessage(newRole: showSpecialRoleWarningForRole))
}
Text(DeviceRoles(rawValue: deviceRole)?.description ?? "")
.foregroundColor(.gray)
.font(.callout)
}
.pickerStyle(DefaultPickerStyle())
VStack(alignment: .leading) {
Picker("Rebroadcast Mode", selection: $rebroadcastMode ) {
ForEach(RebroadcastModes.allCases) { rm in
Text(rm.name)
}
}
Text(RebroadcastModes(rawValue: rebroadcastMode)?.description ?? "")
.foregroundColor(.gray)
.font(.callout)
}
UpdateIntervalPicker(
config: .broadcastLong,
pickerLabel: "Node Info Broadcast Interval",
selectedInterval: $nodeInfoBroadcastSecs
)
}
Section(header: Text("Hardware")) {
Toggle(isOn: $doubleTapAsButtonPress) {
Label("Double Tap as Button", systemImage: "hand.tap")
Text("Treat double tap on supported accelerometers as a user button press.")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $tripleClickAsAdHocPing) {
Label("Triple Click Ad Hoc Ping", systemImage: "mappin")
Text("Send a position on the primary channel when the user button is triple clicked.")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $ledHeartbeatEnabled) {
Label("LED Heartbeat", systemImage: "waveform.path.ecg")
Text("Controls the blinking LED on the device. For most devices this will control one of the up to 4 LEDS, the charger and GPS LEDs are not controllable.")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
Section(header: Text("Debug")) {
VStack(alignment: .leading) {
HStack {
Label("Time Zone", systemImage: "clock.badge.exclamationmark")
TextField("Time Zone", text: $tzdef, axis: .vertical)
.foregroundColor(.gray)
.onChange(of: tzdef) {
var totalBytes = tzdef.utf8.count
// Only mess with the value if it is too big
while totalBytes > 63 {
tzdef = String(tzdef.dropLast())
totalBytes = tzdef.utf8.count
}
}
.foregroundColor(.gray)
}
.keyboardType(.default)
.disableAutocorrection(true)
Text("Time zone for dates on the device screen and log.")
.foregroundColor(.gray)
.font(.callout)
}
}
Section(header: Text("GPIO")) {
Picker("Button GPIO", selection: $buttonGPIO) {
ForEach(0..<49) {
if $0 == 0 {
Text("Unset")
} else {
Text("Pin \($0)")
}
}
}
.pickerStyle(DefaultPickerStyle())
Picker("Buzzer GPIO", selection: $buzzerGPIO) {
ForEach(0..<49) {
if $0 == 0 {
Text("Unset")
} else {
Text("Pin \($0)")
}
}
}
.pickerStyle(DefaultPickerStyle())
}
}
.disabled(!accessoryManager.isConnected || node?.deviceConfig == nil)
.safeAreaInset(edge: .bottom, alignment: .center) {
VStack(spacing: 0) {
// Only show these buttons for the BLE connected node
if accessoryManager.isConnected, let device = accessoryManager.activeConnection?.device, node?.num ?? -1 == device.num {
HStack {
Button("Reset NodeDB", role: .destructive) {
isPresentingNodeDBResetConfirm = true
}
.disabled(node?.user == nil)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.confirmationDialog(
"Are you sure?",
isPresented: $isPresentingNodeDBResetConfirm,
titleVisibility: .visible
) {
Button("Erase all device and app data?", role: .destructive) {
Task {
do {
try await accessoryManager.sendNodeDBReset(fromUser: node!.user!, toUser: node!.user!)
try await Task.sleep(for: .seconds(1))
try await accessoryManager.disconnect()
await MeshPackets.shared.clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
} catch {
Logger.mesh.error("NodeDB Reset Failed")
}
}
}
}
Button("Factory Reset", role: .destructive) {
isPresentingFactoryResetConfirm = true
}
.disabled(node?.user == nil)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.confirmationDialog(
"Factory reset will delete device and app data.",
isPresented: $isPresentingFactoryResetConfirm,
titleVisibility: .visible
) {
Button("Delete all config? ", role: .destructive) {
Task {
do {
try await accessoryManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!)
try await Task.sleep(for: .seconds(1))
try await accessoryManager.disconnect()
await MeshPackets.shared.clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
} catch {
Logger.mesh.error("Factory Reset Failed")
}
}
}
Button("Delete all config, keys and BLE bonds? ", role: .destructive) {
Task {
do {
try await accessoryManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!, resetDevice: true)
try? await Task.sleep(for: .seconds(1))
try await accessoryManager.disconnect()
await MeshPackets.shared.clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
} catch {
Logger.mesh.error("Factory Reset Failed")
}
}
}
}
}
.padding(.bottom)
}
HStack(spacing: 0) {
SaveConfigButton(node: node, hasChanges: $hasChanges) {
if let deviceNum = accessoryManager.activeDeviceNum,
let connectedNode = getNodeInfo(id: deviceNum, context: context) {
var dc = Config.DeviceConfig()
dc.role = DeviceRoles(rawValue: deviceRole)!.protoEnumValue()
dc.buttonGpio = UInt32(buttonGPIO)
dc.buzzerGpio = UInt32(buzzerGPIO)
dc.rebroadcastMode = RebroadcastModes(rawValue: rebroadcastMode)?.protoEnumValue() ?? RebroadcastModes.all.protoEnumValue()
dc.nodeInfoBroadcastSecs = UInt32(nodeInfoBroadcastSecs.intValue)
dc.doubleTapAsButtonPress = doubleTapAsButtonPress
dc.disableTripleClick = !tripleClickAsAdHocPing
dc.tzdef = tzdef
dc.ledHeartbeatDisabled = !ledHeartbeatEnabled
Task {
_ = try await accessoryManager.saveDeviceConfig(config: dc, fromUser: connectedNode.user!, toUser: node!.user!)
Task { @MainActor in
// Should show a saved successfully alert once I know that to be true
// for now just disable the button after a successful save
hasChanges = false
goBack()
}
}
}
}
}
}
.navigationTitle("Device Config")
.navigationBarItems(
trailing: ZStack {
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
}
)
}
.onFirstAppear {
// Need to request a DeviceConfig from the remote node before allowing changes
if let deviceNum = accessoryManager.activeDeviceNum, let node {
let connectedNode = getNodeInfo(id: deviceNum, context: context)
if let connectedNode {
if node.num != deviceNum {
if UserDefaults.enableAdministration {
/// 2.5 Administration with session passkey
let expiration = node.sessionExpiration ?? Date()
if expiration < Date() || node.deviceConfig == nil {
Task {
do {
Logger.mesh.info("⚙️ Empty or expired device config requesting via PKI admin")
try await accessoryManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!)
} catch {
Logger.mesh.error("🚨 Device config request failed")
}
}
}
} else {
if node.deviceConfig == nil {
/// Legacy Administration
Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.")
}
}
}
}
}
}
.onChange(of: deviceRole) { oldRole, newRole in
if oldRole != newRole && newRole != node?.deviceConfig?.role ?? -1 { hasChanges = true }
}
.onChange(of: buttonGPIO) { oldButtonGPIO, newButtonGPIO in
if oldButtonGPIO != newButtonGPIO && newButtonGPIO != node?.deviceConfig?.buttonGpio ?? -1 { hasChanges = true }
}
.onChange(of: buzzerGPIO) { oldBuzzerGPIO, newBuzzerGPIO in
if oldBuzzerGPIO != newBuzzerGPIO && newBuzzerGPIO != node?.deviceConfig?.buzzerGpio ?? -1 { hasChanges = true }
}
.onChange(of: rebroadcastMode) { oldRebroadcastMode, newRebroadcastMode in
if oldRebroadcastMode != newRebroadcastMode && newRebroadcastMode != node?.deviceConfig?.rebroadcastMode ?? -1 { hasChanges = true }
}
.onChange(of: nodeInfoBroadcastSecs.intValue) { oldNodeInfoBroadcastSecs, newNodeInfoBroadcastSecs in
if oldNodeInfoBroadcastSecs != newNodeInfoBroadcastSecs && newNodeInfoBroadcastSecs != node?.deviceConfig?.nodeInfoBroadcastSecs ?? -1 { hasChanges = true }
}
.onChange(of: doubleTapAsButtonPress) { oldDoubleTapAsButtonPress, newDoubleTapAsButtonPress in
if oldDoubleTapAsButtonPress != newDoubleTapAsButtonPress && newDoubleTapAsButtonPress != node?.deviceConfig?.doubleTapAsButtonPress ?? false { hasChanges = true }
}
.onChange(of: tripleClickAsAdHocPing) { oldTripleClickAsAdHocPing, newTripleClickAsAdHocPing in
if oldTripleClickAsAdHocPing != newTripleClickAsAdHocPing && newTripleClickAsAdHocPing != node?.deviceConfig?.tripleClickAsAdHocPing ?? false { hasChanges = true }
}
.onChange(of: tzdef) { oldTzdef, newTzdef in
if oldTzdef != newTzdef && newTzdef != node?.deviceConfig?.tzdef { hasChanges = true }
}
.onChange(of: ledHeartbeatEnabled) { oldLedHeartbeatEnabled, newLedHeartbeatEnabled in
if oldLedHeartbeatEnabled != newLedHeartbeatEnabled && newLedHeartbeatEnabled != node?.deviceConfig?.ledHeartbeatEnabled ?? false { hasChanges = true }
}
}
func setDeviceValues() {
if node?.deviceConfig?.role ?? 0 == 3 {
node?.deviceConfig?.role = 1
}
self.deviceRole = Int(node?.deviceConfig?.role ?? 0)
self.buttonGPIO = Int(node?.deviceConfig?.buttonGpio ?? 0)
self.buzzerGPIO = Int(node?.deviceConfig?.buzzerGpio ?? 0)
self.rebroadcastMode = Int(node?.deviceConfig?.rebroadcastMode ?? 0)
self.nodeInfoBroadcastSecs = UpdateInterval(from: Int(node?.deviceConfig?.nodeInfoBroadcastSecs ?? 10800))
if nodeInfoBroadcastSecs.intValue < 10800 {
nodeInfoBroadcastSecs = UpdateInterval(from: 10800)
}
self.doubleTapAsButtonPress = node?.deviceConfig?.doubleTapAsButtonPress ?? false
self.tripleClickAsAdHocPing = node?.deviceConfig?.tripleClickAsAdHocPing ?? false
self.ledHeartbeatEnabled = node?.deviceConfig?.ledHeartbeatEnabled ?? true
self.tzdef = node?.deviceConfig?.tzdef ?? ""
hasChanges = false
}
private func specialRoleWarningMessage(newRole: Int) -> String {
if [DeviceRoles.router.rawValue, DeviceRoles.routerLate.rawValue].contains(newRole) {
return "The Router roles are only for high vantage locations like mountaintops and towers with few nearby nodes, not for use in urban areas. Improper use will hurt your local mesh."
} else if newRole == DeviceRoles.clientBase.rawValue {
return "Switching to Client Base will clear this node's favorites. Client Base should only favorite other nodes you control. Improper use will hurt your local mesh."
} else {
return ""
}
}
}