mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge pull request #269 from meshtastic/2.0.9_Working_changes
Version 2.0.9 Working Changes
This commit is contained in:
commit
1f3333dd34
14 changed files with 780 additions and 206 deletions
|
|
@ -232,6 +232,7 @@
|
|||
DDD3BBD4292D763200D609B3 /* MeshtasticTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MeshtasticTests.swift; sourceTree = "<group>"; };
|
||||
DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeText.swift; sourceTree = "<group>"; };
|
||||
DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntityExtension.swift; sourceTree = "<group>"; };
|
||||
DDEE03EC29544A1000FCAD57 /* MeshtasticDataModelV4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV4.xcdatamodel; sourceTree = "<group>"; };
|
||||
DDF924C926FBB953009FE055 /* ConnectedDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectedDevice.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -1000,7 +1001,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.8;
|
||||
MARKETING_VERSION = 2.0.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1033,7 +1034,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.8;
|
||||
MARKETING_VERSION = 2.0.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1209,11 +1210,12 @@
|
|||
DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = {
|
||||
isa = XCVersionGroup;
|
||||
children = (
|
||||
DDEE03EC29544A1000FCAD57 /* MeshtasticDataModelV4.xcdatamodel */,
|
||||
DDCDC69A29467643004C1DDA /* MeshtasticDataModelV3.xcdatamodel */,
|
||||
DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */,
|
||||
DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */,
|
||||
);
|
||||
currentVersion = DDCDC69A29467643004C1DDA /* MeshtasticDataModelV3.xcdatamodel */;
|
||||
currentVersion = DDEE03EC29544A1000FCAD57 /* MeshtasticDataModelV4.xcdatamodel */;
|
||||
name = Meshtastic.xcdatamodeld;
|
||||
path = Meshtastic/Meshtastic.xcdatamodeld;
|
||||
sourceTree = "<group>";
|
||||
|
|
|
|||
|
|
@ -313,6 +313,35 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject {
|
|||
connectedPeripheral!.peripheral.readValue(for: FROMRADIO_characteristic)
|
||||
}
|
||||
|
||||
func sendTraceRouteRequest(destNum: Int64, wantResponse: Bool) -> Bool {
|
||||
|
||||
var success = false
|
||||
let fromNodeNum = connectedPeripheral.num
|
||||
|
||||
let routePacket = RouteDiscovery()
|
||||
|
||||
var meshPacket = MeshPacket()
|
||||
meshPacket.to = UInt32(destNum)
|
||||
meshPacket.from = UInt32(fromNodeNum)//0 // Send 0 as from from phone to device to avoid warning about client trying to set node num
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.payload = try! routePacket.serializedData()
|
||||
dataMessage.portnum = PortNum.tracerouteApp
|
||||
dataMessage.wantResponse = wantResponse
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio: ToRadio!
|
||||
toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
let binaryData: Data = try! toRadio.serializedData()
|
||||
|
||||
if connectedPeripheral!.peripheral.state == CBPeripheralState.connected {
|
||||
connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse)
|
||||
success = true
|
||||
MeshLogger.log("🪧 Sent a Trace Route Packet to node: \(destNum).")
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
func sendWantConfig() {
|
||||
guard (connectedPeripheral!.peripheral.state == CBPeripheralState.connected) else { return }
|
||||
|
||||
|
|
@ -504,7 +533,20 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject {
|
|||
case .audioApp:
|
||||
MeshLogger.log("ℹ️ MESH PACKET received for Audio App UNHANDLED \(try! decodedInfo.packet.jsonString())")
|
||||
case .tracerouteApp:
|
||||
MeshLogger.log("ℹ️ MESH PACKET received for Trace Route App UNHANDLED \(try! decodedInfo.packet.jsonString())")
|
||||
if let routingMessage = try? RouteDiscovery(serializedData: decodedInfo.packet.decoded.payload) {
|
||||
|
||||
if routingMessage.route.count == 0 {
|
||||
MeshLogger.log("🪧 Trace Route request sent to \(decodedInfo.packet.from) was recieived directly.")
|
||||
} else {
|
||||
|
||||
var routeString = "🪧 Trace Route request returned: \(decodedInfo.packet.to) --> "
|
||||
for node in routingMessage.route {
|
||||
routeString += "\(node) --> "
|
||||
}
|
||||
routeString += "\(decodedInfo.packet.from)"
|
||||
MeshLogger.log(routeString)
|
||||
}
|
||||
}
|
||||
case .UNRECOGNIZED(_):
|
||||
MeshLogger.log("ℹ️ MESH PACKET received for Other App UNHANDLED \(try! decodedInfo.packet.jsonString())")
|
||||
case .max:
|
||||
|
|
@ -620,6 +662,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject {
|
|||
newMessage.replyID = replyID
|
||||
}
|
||||
newMessage.messagePayload = message
|
||||
newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: message)
|
||||
|
||||
let dataType = PortNum.textMessageApp
|
||||
let payloadData: Data = message.data(using: String.Encoding.utf8)!
|
||||
|
|
@ -995,7 +1038,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject {
|
|||
if let decodedData = Data(base64Encoded: decodedString) {
|
||||
do {
|
||||
let channelSet: ChannelSet = try ChannelSet(serializedData: decodedData)
|
||||
print(channelSet)
|
||||
var i:Int32 = 0
|
||||
for cs in channelSet.settings {
|
||||
var chan = Channel()
|
||||
|
|
@ -1052,6 +1094,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject {
|
|||
MeshLogger.log("✈️ Sent a LoRaConfig for: \(String(self.connectedPeripheral.num))")
|
||||
}
|
||||
return true
|
||||
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,33 @@ import Foundation
|
|||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
func generateMessageMarkdown (message: String) -> String {
|
||||
|
||||
let types: NSTextCheckingResult.CheckingType = [.address, .link, .phoneNumber]
|
||||
let detector = try! NSDataDetector(types: types.rawValue)
|
||||
let matches = detector.matches(in: message, options: [], range: NSRange(location: 0, length: message.utf16.count))
|
||||
var messageWithMarkdown = message
|
||||
if matches.count > 0 {
|
||||
|
||||
for match in matches {
|
||||
guard let range = Range(match.range, in: message) else { continue }
|
||||
if match.resultType == .address {
|
||||
let address = message[range]
|
||||
let urlEncodedAddress = address.addingPercentEncoding(withAllowedCharacters: .alphanumerics)
|
||||
messageWithMarkdown = messageWithMarkdown.replacingOccurrences(of: address, with: "[\(address)](http://maps.apple.com/?address=\(urlEncodedAddress ?? ""))")
|
||||
} else if match.resultType == .phoneNumber {
|
||||
let phone = messageWithMarkdown[range]
|
||||
messageWithMarkdown = messageWithMarkdown.replacingOccurrences(of: phone, with: "[\(phone)](tel:\(phone))")
|
||||
} else if match.resultType == .link {
|
||||
let url = messageWithMarkdown[range]
|
||||
let absoluteUrl = match.url?.absoluteString ?? ""
|
||||
messageWithMarkdown = messageWithMarkdown.replacingOccurrences(of: url, with: "[\(String(match.url?.host ?? "Link"))\(String(match.url?.path ?? ""))](\(absoluteUrl))")
|
||||
}
|
||||
}
|
||||
}
|
||||
return messageWithMarkdown
|
||||
}
|
||||
|
||||
func localConfig (config: Config, context:NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) {
|
||||
|
||||
// We don't care about any of the Power settings, config is available for everyting else
|
||||
|
|
@ -409,20 +436,36 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum
|
|||
if fetchedNode[0].externalNotificationConfig == nil {
|
||||
let newExternalNotificationConfig = ExternalNotificationConfigEntity(context: context)
|
||||
newExternalNotificationConfig.enabled = config.externalNotification.enabled
|
||||
newExternalNotificationConfig.usePWM = config.externalNotification.usePwm
|
||||
newExternalNotificationConfig.alertBell = config.externalNotification.alertBell
|
||||
newExternalNotificationConfig.alertBellBuzzer = config.externalNotification.alertBellBuzzer
|
||||
newExternalNotificationConfig.alertBellVibra = config.externalNotification.alertBellVibra
|
||||
newExternalNotificationConfig.alertMessage = config.externalNotification.alertMessage
|
||||
newExternalNotificationConfig.alertMessageBuzzer = config.externalNotification.alertMessageBuzzer
|
||||
newExternalNotificationConfig.alertMessageVibra = config.externalNotification.alertMessageVibra
|
||||
newExternalNotificationConfig.active = config.externalNotification.active
|
||||
newExternalNotificationConfig.output = Int32(config.externalNotification.output)
|
||||
newExternalNotificationConfig.outputBuzzer = Int32(config.externalNotification.outputBuzzer)
|
||||
newExternalNotificationConfig.outputVibra = Int32(config.externalNotification.outputVibra)
|
||||
newExternalNotificationConfig.outputMilliseconds = Int32(config.externalNotification.outputMs)
|
||||
newExternalNotificationConfig.nagTimeout = Int32(config.externalNotification.nagTimeout)
|
||||
fetchedNode[0].externalNotificationConfig = newExternalNotificationConfig
|
||||
|
||||
} else {
|
||||
fetchedNode[0].externalNotificationConfig?.enabled = config.externalNotification.enabled
|
||||
fetchedNode[0].externalNotificationConfig?.usePWM = config.externalNotification.usePwm
|
||||
fetchedNode[0].externalNotificationConfig?.alertBell = config.externalNotification.alertBell
|
||||
fetchedNode[0].externalNotificationConfig?.alertBellBuzzer = config.externalNotification.alertBellBuzzer
|
||||
fetchedNode[0].externalNotificationConfig?.alertBellVibra = config.externalNotification.alertBellVibra
|
||||
fetchedNode[0].externalNotificationConfig?.alertMessage = config.externalNotification.alertMessage
|
||||
fetchedNode[0].externalNotificationConfig?.alertMessageBuzzer = config.externalNotification.alertMessageBuzzer
|
||||
fetchedNode[0].externalNotificationConfig?.alertMessageVibra = config.externalNotification.alertMessageVibra
|
||||
fetchedNode[0].externalNotificationConfig?.active = config.externalNotification.active
|
||||
fetchedNode[0].externalNotificationConfig?.output = Int32(config.externalNotification.output)
|
||||
fetchedNode[0].externalNotificationConfig?.outputBuzzer = Int32(config.externalNotification.outputBuzzer)
|
||||
fetchedNode[0].externalNotificationConfig?.outputVibra = Int32(config.externalNotification.outputVibra)
|
||||
fetchedNode[0].externalNotificationConfig?.outputMilliseconds = Int32(config.externalNotification.outputMs)
|
||||
fetchedNode[0].externalNotificationConfig?.nagTimeout = Int32(config.externalNotification.nagTimeout)
|
||||
}
|
||||
|
||||
do {
|
||||
|
|
@ -681,7 +724,6 @@ func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedO
|
|||
myInfoEntity.hasGps = myInfo.hasGps_p
|
||||
myInfoEntity.hasWifi = myInfo.hasWifi_p
|
||||
myInfoEntity.bitrate = myInfo.bitrate
|
||||
|
||||
// Swift does strings weird, this does work to get the version without the github hash
|
||||
let lastDotIndex = myInfo.firmwareVersion.lastIndex(of: ".")
|
||||
var version = myInfo.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: myInfo.firmwareVersion))]
|
||||
|
|
@ -692,15 +734,12 @@ func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedO
|
|||
myInfoEntity.maxChannels = Int32(bitPattern: myInfo.maxChannels)
|
||||
|
||||
do {
|
||||
|
||||
try context.save()
|
||||
MeshLogger.log("💾 Saved a new myInfo for node number: \(String(myInfo.myNodeNum))")
|
||||
return myInfoEntity
|
||||
|
||||
} catch {
|
||||
|
||||
context.rollback()
|
||||
|
||||
let nsError = error as NSError
|
||||
print("💥 Error Inserting New Core Data MyInfoEntity: \(nsError)")
|
||||
}
|
||||
|
|
@ -711,7 +750,6 @@ func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedO
|
|||
fetchedMyInfo[0].myNodeNum = Int64(myInfo.myNodeNum)
|
||||
fetchedMyInfo[0].hasGps = myInfo.hasGps_p
|
||||
fetchedMyInfo[0].bitrate = myInfo.bitrate
|
||||
|
||||
let lastDotIndex = myInfo.firmwareVersion.lastIndex(of: ".")//.lastIndex(of: ".", offsetBy: -1)
|
||||
var version = myInfo.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset:6, in: myInfo.firmwareVersion))]
|
||||
version = version.dropLast()
|
||||
|
|
@ -721,20 +759,16 @@ func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedO
|
|||
fetchedMyInfo[0].maxChannels = Int32(bitPattern: myInfo.maxChannels)
|
||||
|
||||
do {
|
||||
|
||||
try context.save()
|
||||
MeshLogger.log("💾 Updated myInfo for node number: \(String(myInfo.myNodeNum))")
|
||||
return fetchedMyInfo[0]
|
||||
|
||||
} catch {
|
||||
|
||||
context.rollback()
|
||||
|
||||
let nsError = error as NSError
|
||||
print("💥 Error Updating Core Data MyInfoEntity: \(nsError)")
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
print("💥 Fetch MyInfo Error")
|
||||
|
|
@ -1109,7 +1143,6 @@ func positionPacket (packet: MeshPacket, context: NSManagedObjectContext) {
|
|||
}
|
||||
|
||||
func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSManagedObjectContext) {
|
||||
print("Routing packet", packet)
|
||||
|
||||
if let routingMessage = try? Routing(serializedData: packet.decoded.payload) {
|
||||
|
||||
|
|
@ -1285,8 +1318,9 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM
|
|||
if fetchedUsers.first(where: { $0.num == packet.from }) != nil {
|
||||
newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from })
|
||||
}
|
||||
|
||||
newMessage.messagePayload = messageText
|
||||
newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: messageText)
|
||||
|
||||
newMessage.fromUser?.objectWillChange.send()
|
||||
newMessage.toUser?.objectWillChange.send()
|
||||
|
||||
|
|
@ -1320,6 +1354,10 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM
|
|||
do {
|
||||
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) as! [MyInfoEntity]
|
||||
for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] {
|
||||
if channel.index == newMessage.channel {
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
}
|
||||
|
||||
if channel.index == newMessage.channel && !channel.mute {
|
||||
// Create an iOS Notification for the received private channel message and schedule it immediately
|
||||
let manager = LocalNotificationManager()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>MeshtasticDataModelV3.xcdatamodel</string>
|
||||
<string>MeshtasticDataModelV4.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A400" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="BluetoothConfigEntity" representedClassName="BluetoothConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="fixedPin" optional="YES" attributeType="Integer 32" defaultValueString="123456" usesScalarValueType="YES"/>
|
||||
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="bluetoothConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="bluetoothConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="CannedMessageConfigEntity" representedClassName="CannedMessageConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="inputbrokerEventCcw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="inputbrokerEventCw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="inputbrokerEventPress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="inputbrokerPinA" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="inputbrokerPinB" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
|
||||
<attribute name="inputbrokerPinPress" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
|
||||
<attribute name="rotary1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="updown1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<relationship name="cannedMessagesConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="cannedMessageConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="ChannelEntity" representedClassName="ChannelEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="downlinkEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="index" attributeType="Integer 32" minValueString="0" maxValueString="13" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="psk" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="uplinkEnabled" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<relationship name="myInfoChannel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="channels" inverseEntity="MyInfoEntity"/>
|
||||
<fetchedProperty name="allPrivateMessages" optional="YES">
|
||||
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="channel == $FETCH_SOURCE.index && toUser == nil AND isEmoji == false"/>
|
||||
</fetchedProperty>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="index"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="DeviceConfigEntity" representedClassName="DeviceConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="buttonGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="buzzerGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="debugLogEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="serialEnabled" optional="YES" attributeType="Boolean" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="deviceConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="deviceConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="DisplayConfigEntity" representedClassName="DisplayConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="compassNorthTop" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="flipScreen" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="gpsFormat" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="oledType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="screenCarouselInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="screenOnSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="displayConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="displayConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="ExternalNotificationConfigEntity" representedClassName="ExternalNotificationConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="active" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="alertBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="alertBellBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="alertBellVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="alertMessage" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="alertMessageBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="alertMessageVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="nagTimeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="output" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="outputBuzzer" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="outputMilliseconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="outputVibra" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="usePWM" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<relationship name="externalNotificationConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="externalNotificationConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="LoRaConfigEntity" representedClassName="LoRaConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="bandwidth" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="channelNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="codingRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="frequencyOffset" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="hopLimit" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="modemPreset" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="regionCode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="spreadFactor" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="txEnabled" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="txPower" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="usePreset" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<relationship name="loRaConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="loRaConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="MessageEntity" representedClassName="MessageEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="ackError" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="ackSNR" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="ackTimestamp" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="admin" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="adminDescription" optional="YES" attributeType="String"/>
|
||||
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="isEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="messageId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="messagePayload" optional="YES" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="messagePayloadMarkdown" optional="YES" attributeType="String"/>
|
||||
<attribute name="messageTimestamp" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="receivedACK" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<relationship name="fromUser" optional="YES" maxCount="1" deletionRule="Nullify" ordered="YES" destinationEntity="UserEntity" inverseName="sentMessages" inverseEntity="UserEntity"/>
|
||||
<relationship name="toUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="receivedMessages" inverseEntity="UserEntity"/>
|
||||
<fetchedProperty name="tapbacks" optional="YES">
|
||||
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="replyID == $FETCH_SOURCE.messageId AND isEmoji == true"/>
|
||||
</fetchedProperty>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="messageId"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="MQTTConfigEntity" representedClassName="MQTTConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="address" optional="YES" attributeType="String" maxValueString="30"/>
|
||||
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="encryptionEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="jsonEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="password" optional="YES" attributeType="String" maxValueString="30"/>
|
||||
<attribute name="username" optional="YES" attributeType="String" maxValueString="30"/>
|
||||
<relationship name="mqttConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="mqttConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="MyInfoEntity" representedClassName="MyInfoEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="bitrate" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="bleName" optional="YES" attributeType="String"/>
|
||||
<attribute name="errorCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="firmwareVersion" attributeType="String"/>
|
||||
<attribute name="hasGps" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="hasWifi" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="maxChannels" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="messageTimeoutMsec" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="minAppVersion" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="myNodeNum" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="peripheralId" optional="YES" attributeType="String"/>
|
||||
<attribute name="rebootCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="channels" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="ChannelEntity" inverseName="myInfoChannel" inverseEntity="ChannelEntity"/>
|
||||
<relationship name="myInfoNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="myInfo" inverseEntity="NodeInfoEntity"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="myNodeNum"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="NetworkConfigEntity" representedClassName="NetworkConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="dns" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="ethEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="gateway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="ip" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="ntpServer" optional="YES" attributeType="String"/>
|
||||
<attribute name="subnet" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="wifiEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="wifiMode" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="wifiPsk" optional="YES" attributeType="String" minValueString="0" maxValueString="60"/>
|
||||
<attribute name="wifiSsid" optional="YES" attributeType="String" minValueString="0" maxValueString="30"/>
|
||||
<relationship name="networkConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="networkConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="NodeInfoEntity" representedClassName="NodeInfoEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="bleName" optional="YES" attributeType="String"/>
|
||||
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="lastHeard" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<relationship name="bluetoothConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="BluetoothConfigEntity" inverseName="bluetoothConfigNode" inverseEntity="BluetoothConfigEntity"/>
|
||||
<relationship name="cannedMessageConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CannedMessageConfigEntity" inverseName="cannedMessagesConfigNode" inverseEntity="CannedMessageConfigEntity"/>
|
||||
<relationship name="deviceConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DeviceConfigEntity" inverseName="deviceConfigNode" inverseEntity="DeviceConfigEntity"/>
|
||||
<relationship name="displayConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DisplayConfigEntity" inverseName="displayConfigNode" inverseEntity="DisplayConfigEntity"/>
|
||||
<relationship name="externalNotificationConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ExternalNotificationConfigEntity" inverseName="externalNotificationConfigNode" inverseEntity="ExternalNotificationConfigEntity"/>
|
||||
<relationship name="loRaConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LoRaConfigEntity" inverseName="loRaConfigNode" inverseEntity="LoRaConfigEntity"/>
|
||||
<relationship name="mqttConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MQTTConfigEntity" inverseName="mqttConfigNode" inverseEntity="MQTTConfigEntity"/>
|
||||
<relationship name="myInfo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="myInfoNode" inverseEntity="MyInfoEntity"/>
|
||||
<relationship name="networkConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NetworkConfigEntity" inverseName="networkConfigNode" inverseEntity="NetworkConfigEntity"/>
|
||||
<relationship name="positionConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PositionConfigEntity" inverseName="positionConfigNode" inverseEntity="PositionConfigEntity"/>
|
||||
<relationship name="positions" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PositionEntity" inverseName="nodePosition" inverseEntity="PositionEntity"/>
|
||||
<relationship name="rangeTestConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RangeTestConfigEntity" inverseName="rangeTestConfigNode" inverseEntity="RangeTestConfigEntity"/>
|
||||
<relationship name="serialConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SerialConfigEntity" inverseName="serialConfigNode" inverseEntity="SerialConfigEntity"/>
|
||||
<relationship name="telemetries" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TelemetryEntity" inverseName="nodeTelemetry" inverseEntity="TelemetryEntity"/>
|
||||
<relationship name="telemetryConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TelemetryConfigEntity" inverseName="telemetryConfigNode" inverseEntity="TelemetryConfigEntity"/>
|
||||
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="userNode" inverseEntity="UserEntity"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="num"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="PositionConfigEntity" representedClassName="PositionConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="deviceGpsEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="fixedPosition" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="gpsAttemptTime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="gpsUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="positionBroadcastSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="rxGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="smartPositionEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="txGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="positionConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positionConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="PositionEntity" representedClassName="PositionEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="satsInView" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="seqNo" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="nodePosition" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positions" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="RangeTestConfigEntity" representedClassName="RangeTestConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="save" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="sender" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
|
||||
<relationship name="rangeTestConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rangeTestConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="SerialConfigEntity" representedClassName="SerialConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="baudRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="echo" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="rxd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="timeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="txd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="serialConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="serialConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="TelemetryConfigEntity" representedClassName="TelemetryConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="deviceUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="environmentDisplayFahrenheit" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="environmentMeasurementEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="environmentScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="environmentUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="telemetryConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetryConfig" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="airUtilTx" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="barometricPressure" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="channelUtilization" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="current" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="gasResistance" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="metricsType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="relativeHumidity" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="temperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="voltage" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<relationship name="nodeTelemetry" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetries" inverseEntity="NodeInfoEntity"/>
|
||||
</entity>
|
||||
<entity name="UserEntity" representedClassName="UserEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="hwModel" attributeType="String"/>
|
||||
<attribute name="isLicensed" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="longName" attributeType="String"/>
|
||||
<attribute name="macaddr" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="shortName" attributeType="String"/>
|
||||
<attribute name="userId" attributeType="String"/>
|
||||
<relationship name="receivedMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="toUser" inverseEntity="MessageEntity"/>
|
||||
<relationship name="sentMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="fromUser" inverseEntity="MessageEntity"/>
|
||||
<relationship name="userNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="user" inverseEntity="NodeInfoEntity"/>
|
||||
<fetchedProperty name="adminMessages" optional="YES">
|
||||
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="(toUser.num == $FETCH_SOURCE.num) AND isEmoji == false AND admin = true"/>
|
||||
</fetchedProperty>
|
||||
<fetchedProperty name="allMessages" optional="YES">
|
||||
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="((toUser.num == $FETCH_SOURCE.num) OR (fromUser.num == $FETCH_SOURCE.num)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false"/>
|
||||
</fetchedProperty>
|
||||
</entity>
|
||||
<entity name="WaypointEntity" representedClassName="WaypointEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="expire" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="id" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="longDescription" optional="YES" attributeType="String" maxValueString="100"/>
|
||||
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="name" optional="YES" attributeType="String" maxValueString="30"/>
|
||||
</entity>
|
||||
</model>
|
||||
|
|
@ -481,33 +481,71 @@ struct ModuleConfig {
|
|||
// methods supported on all messages.
|
||||
|
||||
///
|
||||
/// Preferences for the ExternalNotificationModule
|
||||
/// Enable the ExternalNotificationModule
|
||||
var enabled: Bool = false
|
||||
|
||||
///
|
||||
/// TODO: REPLACE
|
||||
/// When using in On/Off mode, keep the output on for this many
|
||||
/// milliseconds. Default 1000ms (1 second).
|
||||
var outputMs: UInt32 = 0
|
||||
|
||||
///
|
||||
/// TODO: REPLACE
|
||||
/// Define the output pin GPIO setting Defaults to
|
||||
/// EXT_NOTIFY_OUT if set for the board.
|
||||
/// In standalone devices this pin should drive the LED to match the UI.
|
||||
var output: UInt32 = 0
|
||||
|
||||
///
|
||||
/// TODO: REPLACE
|
||||
/// Optional: Define a secondary output pin for a vibra motor
|
||||
/// This is used in standalone devices to match the UI.
|
||||
var outputVibra: UInt32 = 0
|
||||
|
||||
///
|
||||
/// Optional: Define a tertiary output pin for an active buzzer
|
||||
/// This is used in standalone devices to to match the UI.
|
||||
var outputBuzzer: UInt32 = 0
|
||||
|
||||
///
|
||||
/// IF this is true, the 'output' Pin will be pulled active high, false
|
||||
/// means active low.
|
||||
var active: Bool = false
|
||||
|
||||
///
|
||||
/// TODO: REPLACE
|
||||
/// True: Alert when a text message arrives (output)
|
||||
var alertMessage: Bool = false
|
||||
|
||||
///
|
||||
/// TODO: REPLACE
|
||||
/// True: Alert when a text message arrives (output_vibra)
|
||||
var alertMessageVibra: Bool = false
|
||||
|
||||
///
|
||||
/// True: Alert when a text message arrives (output_buzzer)
|
||||
var alertMessageBuzzer: Bool = false
|
||||
|
||||
///
|
||||
/// True: Alert when the bell character is received (output)
|
||||
var alertBell: Bool = false
|
||||
|
||||
///
|
||||
/// TODO: REPLACE
|
||||
/// True: Alert when the bell character is received (output_vibra)
|
||||
var alertBellVibra: Bool = false
|
||||
|
||||
///
|
||||
/// True: Alert when the bell character is received (output_buzzer)
|
||||
var alertBellBuzzer: Bool = false
|
||||
|
||||
///
|
||||
/// use a PWM output instead of a simple on/off output. This will ignore
|
||||
/// the 'output', 'output_ms' and 'active' settings and use the
|
||||
/// device.buzzer_gpio instead.
|
||||
var usePwm: Bool = false
|
||||
|
||||
///
|
||||
/// The notification will toggle with 'output_ms' for this time of seconds.
|
||||
/// Default is 0 which means don't repeat at all. 60 would mean blink
|
||||
/// and/or beep for 60 seconds
|
||||
var nagTimeout: UInt32 = 0
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
|
@ -1248,10 +1286,17 @@ extension ModuleConfig.ExternalNotificationConfig: SwiftProtobuf.Message, SwiftP
|
|||
1: .same(proto: "enabled"),
|
||||
2: .standard(proto: "output_ms"),
|
||||
3: .same(proto: "output"),
|
||||
8: .standard(proto: "output_vibra"),
|
||||
9: .standard(proto: "output_buzzer"),
|
||||
4: .same(proto: "active"),
|
||||
5: .standard(proto: "alert_message"),
|
||||
10: .standard(proto: "alert_message_vibra"),
|
||||
11: .standard(proto: "alert_message_buzzer"),
|
||||
6: .standard(proto: "alert_bell"),
|
||||
12: .standard(proto: "alert_bell_vibra"),
|
||||
13: .standard(proto: "alert_bell_buzzer"),
|
||||
7: .standard(proto: "use_pwm"),
|
||||
14: .standard(proto: "nag_timeout"),
|
||||
]
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
|
|
@ -1267,6 +1312,13 @@ extension ModuleConfig.ExternalNotificationConfig: SwiftProtobuf.Message, SwiftP
|
|||
case 5: try { try decoder.decodeSingularBoolField(value: &self.alertMessage) }()
|
||||
case 6: try { try decoder.decodeSingularBoolField(value: &self.alertBell) }()
|
||||
case 7: try { try decoder.decodeSingularBoolField(value: &self.usePwm) }()
|
||||
case 8: try { try decoder.decodeSingularUInt32Field(value: &self.outputVibra) }()
|
||||
case 9: try { try decoder.decodeSingularUInt32Field(value: &self.outputBuzzer) }()
|
||||
case 10: try { try decoder.decodeSingularBoolField(value: &self.alertMessageVibra) }()
|
||||
case 11: try { try decoder.decodeSingularBoolField(value: &self.alertMessageBuzzer) }()
|
||||
case 12: try { try decoder.decodeSingularBoolField(value: &self.alertBellVibra) }()
|
||||
case 13: try { try decoder.decodeSingularBoolField(value: &self.alertBellBuzzer) }()
|
||||
case 14: try { try decoder.decodeSingularUInt32Field(value: &self.nagTimeout) }()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
|
@ -1294,6 +1346,27 @@ extension ModuleConfig.ExternalNotificationConfig: SwiftProtobuf.Message, SwiftP
|
|||
if self.usePwm != false {
|
||||
try visitor.visitSingularBoolField(value: self.usePwm, fieldNumber: 7)
|
||||
}
|
||||
if self.outputVibra != 0 {
|
||||
try visitor.visitSingularUInt32Field(value: self.outputVibra, fieldNumber: 8)
|
||||
}
|
||||
if self.outputBuzzer != 0 {
|
||||
try visitor.visitSingularUInt32Field(value: self.outputBuzzer, fieldNumber: 9)
|
||||
}
|
||||
if self.alertMessageVibra != false {
|
||||
try visitor.visitSingularBoolField(value: self.alertMessageVibra, fieldNumber: 10)
|
||||
}
|
||||
if self.alertMessageBuzzer != false {
|
||||
try visitor.visitSingularBoolField(value: self.alertMessageBuzzer, fieldNumber: 11)
|
||||
}
|
||||
if self.alertBellVibra != false {
|
||||
try visitor.visitSingularBoolField(value: self.alertBellVibra, fieldNumber: 12)
|
||||
}
|
||||
if self.alertBellBuzzer != false {
|
||||
try visitor.visitSingularBoolField(value: self.alertBellBuzzer, fieldNumber: 13)
|
||||
}
|
||||
if self.nagTimeout != 0 {
|
||||
try visitor.visitSingularUInt32Field(value: self.nagTimeout, fieldNumber: 14)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
|
|
@ -1301,10 +1374,17 @@ extension ModuleConfig.ExternalNotificationConfig: SwiftProtobuf.Message, SwiftP
|
|||
if lhs.enabled != rhs.enabled {return false}
|
||||
if lhs.outputMs != rhs.outputMs {return false}
|
||||
if lhs.output != rhs.output {return false}
|
||||
if lhs.outputVibra != rhs.outputVibra {return false}
|
||||
if lhs.outputBuzzer != rhs.outputBuzzer {return false}
|
||||
if lhs.active != rhs.active {return false}
|
||||
if lhs.alertMessage != rhs.alertMessage {return false}
|
||||
if lhs.alertMessageVibra != rhs.alertMessageVibra {return false}
|
||||
if lhs.alertMessageBuzzer != rhs.alertMessageBuzzer {return false}
|
||||
if lhs.alertBell != rhs.alertBell {return false}
|
||||
if lhs.alertBellVibra != rhs.alertBellVibra {return false}
|
||||
if lhs.alertBellBuzzer != rhs.alertBellBuzzer {return false}
|
||||
if lhs.usePwm != rhs.usePwm {return false}
|
||||
if lhs.nagTimeout != rhs.nagTimeout {return false}
|
||||
if lhs.unknownFields != rhs.unknownFields {return false}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import SwiftUI
|
|||
import CoreData
|
||||
|
||||
struct ChannelMessageList: View {
|
||||
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var userSettings: UserSettings
|
||||
|
|
@ -29,7 +29,7 @@ struct ChannelMessageList: View {
|
|||
@State private var deleteMessageId: Int64 = 0
|
||||
@State private var replyMessageId: Int64 = 0
|
||||
@State private var sendPositionWithMessage: Bool = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { scrollView in
|
||||
|
|
@ -60,7 +60,10 @@ struct ChannelMessageList: View {
|
|||
.offset(y: -5)
|
||||
}
|
||||
VStack(alignment: currentUser ? .trailing : .leading) {
|
||||
Text(message.messagePayload ?? "EMPTY MESSAGE")
|
||||
let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
|
||||
let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
|
||||
Text(markdownText)
|
||||
.tint(linkBlue)
|
||||
.padding(10)
|
||||
.foregroundColor(.white)
|
||||
.background(currentUser ? .accentColor : Color(.gray))
|
||||
|
|
@ -241,7 +244,7 @@ struct ChannelMessageList: View {
|
|||
|
||||
typingMessage = "📍 " + userLongName + " has shared their position with you."
|
||||
}
|
||||
|
||||
|
||||
} label: {
|
||||
Text("share.position")
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
|
|
@ -257,7 +260,7 @@ struct ChannelMessageList: View {
|
|||
}
|
||||
#endif
|
||||
HStack(alignment: .top) {
|
||||
|
||||
|
||||
ZStack {
|
||||
let kbType = UIKeyboardType(rawValue: UserDefaults.standard.object(forKey: "keyboardType") as? Int ?? 0)
|
||||
TextField("message", text: $typingMessage, axis: .vertical)
|
||||
|
|
@ -293,13 +296,13 @@ struct ChannelMessageList: View {
|
|||
|
||||
typingMessage = "📍 " + userLongName + " has shared their position with you."
|
||||
}
|
||||
|
||||
|
||||
} label: {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.imageScale(.large).foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
|
||||
ProgressView("\(NSLocalizedString("bytes", comment: "")): \(totalBytes) / \(maxbytes)", value: Double(totalBytes), total: Double(maxbytes))
|
||||
.frame(width: 130)
|
||||
.padding(5)
|
||||
|
|
@ -313,18 +316,18 @@ struct ChannelMessageList: View {
|
|||
.frame(minHeight: 50)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.onSubmit {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if bleManager.sendMessage(message: typingMessage, toUserNum: 0, channel: channel.index, isEmoji: false, replyID: replyMessageId) {
|
||||
typingMessage = ""
|
||||
focusedField = nil
|
||||
replyMessageId = 0
|
||||
if sendPositionWithMessage {
|
||||
if bleManager.sendPosition(destNum: Int64(channel.index), wantAck: true) {
|
||||
if bleManager.sendPosition(destNum: Int64(channel.index), wantResponse: false) {
|
||||
print("Location Sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
Text(typingMessage).opacity(0).padding(.all, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ struct Contacts: View {
|
|||
@State private var selection: UserEntity? = nil // Nothing selected by default.
|
||||
@State private var isPresentingDeleteChannelMessagesConfirm: Bool = false
|
||||
@State private var isPresentingDeleteUserMessagesConfirm: Bool = false
|
||||
@State private var isPresentingTraceRouteSentAlert = false
|
||||
|
||||
var body: some View {
|
||||
|
||||
|
|
@ -194,6 +195,14 @@ struct Contacts: View {
|
|||
} label: {
|
||||
Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash")
|
||||
}
|
||||
Button {
|
||||
let success = bleManager.sendTraceRouteRequest(destNum: user.num, wantResponse: true)
|
||||
if success {
|
||||
isPresentingTraceRouteSentAlert = true
|
||||
}
|
||||
} label: {
|
||||
Label("Trace Route", systemImage: "signpost.right.and.left")
|
||||
}
|
||||
if user.messageList.count > 0 {
|
||||
Button(role: .destructive) {
|
||||
isPresentingDeleteUserMessagesConfirm = true
|
||||
|
|
@ -202,12 +211,21 @@ struct Contacts: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"Trace Route Sent",
|
||||
isPresented: $isPresentingTraceRouteSentAlert
|
||||
)
|
||||
{
|
||||
Button("OK", role: .cancel) { }
|
||||
}
|
||||
message: {
|
||||
Text("This could take a while, response will appear in the mesh log.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"This conversation will be deleted.",
|
||||
isPresented: $isPresentingDeleteUserMessagesConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
|
||||
Button(role: .destructive) {
|
||||
deleteUserMessages(user: user, context: context)
|
||||
context.refresh(node!.user!, mergeChanges: true)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import SwiftUI
|
|||
import CoreData
|
||||
|
||||
struct UserMessageList: View {
|
||||
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var userSettings: UserSettings
|
||||
|
|
@ -28,8 +28,8 @@ struct UserMessageList: View {
|
|||
@State private var deleteMessageId: Int64 = 0
|
||||
@State private var replyMessageId: Int64 = 0
|
||||
@State private var sendPositionWithMessage: Bool = false
|
||||
|
||||
var body: some View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
|
|
@ -37,7 +37,7 @@ struct UserMessageList: View {
|
|||
ForEach( user.messageList ) { (message: MessageEntity) in
|
||||
if user.num != userSettings.preferredNodeNum {
|
||||
let currentUser: Bool = (userSettings.preferredNodeNum == message.fromUser?.num ? true : false)
|
||||
|
||||
|
||||
if message.replyID > 0 {
|
||||
let messageReply = user.messageList.first(where: { $0.messageId == message.replyID })
|
||||
HStack {
|
||||
|
|
@ -61,7 +61,11 @@ struct UserMessageList: View {
|
|||
.offset(y: -5)
|
||||
}
|
||||
VStack(alignment: currentUser ? .trailing : .leading) {
|
||||
Text(message.messagePayload ?? "EMPTY MESSAGE")
|
||||
let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
|
||||
|
||||
let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
|
||||
Text(markdownText)
|
||||
.tint(linkBlue)
|
||||
.padding(10)
|
||||
.foregroundColor(.white)
|
||||
.background(currentUser ? .accentColor : Color(.gray))
|
||||
|
|
@ -256,7 +260,7 @@ struct UserMessageList: View {
|
|||
.padding(.trailing)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
HStack(alignment: .top) {
|
||||
ZStack {
|
||||
let kbType = UIKeyboardType(rawValue: UserDefaults.standard.object(forKey: "keyboardType") as? Int ?? 0)
|
||||
|
|
@ -310,18 +314,18 @@ struct UserMessageList: View {
|
|||
.frame(minHeight: 50)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.onSubmit {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if bleManager.sendMessage(message: typingMessage, toUserNum: user.num, channel: 0, isEmoji: false, replyID: replyMessageId) {
|
||||
typingMessage = ""
|
||||
focusedField = nil
|
||||
replyMessageId = 0
|
||||
if sendPositionWithMessage {
|
||||
if bleManager.sendPosition(destNum: user.num, wantAck: true) {
|
||||
if bleManager.sendPosition(destNum: user.num, wantResponse: true) {
|
||||
print("Location Sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
Text(typingMessage).opacity(0).padding(.all, 0)
|
||||
}
|
||||
|
|
@ -361,5 +365,5 @@ struct UserMessageList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,43 +75,50 @@ struct DeviceMetricsLog: View {
|
|||
|
||||
} else {
|
||||
ScrollView {
|
||||
Grid(alignment: .topLeading, horizontalSpacing: 2) {
|
||||
|
||||
let columns = [
|
||||
GridItem(),
|
||||
GridItem(),
|
||||
GridItem(),
|
||||
GridItem(),
|
||||
GridItem(.fixed(120))
|
||||
]
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 1) {
|
||||
GridRow {
|
||||
Text("Batt")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("Voltage")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("ChUtil")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("AirTm")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("Timestamp")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
Divider()
|
||||
ForEach(node.telemetries!.reversed() as! [TelemetryEntity], id: \.self) { (dm: TelemetryEntity) in
|
||||
if dm.metricsType == 0 {
|
||||
GridRow {
|
||||
if dm.batteryLevel == 0 {
|
||||
Text("USB")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("\(String(dm.batteryLevel))%")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(String(dm.voltage))
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
Text("\(String(format: "%.2f", dm.channelUtilization))%")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
Text("\(String(format: "%.2f", dm.airUtilTx))%")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
Text(dm.time?.formattedDate(format: "MM/dd/yy hh:mm") ?? "Unknown time")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,14 @@ struct EnvironmentMetricsLog: View {
|
|||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
|
||||
Grid(alignment: .topLeading, horizontalSpacing: 2) {
|
||||
let columns = [
|
||||
GridItem(),
|
||||
GridItem(),
|
||||
GridItem(),
|
||||
GridItem(),
|
||||
GridItem(.fixed(115))
|
||||
]
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 1) {
|
||||
|
||||
GridRow {
|
||||
|
||||
|
|
@ -80,17 +86,10 @@ struct EnvironmentMetricsLog: View {
|
|||
Text("Gas")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("DC")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("Volt")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("Timestamp")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
Divider()
|
||||
ForEach(node.telemetries!.reversed() as! [TelemetryEntity], id: \.self) { (em: TelemetryEntity) in
|
||||
|
||||
if em.metricsType == 1 {
|
||||
|
|
@ -105,10 +104,6 @@ struct EnvironmentMetricsLog: View {
|
|||
.font(.caption)
|
||||
Text("\(String(format: "%.2f", em.gasResistance))")
|
||||
.font(.caption)
|
||||
Text("\(String(format: "%.2f", em.current))")
|
||||
.font(.caption)
|
||||
Text("\(String(format: "%.2f", em.voltage))")
|
||||
.font(.caption)
|
||||
Text(em.time?.formattedDate(format: "MM/dd/yy hh:mm") ?? "Unknown time")
|
||||
.font(.caption)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,14 @@ struct PositionLog: View {
|
|||
|
||||
ScrollView {
|
||||
// Use a grid on iOS as a table only shows a single column
|
||||
Grid(alignment: .topLeading, horizontalSpacing: 2) {
|
||||
let columns = [
|
||||
GridItem(.fixed(95)),
|
||||
GridItem(.fixed(95)),
|
||||
GridItem(),
|
||||
GridItem(),
|
||||
GridItem(.fixed(115))
|
||||
]
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 1) {
|
||||
|
||||
GridRow {
|
||||
|
||||
|
|
@ -78,7 +85,6 @@ struct PositionLog: View {
|
|||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
Divider()
|
||||
ForEach(node.positions!.reversed() as! [PositionEntity], id: \.self) { (mappin: PositionEntity) in
|
||||
GridRow {
|
||||
Text(String(format: "%.6f", mappin.latitude ?? 0))
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//
|
||||
// ShareChannel.swift
|
||||
// MeshtasticApple
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 4/8/22.
|
||||
//
|
||||
////
|
||||
//// ShareChannel.swift
|
||||
//// MeshtasticApple
|
||||
////
|
||||
//// Copyright(c) Garth Vander Houwen 4/8/22.
|
||||
////
|
||||
//import SwiftUI
|
||||
//import CoreData
|
||||
//
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
import SwiftUI
|
||||
|
||||
enum OutputIntervals: Int, CaseIterable, Identifiable {
|
||||
|
||||
|
||||
case unset = 0
|
||||
case oneSecond = 1000
|
||||
case twoSeconds = 2000
|
||||
case threeSeconds = 3000
|
||||
|
|
@ -17,12 +18,14 @@ enum OutputIntervals: Int, CaseIterable, Identifiable {
|
|||
case fifteenSeconds = 15000
|
||||
case thirtySeconds = 30000
|
||||
case oneMinute = 60000
|
||||
|
||||
|
||||
var id: Int { self.rawValue }
|
||||
var description: String {
|
||||
get {
|
||||
switch self {
|
||||
|
||||
|
||||
case .unset:
|
||||
return "Unset"
|
||||
case .oneSecond:
|
||||
return "One Second"
|
||||
case .twoSeconds:
|
||||
|
|
@ -58,151 +61,249 @@ struct ExternalNotificationConfig: View {
|
|||
@State var hasChanges = false
|
||||
@State var enabled = false
|
||||
@State var alertBell = false
|
||||
@State var alertBellBuzzer = false
|
||||
@State var alertBellVibra = false
|
||||
@State var alertMessage = false
|
||||
@State var alertMessageBuzzer = false
|
||||
@State var alertMessageVibra = false
|
||||
@State var active = false
|
||||
@State var usePWM = true
|
||||
@State var usePWM = false
|
||||
@State var output = 0
|
||||
@State var outputBuzzer = 0
|
||||
@State var outputVibra = 0
|
||||
@State var outputMilliseconds = 0
|
||||
@State var nagTimeout = 0
|
||||
|
||||
var body: some View {
|
||||
|
||||
VStack {
|
||||
Form {
|
||||
Section(header: Text("options")) {
|
||||
Toggle(isOn: $enabled) {
|
||||
Label("enabled", systemImage: "megaphone")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $alertBell) {
|
||||
Label("Alert when receiving a bell", systemImage: "bell")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $alertMessage) {
|
||||
Label("Alert when receiving a message", systemImage: "message")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $usePWM) {
|
||||
Label("Use PWM Buzzer", systemImage: "light.beacon.max.fill")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Form {
|
||||
Section(header: Text("options")) {
|
||||
Toggle(isOn: $enabled) {
|
||||
Label("enabled", systemImage: "megaphone")
|
||||
}
|
||||
if !usePWM {
|
||||
Section(header: Text("GPIO")) {
|
||||
Toggle(isOn: $active) {
|
||||
Label("Active", systemImage: "togglepower")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Text("Specifies whether the external circuit is triggered when the device's GPIO is low or high.")
|
||||
.font(.caption)
|
||||
.listRowSeparator(.visible)
|
||||
Picker("GPIO to monitor", selection: $output) {
|
||||
ForEach(0..<40) {
|
||||
if $0 == 0 {
|
||||
Text("Unset")
|
||||
} else {
|
||||
Text("Pin \($0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
Text("Specifies the GPIO that your external circuit is attached to on the device.")
|
||||
.font(.caption)
|
||||
Picker("GPIO Output Duration", selection: $outputMilliseconds ) {
|
||||
ForEach(OutputIntervals.allCases) { oi in
|
||||
Text(oi.description)
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
Text("Specifies how long the monitored GPIO should output.")
|
||||
.font(.caption)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $alertBell) {
|
||||
Label("Alert when receiving a bell", systemImage: "bell")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $alertMessage) {
|
||||
Label("Alert when receiving a message", systemImage: "message")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $usePWM) {
|
||||
Label("Use PWM Buzzer", systemImage: "light.beacon.max.fill")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Text("Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead.")
|
||||
.font(.caption)
|
||||
}
|
||||
if !usePWM {
|
||||
Section(header: Text("Primary GPIO")) {
|
||||
Toggle(isOn: $active) {
|
||||
Label("Active", systemImage: "togglepower")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Text("If enabled, the 'output' Pin will be pulled active high, disabled means active low.")
|
||||
.font(.caption)
|
||||
Picker("Output pin GPIO", selection: $output) {
|
||||
ForEach(0..<40) {
|
||||
if $0 == 0 {
|
||||
Text("Unset")
|
||||
} else {
|
||||
Text("Pin \($0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
Picker("GPIO Output Duration", selection: $outputMilliseconds ) {
|
||||
ForEach(OutputIntervals.allCases) { oi in
|
||||
Text(oi.description)
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
Text("When using in GPIO mode, keep the output on for this long. ")
|
||||
.font(.caption)
|
||||
Picker("Nag timeout", selection: $nagTimeout ) {
|
||||
ForEach(OutputIntervals.allCases) { oi in
|
||||
Text(oi.description)
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
Text("Specifies how long the monitored GPIO should output.")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
}
|
||||
.disabled(bleManager.connectedPeripheral == nil)
|
||||
Button {
|
||||
isPresentingSaveConfirm = true
|
||||
} label: {
|
||||
Label("save", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.disabled(bleManager.connectedPeripheral == nil || !hasChanges)
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding()
|
||||
.confirmationDialog(
|
||||
"are.you.sure",
|
||||
isPresented: $isPresentingSaveConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Save External Notification Module Config to \(bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown")?") {
|
||||
var enc = ModuleConfig.ExternalNotificationConfig()
|
||||
enc.enabled = enabled
|
||||
enc.alertBell = alertBell
|
||||
enc.alertMessage = alertMessage
|
||||
enc.active = active
|
||||
enc.output = UInt32(output)
|
||||
enc.outputMs = UInt32(outputMilliseconds)
|
||||
enc.usePwm = usePWM
|
||||
let adminMessageId = bleManager.saveExternalNotificationModuleConfig(config: enc, fromUser: node!.user!, toUser: node!.user!)
|
||||
if adminMessageId > 0{
|
||||
// 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()
|
||||
Section(header: Text("Optional GPIO")) {
|
||||
Toggle(isOn: $alertBellBuzzer) {
|
||||
Label("Alert GPIO buzzer when receiving a bell", systemImage: "bell")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $alertBellVibra) {
|
||||
Label("Alert GPIO vibra motor when receiving a bell", systemImage: "bell")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $alertMessageBuzzer) {
|
||||
Label("Alert GPIO buzzer when receiving a message", systemImage: "message")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Toggle(isOn: $alertMessageBuzzer) {
|
||||
Label("Alert GPIO vibra motor when receiving a message", systemImage: "message")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
Picker("Output pin buzzer GPIO ", selection: $outputBuzzer) {
|
||||
ForEach(0..<40) {
|
||||
if $0 == 0 {
|
||||
Text("Unset")
|
||||
} else {
|
||||
Text("Pin \($0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
Picker("Output pin vibra GPIO", selection: $outputVibra) {
|
||||
ForEach(0..<40) {
|
||||
if $0 == 0 {
|
||||
Text("Unset")
|
||||
} else {
|
||||
Text("Pin \($0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
}
|
||||
}
|
||||
.navigationTitle("external.notification.config")
|
||||
.navigationBarItems(trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????")
|
||||
})
|
||||
.onAppear {
|
||||
self.bleManager.context = context
|
||||
self.enabled = node?.externalNotificationConfig?.enabled ?? false
|
||||
self.alertBell = node?.externalNotificationConfig?.alertBell ?? false
|
||||
self.alertMessage = node?.externalNotificationConfig?.alertMessage ?? false
|
||||
self.active = node?.externalNotificationConfig?.active ?? false
|
||||
self.output = Int(node?.externalNotificationConfig?.output ?? 0)
|
||||
self.outputMilliseconds = Int(node?.externalNotificationConfig?.outputMilliseconds ?? 0)
|
||||
self.usePWM = node?.externalNotificationConfig?.usePWM ?? true
|
||||
self.hasChanges = false
|
||||
}
|
||||
.onChange(of: enabled) { newEnabled in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newEnabled != node!.externalNotificationConfig!.enabled { hasChanges = true }
|
||||
}
|
||||
.disabled(bleManager.connectedPeripheral == nil)
|
||||
Button {
|
||||
isPresentingSaveConfirm = true
|
||||
} label: {
|
||||
Label("save", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.disabled(bleManager.connectedPeripheral == nil || !hasChanges)
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding()
|
||||
.confirmationDialog(
|
||||
"are.you.sure",
|
||||
isPresented: $isPresentingSaveConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Save External Notification Module Config to \(bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown")?") {
|
||||
var enc = ModuleConfig.ExternalNotificationConfig()
|
||||
enc.enabled = enabled
|
||||
enc.alertBell = alertBell
|
||||
enc.alertBellBuzzer = alertBellBuzzer
|
||||
enc.alertBellVibra = alertBellVibra
|
||||
enc.alertMessage = alertMessage
|
||||
enc.alertMessageBuzzer = alertMessageBuzzer
|
||||
enc.alertMessageVibra = alertMessageVibra
|
||||
enc.active = active
|
||||
enc.output = UInt32(output)
|
||||
enc.outputBuzzer = UInt32(outputBuzzer)
|
||||
enc.outputVibra = UInt32(outputVibra)
|
||||
enc.outputMs = UInt32(outputMilliseconds)
|
||||
enc.usePwm = usePWM
|
||||
let adminMessageId = bleManager.saveExternalNotificationModuleConfig(config: enc, fromUser: node!.user!, toUser: node!.user!)
|
||||
if adminMessageId > 0{
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
.onChange(of: alertBell) { newAlertBell in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertBell != node!.externalNotificationConfig!.alertBell { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.navigationTitle("external.notification.config")
|
||||
.navigationBarItems(trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????")
|
||||
})
|
||||
.onAppear {
|
||||
self.bleManager.context = context
|
||||
self.enabled = node?.externalNotificationConfig?.enabled ?? false
|
||||
self.alertBell = node?.externalNotificationConfig?.alertBell ?? false
|
||||
self.alertBellBuzzer = node?.externalNotificationConfig?.alertBellBuzzer ?? false
|
||||
self.alertBellVibra = node?.externalNotificationConfig?.alertBellVibra ?? false
|
||||
self.alertMessage = node?.externalNotificationConfig?.alertMessage ?? false
|
||||
self.alertMessageBuzzer = node?.externalNotificationConfig?.alertMessageBuzzer ?? false
|
||||
self.alertMessageVibra = node?.externalNotificationConfig?.alertMessageVibra ?? false
|
||||
self.active = node?.externalNotificationConfig?.active ?? false
|
||||
self.output = Int(node?.externalNotificationConfig?.output ?? 0)
|
||||
self.outputBuzzer = Int(node?.externalNotificationConfig?.outputBuzzer ?? 0)
|
||||
self.outputVibra = Int(node?.externalNotificationConfig?.outputVibra ?? 0)
|
||||
self.outputMilliseconds = Int(node?.externalNotificationConfig?.outputMilliseconds ?? 0)
|
||||
self.nagTimeout = Int(node?.externalNotificationConfig?.nagTimeout ?? 0)
|
||||
self.usePWM = node?.externalNotificationConfig?.usePWM ?? false
|
||||
self.hasChanges = false
|
||||
}
|
||||
.onChange(of: enabled) { newEnabled in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newEnabled != node!.externalNotificationConfig!.enabled { hasChanges = true }
|
||||
}
|
||||
.onChange(of: alertMessage) { newAlertMessage in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertMessage != node!.externalNotificationConfig!.alertMessage { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: alertBell) { newAlertBell in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertBell != node!.externalNotificationConfig!.alertBell { hasChanges = true }
|
||||
}
|
||||
.onChange(of: active) { newActuve in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newActuve != node!.externalNotificationConfig!.active { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: alertBellBuzzer) { newAlertBellBuzzer in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertBellBuzzer != node!.externalNotificationConfig!.alertBellBuzzer { hasChanges = true }
|
||||
}
|
||||
.onChange(of: output) { newOutput in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newOutput != node!.externalNotificationConfig!.output { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: alertBellVibra) { newAlertBellVibra in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertBellVibra != node!.externalNotificationConfig!.alertBellVibra { hasChanges = true }
|
||||
}
|
||||
.onChange(of: outputMilliseconds) { newOutputMs in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newOutputMs != node!.externalNotificationConfig!.outputMilliseconds { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: alertMessage) { newAlertMessage in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertMessage != node!.externalNotificationConfig!.alertMessage { hasChanges = true }
|
||||
}
|
||||
.onChange(of: usePWM) { newUsePWM in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newUsePWM != node!.externalNotificationConfig!.usePWM { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: alertMessageBuzzer) { newAlertMessageBuzzer in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertMessageBuzzer != node!.externalNotificationConfig!.alertMessageBuzzer { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: alertMessageVibra) { newAlertMessageVibra in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newAlertMessageVibra != node!.externalNotificationConfig!.alertMessageVibra { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: active) { newActive in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newActive != node!.externalNotificationConfig!.active { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: output) { newOutput in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newOutput != node!.externalNotificationConfig!.output { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: output) { newOutputBuzzer in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newOutputBuzzer != node!.externalNotificationConfig!.outputBuzzer { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: output) { newOutputVibra in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newOutputVibra != node!.externalNotificationConfig!.outputVibra { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: outputMilliseconds) { newOutputMs in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newOutputMs != node!.externalNotificationConfig!.outputMilliseconds { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: usePWM) { newUsePWM in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newUsePWM != node!.externalNotificationConfig!.usePWM { hasChanges = true }
|
||||
}
|
||||
}
|
||||
.onChange(of: nagTimeout) { newNagTimeout in
|
||||
if node != nil && node!.externalNotificationConfig != nil {
|
||||
if newNagTimeout != node!.externalNotificationConfig!.nagTimeout { hasChanges = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue